From 878ebb284dc8d26fb9e51e1f5c1e0dddc89e5917 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 6 Sep 2026 17:47:17 -0700 Subject: [PATCH 1/2] Start fix/306-idle-prompt From d956d3412f408cec528eaed4dde53f1f2fee1326 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 6 Sep 2026 17:54:52 -0700 Subject: [PATCH 2/2] Read what a Claude Code notification means before calling it 'needs you' (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Notification hook reported presence=awaitingInput for every notification without reading its payload. Claude Code fires one about sixty seconds after a turn ends — idle_prompt, meaning still idle — so every resting loop flipped to 'needs a human' a minute after it stopped, and since staged follow-ups and Mailroom wakes deliver only on idle, they were parked for good: 0 of 18 watched posts arrived. A notification.sh reporter now reads notification_type: idle_prompt confirms idle; permission_prompt and elicitation_dialog stay awaitingInput; auth_success leaves presence alone; an unknown or absent kind keeps today's behaviour, because a loop that genuinely needs someone becoming invisible is the worse failure. The hook falls back to today's report when the script is unreadable, and remote hosts are handed the script. Every NEEDS-YOU surface had been over-reporting for any loop idle past a minute. Closes #306. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DeGL2CxuGmq16RSZpJYm2N --- .../Sources/Sessions/PresenceHooks.swift | 76 ++++++- .../Tests/NotificationPresenceTests.swift | 190 ++++++++++++++++++ 2 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 graphcode/Tests/NotificationPresenceTests.swift 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") }) + } +}