diff --git a/GraphcodeKit/Sources/Sessions/PresenceHooks.swift b/GraphcodeKit/Sources/Sessions/PresenceHooks.swift index 087cfdc6..ae9832b6 100644 --- a/GraphcodeKit/Sources/Sessions/PresenceHooks.swift +++ b/GraphcodeKit/Sources/Sessions/PresenceHooks.swift @@ -46,6 +46,11 @@ public enum PresenceHooks { directory.appendingPathComponent("usage.sh") } + /// The `Notification` reporter — the one hook whose meaning depends on its payload. + public static var notificationScriptFile: URL { + directory.appendingPathComponent("notification.sh") + } + /// Which of a backend's lifecycle events mean what, in the backend's own event names. /// /// `nil` for a backend with no hook mechanism at all, which is the honest answer for @@ -109,6 +114,52 @@ public enum PresenceHooks { "if [ -r \(scriptPath) ]; then /bin/sh \(scriptPath); fi; exit 0" } + /// The `Notification` body: the reporter decides from the payload, and a missing + /// reporter falls back to what every notification meant before it existed — + /// `awaitingInput`, the state a human is shown when a loop needs them. Falling back + /// to *that* rather than to nothing is deliberate: a loop that genuinely asked a + /// question must never become invisible because a script was not there. + static func reportNotification(scriptPath: String, zmxPath: String) -> String { + "if [ -r \(scriptPath) ]; then /bin/sh \(scriptPath); else " + + "\(report(.awaitingInput, zmxPath: zmxPath, clearingActivity: true)); fi; exit 0" + } + + /// What a `Notification` means, by its kind (issue #306). + /// + /// Claude Code fires a `Notification` for several unrelated things and names which in + /// `notification_type`. Mapping every one to `awaitingInput` was the defect: about a + /// minute after a turn ends it sends `idle_prompt` — "still idle" — and that flipped + /// every resting loop to *needs a human*. Staged follow-ups and Mailroom wakes deliver + /// only on `presence == idle`, so for any Claude Code loop idle over a minute, which + /// is most loops most of the time, `mail watch` and `node send --follow-up` silently + /// delivered nothing (0 of 18 measured), and the sidebar said NEEDS YOU of a loop that + /// needed nobody. + /// + /// The mapping errs the other way on purpose. Only `idle_prompt` confirms idle. + /// `permission_prompt` and `elicitation_dialog` are a real question to the human and + /// stay `awaitingInput`; `auth_success` is neither and leaves presence alone; and a + /// kind this script does not know — including no kind at all, from a Claude Code that + /// predates the field — keeps today's behaviour, because the failure on that side is + /// worse: a loop that genuinely needs someone, invisible. + static func notificationScript(zmxPath: String) -> String { + """ + # Written by graphcode. Reports what a Claude Code notification means for this + # session's presence: idle_prompt confirms idle; a real prompt to the human is + # awaitingInput; anything unknown is treated as one, never as idle. + [ -n "$ZMX_SESSION" ] || exit 0 + payload=$(head -c 4096 | tr '\\n' ' ') + kind=$(printf '%s' "$payload" | + sed -n 's/.*"notification_type"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p') + case "$kind" in + idle_prompt) presence=idle ;; + auth_success) exit 0 ;; + *) presence=awaitingInput ;; + esac + \(singleQuoted(zmxPath)) set "$ZMX_SESSION" "presence=$presence" activity= >/dev/null 2>&1 + exit 0 + """ + } + /// The `Stop`/`SessionEnd` body that runs `usageScript`, guarded the same way and for /// the same reasons as `reportActivity` — and kept beside the presence report rather /// than folded in, so token accounting going wrong can never stop presence being @@ -286,6 +337,14 @@ public enum PresenceHooks { public static let remoteUsageScriptExpression = "\"$HOME/.graphcode/hooks/usage.sh\"" + /// Where `notificationScript` lands, in the same two shapes. + static var localNotificationScriptExpression: String { + singleQuoted(notificationScriptFile.path) + } + + public static let remoteNotificationScriptExpression = + "\"$HOME/.graphcode/hooks/notification.sh\"" + public static let remoteOpenCodeConfigPath = "$HOME/.graphcode/hooks/openCode.json" public static let remoteOpenCodePluginExpression = "\"$HOME/.graphcode/hooks/opencode-presence.js\"" @@ -308,7 +367,8 @@ public enum PresenceHooks { public static func json( forBackend backend: CLISessionBackendKind, zmxPath: String, sessionsDirectory: String? = nil, activityScriptPath: String? = nil, - usageScriptPath: String? = nil, openCodePluginPath: String? = nil + usageScriptPath: String? = nil, openCodePluginPath: String? = nil, + notificationScriptPath: String? = nil ) -> String? { if backend == .openCode { return OpenCodePresencePlugin.config( @@ -318,10 +378,15 @@ public enum PresenceHooks { let sessions = sessionsDirectory ?? localSessionsExpression let script = activityScriptPath ?? localActivityScriptExpression let usage = usageScriptPath ?? localUsageScriptExpression + let notification = notificationScriptPath ?? localNotificationScriptExpression let hooks = events.reduce(into: [String: [Matcher]]()) { result, event in let isToolUse = event.0 == "PreToolUse" + let isNotification = event.0 == "Notification" && backend == .claudeCode var commands = [ - Command(command: report(event.1, zmxPath: zmxPath, clearingActivity: !isToolUse)) + Command( + command: isNotification + ? reportNotification(scriptPath: notification, zmxPath: zmxPath) + : report(event.1, zmxPath: zmxPath, clearingActivity: !isToolUse)) ] if isToolUse && backend == .claudeCode { commands.append(Command(command: reportActivity(scriptPath: script))) @@ -362,6 +427,8 @@ public enum PresenceHooks { .write(to: activityScriptFile, atomically: true, encoding: .utf8) try? usageScript(zmxPath: ZmxLocator.binaryURL.path) .write(to: usageScriptFile, atomically: true, encoding: .utf8) + try? notificationScript(zmxPath: ZmxLocator.binaryURL.path) + .write(to: notificationScriptFile, atomically: true, encoding: .utf8) if backend == .openCode { // Not best-effort: the config names this file, and OpenCode reports a plugin it // cannot load as an error in the session. No plugin means no config. @@ -418,7 +485,8 @@ public enum PresenceHooks { forBackend: .claudeCode, zmxPath: "zmx", sessionsDirectory: remoteSessionsExpression, activityScriptPath: remoteActivityScriptExpression, - usageScriptPath: remoteUsageScriptExpression) + usageScriptPath: remoteUsageScriptExpression, + notificationScriptPath: remoteNotificationScriptExpression) else { return nil } // `|| true` so both call sites can chain it with `&&` — a failed write must never // block the launch it precedes. The reporters go first: the settings that name them @@ -429,6 +497,8 @@ public enum PresenceHooks { + " > \(remoteActivityScriptExpression)" + " && printf '%s' \(singleQuoted(usageScript(zmxPath: "zmx")))" + " > \(remoteUsageScriptExpression)" + + " && printf '%s' \(singleQuoted(notificationScript(zmxPath: "zmx")))" + + " > \(remoteNotificationScriptExpression)" + " && printf '%s' \(singleQuoted(json))" + " > \"\(remotePathExpression)\"; } 2>/dev/null || true" } diff --git a/graphcode/Tests/NotificationPresenceTests.swift b/graphcode/Tests/NotificationPresenceTests.swift new file mode 100644 index 00000000..dd36a5d2 --- /dev/null +++ b/graphcode/Tests/NotificationPresenceTests.swift @@ -0,0 +1,190 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +@testable import GraphcodeKit + +/// Issue #306: Claude Code fires a `Notification` about a minute after a turn ends — +/// `idle_prompt`, "still idle" — and graphcode mapped every notification to +/// `awaitingInput`. So every resting loop flipped to *needs a human* sixty seconds after +/// it stopped, and since staged follow-ups and Mailroom wakes deliver only on `idle`, +/// they never arrived (0 of 18 measured). The reporter now reads the kind, and errs +/// towards `awaitingInput` for anything it cannot classify: a loop that genuinely asked +/// a question must never become invisible. +@Suite +struct NotificationPresenceTests { + /// Runs the generated reporter the way Claude Code runs it — `/bin/sh`, the payload on + /// stdin — against a `zmx` that records what it was asked to set. The labels it set, + /// or empty if it set nothing. + private func labels(for payload: String, session: String = "graphcode-test") throws + -> [String] + { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("graphcode-notification-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let recording = directory.appendingPathComponent("set.txt") + let fakeZmx = directory.appendingPathComponent("zmx") + try "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '\(recording.path)'\n" + .write(to: fakeZmx, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: fakeZmx.path) + let script = directory.appendingPathComponent("notification.sh") + try PresenceHooks.notificationScript(zmxPath: fakeZmx.path) + .write(to: script, atomically: true, encoding: .utf8) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [script.path] + process.environment = ["ZMX_SESSION": session, "PATH": "/usr/bin:/bin"] + let input = Pipe() + process.standardInput = input + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + input.fileHandleForWriting.write(Data(payload.utf8)) + try input.fileHandleForWriting.close() + process.waitUntilExit() + // Never non-zero: a failing hook prints over the human's own session. + #expect(process.terminationStatus == 0) + guard let recorded = try? String(contentsOf: recording, encoding: .utf8) else { return [] } + return recorded.split(separator: "\n").map(String.init) + } + + private func payload(kind: String?) -> String { + let type = kind.map { "\"notification_type\":\"\($0)\"," } ?? "" + return """ + {"session_id":"a","cwd":"/w","hook_event_name":"Notification",\(type)\ + "message":"Claude is waiting for your input","title":"Claude Code"} + """ + } + + private func presence(_ labels: [String]) -> Presence? { + guard let line = labels.last else { return nil } + let label = line.split(separator: " ").first { $0.hasPrefix("presence=") } + return label.flatMap { Presence(rawValue: String($0.dropFirst("presence=".count))) } + } + + @Test + func anIdlePromptConfirmsIdleAndClearsTheActivity() throws { + let set = try labels(for: payload(kind: "idle_prompt")) + #expect(presence(set) == .idle) + #expect(set.last?.contains(" activity=") == true) + #expect(set.last?.hasPrefix("set graphcode-test ") == true) + } + + @Test + func aRealPromptToTheHumanIsAwaitingInput() throws { + #expect(try presence(labels(for: payload(kind: "permission_prompt"))) == .awaitingInput) + #expect(try presence(labels(for: payload(kind: "elicitation_dialog"))) == .awaitingInput) + } + + /// The mirror of the bug, refused: a kind this build does not know, or no kind at all + /// from a Claude Code that predates the field, keeps today's behaviour — never idle. + @Test + func anUnknownOrAbsentKindKeepsTodaysBehaviour() throws { + #expect(try presence(labels(for: payload(kind: "something_newer"))) == .awaitingInput) + #expect(try presence(labels(for: payload(kind: nil))) == .awaitingInput) + } + + @Test + func anAuthSuccessSaysNothingAboutPresence() throws { + #expect(try labels(for: payload(kind: "auth_success")).isEmpty) + } + + @Test + func aSessionGraphcodeDidNotStartIsLeftAlone() throws { + #expect(try labels(for: payload(kind: "idle_prompt"), session: "").isEmpty) + } + + /// The hook file runs the reporter, and falls back to today's report — not to + /// nothing — when the reporter is missing. + @Test + func theHookRunsTheReporterAndFallsBackToAwaitingInput() throws { + let json = try #require( + PresenceHooks.json( + forBackend: .claudeCode, zmxPath: "/opt/zmx", notificationScriptPath: "'/hooks/n.sh'")) + let object = try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any] + let hooks = try #require(object?["hooks"] as? [String: Any]) + let notification = try #require(hooks["Notification"] as? [[String: Any]]) + let commands = try #require(notification.first?["hooks"] as? [[String: Any]]) + let command = try #require(commands.first?["command"] as? String) + #expect(command.hasPrefix("if [ -r '/hooks/n.sh' ]; then /bin/sh '/hooks/n.sh'; else ")) + #expect(command.contains("presence=awaitingInput activity=")) + #expect(command.hasSuffix("; fi; exit 0")) + // Every other event still reports its fixed presence. + let stop = try #require( + (hooks["Stop"] as? [[String: Any]])?.first?["hooks"] as? [[String: Any]]) + #expect((stop.first?["command"] as? String)?.contains("presence=idle") == true) + // And the remote host is handed the reporter beside the settings that name it. + let fragment = try #require(PresenceHooks.remoteWriteFragment(forBackend: .claudeCode)) + #expect(fragment.contains("notification.sh")) + #expect(fragment.contains("idle_prompt) presence=idle")) + } + + /// The whole path, the way MailWatcher measured it: a loop that has been idle past the + /// notification receives the Mailroom wake and the `--follow-up` staged for it — on + /// exactly the presence the reporter writes for `idle_prompt`, and not on the one it + /// writes for a permission prompt. + @Test + func aLoopIdlePastTheNotificationReceivesItsWakeAndItsFollowUp() async throws { + let afterIdlePrompt = try #require(presence(labels(for: payload(kind: "idle_prompt")))) + let afterPermission = try #require( + presence(labels(for: payload(kind: "permission_prompt")))) + + actor Readings { + var presence: Presence = .busy + func set(_ presence: Presence) { self.presence = presence } + func read() -> PresenceReading { PresenceReading(presence: presence, confidence: .reported) } + } + let readings = Readings() + let delivered = LockIsolated<[String]>([]) + let store = GraphStore( + onEnsureSession: { _, _ in }, + onDeliverMessage: { _, message, _ in + delivered.withValue { $0.append(message) } + return true + }, + onReadPresence: { _, _ in await readings.read() }, + onMailroomEnabled: { true }) + await store.handle( + .createNode(NodeDraft(title: "Watcher", loopType: .turnBased, firstInstruction: "Work"))) + await store.handle( + .createNode(NodeDraft(title: "Poster", loopType: .turnBased, firstInstruction: "Work"))) + let ids = await store.graph.nodes.map(\.id) + // A connection so the poll runs, with a channel lifecycle of its own and a reader + // draining it: a bare `/dev/null` descriptor can be dropped as "disconnected" when + // its number is reused across the suite, and then the poll never drains. + var pair: [Int32] = [0, 0] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0) + let drainer = Task.detached { + var sink = [UInt8](repeating: 0, count: 65536) + while recv(pair[1], &sink, sink.count, 0) > 0 {} + } + defer { + OutboundChannels.close(pair[0]) + close(pair[1]) + drainer.cancel() + } + await store.addConnection(id: UUID(), fileDescriptor: pair[0]) + await store.pollPresence() + await store.handle(.mailroomWatch(on: true, topic: "e2e", from: ids[0])) + await store.handle(.mailroomPost(text: "matching post", topic: "e2e", from: ids[1])) + await store.handle(.messageNode(ids[0], text: "a follow-up", from: ids[1], followUp: true)) + #expect(delivered.value.isEmpty) + + // Sixty seconds later, the old mapping: the loop reads as needing a human, and the + // drain — which this state was chosen to gate — hands over nothing. + await readings.set(afterPermission) + await store.pollPresence() + #expect(delivered.value.isEmpty) + + // The new mapping of the same idle notification: still idle, and both arrive. + await readings.set(afterIdlePrompt) + await store.pollPresence() + #expect(delivered.value.count == 2) + #expect(delivered.value.contains { $0.contains("new post #1") }) + #expect(delivered.value.contains { $0.contains("a follow-up") }) + } +}