From 8bcf9607876ebd6c40b7433e48e00459ad5d8b15 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 29 Aug 2026 18:30:56 -0700 Subject: [PATCH 1/2] Recover Claude loops after startup exits --- .../Sources/CLI/GraphcodeCommand.swift | 4 +- GraphcodeKit/Sources/Domain/LoopNode.swift | 4 + GraphcodeKit/Sources/Domain/Presence.swift | 4 +- GraphcodeKit/Sources/GraphStore.swift | 11 ++ GraphcodeKit/Sources/ProjectRegistry.swift | 5 + .../Sources/Sessions/CLISessionBackend.swift | 4 + .../Sources/Sessions/ClaudeTrust.swift | 33 +++++ .../Sources/Sessions/CopilotSessionLog.swift | 2 + .../Sources/Sessions/ZmxSessionLauncher.swift | 138 +++++++++++++++--- .../Ghostty/GhosttyTerminalView.swift | 5 + graphcode/Tests/ClaudeTrustTests.swift | 78 ++++++++++ graphcode/Tests/GraphcodeCommandTests.swift | 29 +++- graphcode/Tests/MessageAndSpawnTests.swift | 32 ++++ graphcode/Tests/PresenceReportingTests.swift | 26 ++++ .../Tests/RemoteSessionLaunchTests.swift | 25 +++- 15 files changed, 366 insertions(+), 34 deletions(-) create mode 100644 GraphcodeKit/Sources/Sessions/ClaudeTrust.swift create mode 100644 graphcode/Tests/ClaudeTrustTests.swift diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 969e08f7..08097e7b 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -678,7 +678,9 @@ extension GraphcodeCommand { } for node in graph.nodes { var line = " \(node.id) \(node.displayState) \(node.loopType) \(node.title)" - if let reason = AttentionRollup.reason(for: node) { + if let exitCode = node.presence?.exitCode { + line += " ← session exited (\(exitCode))" + } else if let reason = AttentionRollup.reason(for: node) { line += " ← \(reason.displayName)" } lines.append(line) diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 71248419..27a90222 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -335,6 +335,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// `nil` and `.unknown` count as no: opening is what could *start* a session, and a /// gate deciding whether that's safe must not treat "don't know" as "yes". public var presenceShowsLiveSession: Bool { + if presence?.exitCode != nil { return false } switch presence?.presence { case .busy, .idle, .awaitingInput: return true case .absent, .unknown, nil: return false @@ -366,6 +367,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// untouched, which is exactly the behaviour every surface had before presence existed. public var displayState: LoopState { guard state == .running, let presence = presence?.presence else { return state } + if let exitCode = self.presence?.exitCode { + return exitCode == 0 ? .idle : .failed + } switch presence { case .busy: return .running case .idle, .absent: return hasActiveDependents ? .waiting : .idle diff --git a/GraphcodeKit/Sources/Domain/Presence.swift b/GraphcodeKit/Sources/Domain/Presence.swift index bd6a6641..3b956781 100644 --- a/GraphcodeKit/Sources/Domain/Presence.swift +++ b/GraphcodeKit/Sources/Domain/Presence.swift @@ -43,10 +43,12 @@ public enum PresenceConfidence: String, Codable, Equatable, Sendable { public struct PresenceReading: Codable, Equatable, Sendable { public var presence: Presence public var confidence: PresenceConfidence + public var exitCode: Int? - public init(presence: Presence, confidence: PresenceConfidence) { + public init(presence: Presence, confidence: PresenceConfidence, exitCode: Int? = nil) { self.presence = presence self.confidence = confidence + self.exitCode = exitCode } public static let absent = PresenceReading(presence: .absent, confidence: .reported) diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index e06a7064..e36ddefd 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -41,6 +41,7 @@ public actor GraphStore { /// stays for the `until`-guard and for every test that stubs a bare yes/no. private let onCheckPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? private let onDeliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? + private let onRecoverSession: (@Sendable (LoopNode, String?) async -> Bool)? private let onCaptureScript: (@Sendable (ShellPredicate) async -> String?)? private let onReadUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? private let onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? @@ -182,6 +183,7 @@ public actor GraphStore { onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? = nil, onCheckPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? = nil, onDeliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? = nil, + onRecoverSession: (@Sendable (LoopNode, String?) async -> Bool)? = nil, onCaptureScript: (@Sendable (ShellPredicate) async -> String?)? = nil, onReadUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? = nil, onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? = nil, @@ -207,6 +209,7 @@ public actor GraphStore { self.onEvaluatePredicate = onEvaluatePredicate self.onCheckPredicate = onCheckPredicate self.onDeliverMessage = onDeliverMessage + self.onRecoverSession = onRecoverSession self.onCaptureScript = onCaptureScript self.onReadUsage = onReadUsage self.onReadActivity = onReadActivity @@ -474,6 +477,7 @@ public actor GraphStore { onEvaluatePredicate: onEvaluatePredicate, onCheckPredicate: onCheckPredicate, onDeliverMessage: onDeliverMessage, + onRecoverSession: onRecoverSession, onCaptureScript: onCaptureScript, onAppendMemory: onAppendMemory, onRemoveMemory: onRemoveMemory, @@ -1882,6 +1886,13 @@ public actor GraphStore { return } guard await deliverToSession(target, message) else { + if let onRecoverSession, await onRecoverSession(target, graph.project.path), + await deliverToSession(target, message) + { + graph.nodes[id: nodeID]?.presence = nil + recordMemory(nodeID, "session restarted to deliver: \(message)") + return + } recordMemory(nodeID, "while you were away: \(message)") announceError( "delivery to \(target.title)'s session failed — message staged to its memory; " diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index 4b6f0f85..e0f7bbfe 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -35,6 +35,7 @@ public actor ProjectRegistry { private let evaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? private let checkPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? private let deliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? + private let recoverSession: (@Sendable (LoopNode, String?) async -> Bool)? private let captureScript: (@Sendable (ShellPredicate) async -> String?)? private let readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? private let readActivity: (@Sendable (LoopNode, String?) async -> String?)? @@ -63,6 +64,8 @@ public actor ProjectRegistry { ShellPredicateEvaluator.check, deliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? = CLISessionBackend.deliverMessage, + recoverSession: (@Sendable (LoopNode, String?) async -> Bool)? = + CLISessionBackend.recoverSession, captureScript: (@Sendable (ShellPredicate) async -> String?)? = ShellPredicateEvaluator.capture, readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? = CLISessionBackend.readUsage, @@ -82,6 +85,7 @@ public actor ProjectRegistry { self.evaluatePredicate = evaluatePredicate self.checkPredicate = checkPredicate self.deliverMessage = deliverMessage + self.recoverSession = recoverSession self.captureScript = captureScript self.readUsage = readUsage self.readActivity = readActivity @@ -446,6 +450,7 @@ public actor ProjectRegistry { onEvaluatePredicate: evaluatePredicate, onCheckPredicate: checkPredicate, onDeliverMessage: deliverMessage, + onRecoverSession: recoverSession, onCaptureScript: captureScript, onReadUsage: readUsage, onReadActivity: readActivity, diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index 61a0cc87..2da4182c 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -239,6 +239,10 @@ extension CLISessionBackend { await backend(for: node).sendInput(node, text, path) } + public static let recoverSession: @Sendable (LoopNode, String?) async -> Bool = { node, path in + await ZmxSessionLauncher.restart(node, projectPath: path) + } + /// The usage-reading hook `GraphStore` is wired with. public static let readUsage: @Sendable (LoopNode, String?) async -> UsageSample? = { node, path in diff --git a/GraphcodeKit/Sources/Sessions/ClaudeTrust.swift b/GraphcodeKit/Sources/Sessions/ClaudeTrust.swift new file mode 100644 index 00000000..37e87d01 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/ClaudeTrust.swift @@ -0,0 +1,33 @@ +import Foundation + +public enum ClaudeTrust { + public static var configURL: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".claude.json") + } + + public static func ensureTrusted(directory: String, configURL: URL = configURL) { + guard !directory.isEmpty else { return } + var config: [String: Any] = [:] + if let data = try? Data(contentsOf: configURL), !data.isEmpty { + guard let parsed = try? JSONSerialization.jsonObject(with: data), + let dictionary = parsed as? [String: Any] + else { return } + config = dictionary + } + if let value = config["projects"], !(value is [String: Any]) { return } + var projects = config["projects"] as? [String: Any] ?? [:] + if let value = projects[directory], !(value is [String: Any]) { return } + var project = projects[directory] as? [String: Any] ?? [:] + guard project["hasTrustDialogAccepted"] as? Bool != true else { return } + project["hasTrustDialogAccepted"] = true + projects[directory] = project + config["projects"] = projects + guard + let data = try? JSONSerialization.data( + withJSONObject: config, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + else { return } + try? FileManager.default.createDirectory( + at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try? data.write(to: configURL, options: .atomic) + } +} diff --git a/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift b/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift index edaa7835..fcd2f704 100644 --- a/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift +++ b/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift @@ -466,6 +466,8 @@ public enum CopilotSessionLog { switch status { case .unreachable: return .unknown case .absent: return .absent + case .exited(let code): + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) case .live(let label): guard let label, let event = parseCopilotEventLabel(label), let p = presence(forEvent: event) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index d5e1bf62..ca29be13 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -272,6 +272,7 @@ public enum ZmxSessionLauncher { } guard ZmxLocator.isInstalled, !text.isEmpty else { return false } guard await sessionExists(node) else { return false } + guard await taskExitCode(of: node) == nil else { return false } // Typed in PTY-queue-sized pieces rather than one write — see `maxSendChunkBytes` // for what a single oversized write silently does. The pieces just accumulate in // the composer, exactly as the text and the later `\r` already do; nothing is @@ -318,15 +319,48 @@ public enum ZmxSessionLauncher { let session = try? PTYProcessSession( executable: ZmxLocator.binaryURL.path, arguments: presenceLabelArguments(forNode: node)) - else { return PresenceReading(presence: .idle, confidence: .heuristic) } + else { + return await exitedReading(of: node) + ?? PresenceReading(presence: .idle, confidence: .heuristic) + } let (succeeded, output) = await session.waitCollectingOutput() + if succeeded, let reported = parsePresenceLabel(output), reported == .busy { + return PresenceReading(presence: reported, confidence: .reported) + } + if let exited = await exitedReading(of: node) { return exited } guard succeeded, let reported = parsePresenceLabel(output) else { return PresenceReading(presence: .idle, confidence: .heuristic) } return PresenceReading(presence: reported, confidence: .reported) } + static func parseTaskExitCode(_ output: String) -> Int? { + guard let marker = output.range(of: "ZMX_TASK_COMPLETED:", options: .backwards) else { + return nil + } + let suffix = output[marker.upperBound...] + let digits = suffix.prefix(while: \.isNumber) + return digits.isEmpty ? nil : Int(digits) + } + + static func taskExitCode(of node: LoopNode) async -> Int? { + guard + let session = try? PTYProcessSession( + executable: ZmxLocator.binaryURL.path, + arguments: [ + "history", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, + ]) + else { return nil } + let (succeeded, output) = await session.waitCollectingOutput() + return succeeded ? parseTaskExitCode(output) : nil + } + + static func exitedReading(of node: LoopNode) async -> PresenceReading? { + guard let code = await taskExitCode(of: node) else { return nil } + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) + } + /// Codex's presence, which is read the other way up from everyone else's. /// /// Codex reports exactly one edge — `notify` fires on `agent-turn-complete`, writing @@ -436,6 +470,36 @@ public enum ZmxSessionLauncher { } } + static func restart(_ node: LoopNode, projectPath: String? = nil) async -> Bool { + if let projectPath, let remote = RemoteProjectLocation.parse(projectPath: projectPath) { + switch await remoteStatus(of: node, label: "presence", at: remote) { + case .absent, .exited: break + case .live, .unreachable: return false + } + guard await runRemoteRetrying(remoteKillInvocation(forNode: node, at: remote)) else { + return false + } + await startRemote(node, at: remote) + if case .live = await remoteStatus(of: node, label: "presence", at: remote) { + return true + } + return false + } + guard ZmxLocator.isInstalled else { return false } + let exists = await sessionExists(node) + if exists { + guard await taskExitCode(of: node) != nil else { return false } + guard + let session = try? PTYProcessSession( + executable: ZmxLocator.binaryURL.path, + arguments: killArguments(forNode: node)), + await session.waitUntilFinished() + else { return false } + } + await start(node, projectPath: projectPath) + return await sessionExists(node) + } + /// `zmx kill`, then proof: `zmx kill` exits 0 whether or not anything died, and /// `zmx get` exits 1 both for absence and for a timeout against a live busy session. /// A successful `zmx ls` that contains no row for the name is the only unambiguous @@ -906,18 +970,19 @@ public enum ZmxSessionLauncher { else { return nil } let check = quotedCommand(["zmx"] + existenceCheckArguments(forNode: node)) let run = remoteQuotedCommand(["zmx"] + zmxArguments) - // Copilot only, and remote only: an unattended Copilot queues its `--interactive` - // goal behind a per-session folder-trust dialog that nobody is present to answer, - // so a fresh remote Copilot loop booted to an idle screen with its goal parked - // forever (`--yolo` does not cover folder trust — measured). Pre-trusting the one - // repository the loop was pointed at, on the host it runs on, is the same consent - // the human gave by creating the loop there. The write is additive and idempotent - // (the `trustedFolders` list in `~/.copilot/config.json`, schema read off a real - // "remember this folder" answer), and any failure — no python3, malformed config — - // falls back to today's behaviour: the dialog, answerable by opening the loop. - let trustSeed = - node.backend == .copilotCLI - ? copilotTrustSeedScript(forRemotePath: location.remotePath) + "; " : "" + // Folder trust is outside both Claude's permission mode and Copilot's `--yolo`. + // Pre-trusting only the repository explicitly added to the graph keeps an unattended + // launch from parking its opening goal behind a dialog nobody is present to answer. + let trustSeed: String = { + switch node.backend { + case .claudeCode: + return claudeTrustSeedScript(forRemotePath: location.remotePath) + "; " + case .copilotCLI: + return copilotTrustSeedScript(forRemotePath: location.remotePath) + "; " + case .codex, .openCode: + return "" + } + }() let hooksWrite = PresenceHooks.remoteWriteFragment(forBackend: node.backend) .map { $0 + "; " } ?? "" @@ -1124,6 +1189,16 @@ public enum ZmxSessionLauncher { return quotedCommand(["python3", "-c", program, remotePath]) + " 2>/dev/null || true" } + static func claudeTrustSeedScript(forRemotePath remotePath: String) -> String { + let program = + "import json,os,sys; p=os.path.expanduser('~/.claude.json'); " + + "c=json.load(open(p)) if os.path.exists(p) else {}; t=sys.argv[1]; " + + "ps=c.get('projects') or {}; pr=ps.get(t) or {}; " + + "pr['hasTrustDialogAccepted']=True; ps[t]=pr; c['projects']=ps; " + + "open(p,'w').write(json.dumps(c)+'\\n')" + return quotedCommand(["python3", "-c", program, remotePath]) + " 2>/dev/null || true" + } + /// One argv as one shell-safe string — each argument quoted, so a prompt containing /// quotes, `$(…)`, or `;` stays one word through the remote shell exactly as it does /// through zmx's own quoting locally. Public because the app's remote *attach* is @@ -1135,8 +1210,8 @@ public enum ZmxSessionLauncher { /// The remote twin of the local text-then-Enter delivery, in one ssh round-trip: /// type, give the composer its beat, then the Enter as its own keystroke — the same /// paste-heuristic dance the local path does, run on the host that owns the session. - /// `zmx send` into a session that doesn't exist exits non-zero, so a dead remote - /// loop reports failure and the caller stages the message, exactly like local. + /// A missing session or one whose backend task already exited reports failure, so the + /// caller can recover it instead of typing into the stale shell left behind. static func remoteSendInvocation( _ text: String, toNode node: LoopNode, at location: RemoteProjectLocation ) -> [String] { @@ -1151,7 +1226,11 @@ public enum ZmxSessionLauncher { let clearCodexPresence = node.backend == .codex ? " && " + quotedCommand(["zmx", "set", sessionName, "presence="]) : "" - let script = "\(sends) && sleep 0.4 && \(submit)\(clearCodexPresence)" + let history = quotedCommand(["zmx", "history", sessionName]) + let liveBackend = + "! \(history) 2>/dev/null | tail -c 4096 | " + + "grep -Eq 'ZMX_TASK_COMPLETED:[0-9]+'" + let script = "\(liveBackend) && \(sends) && sleep 0.4 && \(submit)\(clearCodexPresence)" return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)) } @@ -1175,6 +1254,7 @@ public enum ZmxSessionLauncher { enum RemoteSessionStatus: Equatable { case unreachable case absent + case exited(code: Int) case live(label: String?) } @@ -1196,9 +1276,13 @@ public enum ZmxSessionLauncher { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName let check = quotedCommand(["zmx", "get", name]) let read = quotedCommand(["zmx", "get", name, label]) + let history = quotedCommand(["zmx", "history", name]) let script = "if \(check) >/dev/null 2>&1; then " - + "echo \"\(remoteProbeMarker) live $(\(read) 2>/dev/null)\"; " + + "gc_done=$(\(history) 2>/dev/null | tail -c 4096 | " + + "sed -n 's/.*ZMX_TASK_COMPLETED:\\([0-9][0-9]*\\).*/\\1/p' | tail -1); " + + "if [ -n \"$gc_done\" ]; then echo \"\(remoteProbeMarker) exited $gc_done\"; " + + "else echo \"\(remoteProbeMarker) live $(\(read) 2>/dev/null)\"; fi; " + "else echo '\(remoteProbeMarker) absent'; fi" return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)) } @@ -1229,6 +1313,11 @@ public enum ZmxSessionLauncher { let status = marked.dropFirst(remoteProbeMarker.count) .trimmingCharacters(in: .whitespaces) if status == "absent" { return .absent } + if status.hasPrefix("exited "), + let code = Int(status.dropFirst("exited ".count).trimmingCharacters(in: .whitespaces)) + { + return .exited(code: code) + } guard status.hasPrefix("live") else { return .unreachable } let label = status.dropFirst("live".count).trimmingCharacters(in: .whitespaces) return .live(label: label.isEmpty ? nil : label) @@ -1252,6 +1341,8 @@ public enum ZmxSessionLauncher { switch status { case .unreachable: return .unknown case .absent: return .absent + case .exited(let code): + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) case .live(let label): guard let label, let reported = parsePresenceLabel(label) else { return liveWithoutLabel } return PresenceReading(presence: reported, confidence: .reported) @@ -1359,11 +1450,14 @@ public enum ZmxSessionLauncher { DialLog.record(session: name, dial: "ensure", event: "resume-dead") SessionIDStore.remove(forNodeID: node.id) } - // The local half of the trust seed the remote ensure has always done: without it a - // fresh unattended Copilot parks its opening prompt behind the folder-trust dialog - // and swallows anything typed at it — the first pass included (`CopilotTrust`). - if node.backend == .copilotCLI, let directory = wd { - CopilotTrust.ensureTrusted(directory: directory) + // Folder trust is separate from backend permission modes. The path is the one the + // human explicitly added to the graph, and the write is additive and idempotent. + if let directory = wd { + if node.backend == .claudeCode { + ClaudeTrust.ensureTrusted(directory: directory) + } else if node.backend == .copilotCLI { + CopilotTrust.ensureTrusted(directory: directory) + } } // Noted *before* the launch: the first pass below waits for a Copilot session // directory that was not already there, which is how it tells a session it just diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 7f3f3710..9090602c 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -78,6 +78,11 @@ struct GhosttyTerminalView: NSViewRepresentable { /// view showing it. func makeNSView(context: Context) -> TerminalSurfaceHostView { let host = TerminalSurfaceHostView() + if launchesClaudeCode, backend == .claudeCode, remoteLocation == nil { + if let workingDirectory { + ClaudeTrust.ensureTrusted(directory: workingDirectory) + } + } let view = TerminalSurfaceStore.shared.surface(for: surfaceID) { // A remote surface needs the daemon's socket present on its host before the // delivered CLI can reach the graph — same forward the daemon's own launches diff --git a/graphcode/Tests/ClaudeTrustTests.swift b/graphcode/Tests/ClaudeTrustTests.swift new file mode 100644 index 00000000..14616a25 --- /dev/null +++ b/graphcode/Tests/ClaudeTrustTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +@Suite +struct ClaudeTrustTests { + private func temporaryConfig(_ contents: String?) -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent(".claude.json") + if let contents { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? Data(contents.utf8).write(to: url) + } + return url + } + + @Test + func trustIsAddedWithoutChangingExistingProjectSettings() throws { + let url = temporaryConfig( + #"{"projects":{"/tmp/project":{"allowedTools":["Bash"]}},"theme":"dark"}"#) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + ClaudeTrust.ensureTrusted(directory: "/tmp/project", configURL: url) + + let data = try Data(contentsOf: url) + let config = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let projects = try #require(config["projects"] as? [String: Any]) + let project = try #require(projects["/tmp/project"] as? [String: Any]) + #expect(project["hasTrustDialogAccepted"] as? Bool == true) + #expect(project["allowedTools"] as? [String] == ["Bash"]) + #expect(config["theme"] as? String == "dark") + } + + @Test + func aMissingConfigIsCreated() throws { + let url = temporaryConfig(nil) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + ClaudeTrust.ensureTrusted(directory: "/tmp/project", configURL: url) + + #expect(try String(contentsOf: url, encoding: .utf8).contains("hasTrustDialogAccepted")) + } + + @Test + func anAlreadyTrustedDirectoryLeavesTheFileUntouched() throws { + let url = temporaryConfig( + #"{"projects":{"/tmp/project":{"hasTrustDialogAccepted":true}}}"#) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let before = try Data(contentsOf: url) + + ClaudeTrust.ensureTrusted(directory: "/tmp/project", configURL: url) + + #expect(try Data(contentsOf: url) == before) + } + + @Test + func malformedConfigIsLeftUntouched() throws { + let url = temporaryConfig("{not json") + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + ClaudeTrust.ensureTrusted(directory: "/tmp/project", configURL: url) + + #expect(try String(contentsOf: url, encoding: .utf8) == "{not json") + } + + @Test + func anUnexpectedProjectsShapeIsLeftUntouched() throws { + let url = temporaryConfig(#"{"projects":"unexpected"}"#) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + ClaudeTrust.ensureTrusted(directory: "/tmp/project", configURL: url) + + #expect(try String(contentsOf: url, encoding: .utf8) == #"{"projects":"unexpected"}"#) + } +} diff --git a/graphcode/Tests/GraphcodeCommandTests.swift b/graphcode/Tests/GraphcodeCommandTests.swift index 1b821e5d..8d2705b2 100644 --- a/graphcode/Tests/GraphcodeCommandTests.swift +++ b/graphcode/Tests/GraphcodeCommandTests.swift @@ -106,12 +106,14 @@ struct GraphcodeCommandTests { func aDaemonBackedCodexTimeLoopIsAccepted() throws { // Codex has no in-session recurrence, but a leading simple directive is converted to // the graphcode daemon cadence rather than rejected as an unsupported pairing. - #expect(throws: Never.self, performing: { - try GraphcodeCommand.parse([ - "node", "create", "/tmp/x", "--title", "Poll", "--type", "time", - "--prompt", "/loop 1h Check", "--backend", "codex", - ]) - }) + #expect( + throws: Never.self, + performing: { + try GraphcodeCommand.parse([ + "node", "create", "/tmp/x", "--title", "Poll", "--type", "time", + "--prompt", "/loop 1h Check", "--backend", "codex", + ]) + }) // And the pairing that is fine now, which is the point of the change. #expect( throws: Never.self, @@ -314,6 +316,21 @@ struct GraphcodeCommandTests { #expect(output.contains("Failed")) } + @Test + func renderingAGraphShowsTheBackendExitCode() { + let node = LoopNode( + title: "Trust dialog", loopType: .goalBased, goal: GoalSpec(summary: "work"), + presence: PresenceReading(presence: .idle, confidence: .scanned, exitCode: 1), + state: .running) + let graph = LoopGraph( + project: ProjectRef(path: "/tmp/x", name: "x"), nodes: [node]) + + let output = GraphcodeCommand.render(graph) + + #expect(output.contains("failed")) + #expect(output.contains("session exited (1)")) + } + @Test func renderingAnEmptyGraphSaysSoRatherThanPrintingNothing() { let output = GraphcodeCommand.render( diff --git a/graphcode/Tests/MessageAndSpawnTests.swift b/graphcode/Tests/MessageAndSpawnTests.swift index 4075c11b..0a003bc6 100644 --- a/graphcode/Tests/MessageAndSpawnTests.swift +++ b/graphcode/Tests/MessageAndSpawnTests.swift @@ -288,4 +288,36 @@ struct MessageAndSpawnTests { #expect(delivered.value.isEmpty) } + + @Test + func anAdHocMessageRestartsADeadSessionAndRetriesDelivery() async { + let node = LoopNode( + title: "Worker", loopType: .goalBased, goal: GoalSpec(summary: "work"), + presence: PresenceReading(presence: .idle, confidence: .scanned, exitCode: 1), + state: .running) + let graph = LoopGraph( + project: ProjectRef(path: "/tmp/recover", name: "recover"), nodes: [node]) + let attempts = LockIsolated(0) + let recovered = LockIsolated<[String]>([]) + let remembered = LockIsolated<[String]>([]) + let store = GraphStore( + graph: graph, + onDeliverMessage: { _, _, _ in + attempts.withValue { $0 += 1 } + return attempts.value > 1 + }, + onRecoverSession: { target, path in + recovered.withValue { $0.append("\(target.title)@\(path ?? "")") } + return true + }, + onAppendMemory: { _, entry in remembered.withValue { $0.append(entry) } }) + + await store.handle(.messageNode(node.id, text: "keep going", from: nil, followUp: nil)) + + #expect(attempts.value == 2) + #expect(recovered.value == ["Worker@/tmp/recover"]) + #expect(remembered.value.contains { $0.contains("session restarted to deliver") }) + #expect(!remembered.value.contains { $0.contains("while you were away") }) + #expect(await store.graph.nodes[0].presence == nil) + } } diff --git a/graphcode/Tests/PresenceReportingTests.swift b/graphcode/Tests/PresenceReportingTests.swift index d8efe97d..8976fdad 100644 --- a/graphcode/Tests/PresenceReportingTests.swift +++ b/graphcode/Tests/PresenceReportingTests.swift @@ -317,6 +317,25 @@ struct PresenceReportingTests { #expect(node(.running, .absent).displayState == .idle) } + @Test + func aNonzeroBackendExitIsSurfacedAsFailureWithoutResolvingTheLoop() { + var exited = node(.running, .idle) + exited.presence = PresenceReading(presence: .idle, confidence: .scanned, exitCode: 1) + + #expect(exited.displayState == .failed) + #expect(exited.state == .running) + #expect(!exited.presenceShowsLiveSession) + } + + @Test + func zmxTaskCompletionMarkersExposeTheLastExitCode() { + #expect(ZmxSessionLauncher.parseTaskExitCode("ZMX_TASK_COMPLETED:1") == 1) + #expect( + ZmxSessionLauncher.parseTaskExitCode( + "old ZMX_TASK_COMPLETED:0\nnew ZMX_TASK_COMPLETED:17") == 17) + #expect(ZmxSessionLauncher.parseTaskExitCode("still running") == nil) + } + @Test func aQuietSessionWithActiveDependentsIsWaiting() { #expect(node(.running, .idle, activeDependents: true).displayState == .waiting) @@ -364,6 +383,13 @@ struct PresenceReportingTests { #expect(idleDecoded.presence?.presence == .idle) #expect(idleDecoded.displayState == .idle) + + var exited = node(.running, .idle) + exited.presence = PresenceReading(presence: .idle, confidence: .scanned, exitCode: 1) + let exitDecoded = try JSONDecoder().decode( + LoopNode.self, from: JSONEncoder().encode(exited)) + #expect(exitDecoded.presence?.exitCode == 1) + #expect(exitDecoded.displayState == .failed) } // MARK: - Remote Copilot event-log presence diff --git a/graphcode/Tests/RemoteSessionLaunchTests.swift b/graphcode/Tests/RemoteSessionLaunchTests.swift index f1e8b454..df5a93f9 100644 --- a/graphcode/Tests/RemoteSessionLaunchTests.swift +++ b/graphcode/Tests/RemoteSessionLaunchTests.swift @@ -73,16 +73,20 @@ struct RemoteSessionLaunchTests { } @Test - func aRemoteClaudeLaunchIsUntouchedByTrustSeeding() throws { - // The seed is Copilot-scoped: Claude's remote script must not carry it. (python3 - // itself now appears for every backend — the delivery fragment rides on it.) + func aRemoteClaudeLaunchSeedsFolderTrustFirst() throws { let node = LoopNode( title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: "tests pass")) let invocation = try #require( ZmxSessionLauncher.remoteEnsureInvocation(forNode: node, at: location)) let remoteCommand = try #require(invocation.last) - #expect(!remoteCommand.contains("trustedFolders")) + #expect(remoteCommand.contains("hasTrustDialogAccepted")) + #expect(remoteCommand.contains("/home/dev/widget")) + let get = try #require(remoteCommand.range(of: "'get'")) + let seed = try #require(remoteCommand.range(of: "hasTrustDialogAccepted")) + let run = try #require(remoteCommand.range(of: "'run'")) + #expect(get.lowerBound < seed.lowerBound) + #expect(seed.lowerBound < run.lowerBound) } @Test @@ -98,12 +102,25 @@ struct RemoteSessionLaunchTests { #expect(invocation.first == "/usr/bin/ssh") let remoteCommand = try #require(invocation.last) #expect(remoteCommand.contains("send")) + #expect(remoteCommand.contains("ZMX_TASK_COMPLETED")) #expect(remoteCommand.contains("task done")) #expect(remoteCommand.contains("sleep")) #expect( remoteCommand.contains(SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName)) } + @Test + func aRemoteCompletedTaskCarriesItsExitCode() { + let status = ZmxSessionLauncher.parseRemoteStatus( + succeeded: true, output: "graphcode-status: exited 1") + #expect(status == .exited(code: 1)) + let reading = ZmxSessionLauncher.presenceReading( + from: status, + liveWithoutLabel: PresenceReading(presence: .idle, confidence: .heuristic)) + #expect(reading.exitCode == 1) + #expect(reading.confidence == .scanned) + } + @Test func aRemoteCodexMessageClearsItsIdleLabelAfterSubmission() throws { let node = LoopNode( From 79c7c23ca66bc7f4cd8be7e2030a8946d0971c06 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 29 Aug 2026 19:06:11 -0700 Subject: [PATCH 2/2] Prevent duplicate Codex launch on first attach --- .../Infrastructure/Ghostty/GhosttyTerminalView.swift | 10 ++++++++++ graphcode/Tests/AttachedSessionBriefingTests.swift | 12 ++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 9090602c..69a1ff48 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -325,10 +325,20 @@ struct GhosttyTerminalView: NSViewRepresentable { if let resuming = localResumeOrFreshCommand(agentLaunch: agentCommand) { return resuming } + // Unattended Codex sessions are started by graphcoded. Attaching with the agent + // command as well creates a race where zmx run types that command into Codex. + if defersCodexLaunchToDaemon { + return command + } command += agentCommand return command } + var defersCodexLaunchToDaemon: Bool { + backend == .codex && launchesClaudeCode + && (loopType == .goalBased || loopType == .timeBased) + } + /// Opening a loop whose session is gone used to start the agent **fresh**, prompt and /// all, because this was the one launch path that could not resume — `agentCommand` /// carries `sessionPrompt`, and only the daemon knew about `SessionIDStore`. diff --git a/graphcode/Tests/AttachedSessionBriefingTests.swift b/graphcode/Tests/AttachedSessionBriefingTests.swift index 9e086618..7f57a5d8 100644 --- a/graphcode/Tests/AttachedSessionBriefingTests.swift +++ b/graphcode/Tests/AttachedSessionBriefingTests.swift @@ -17,11 +17,11 @@ struct AttachedSessionBriefingTests { private func surface( _ backend: CLISessionBackendKind, launchesClaudeCode: Bool = true, - initialPrompt: String? = "go" + initialPrompt: String? = "go", loopType: LoopType = .turnBased ) -> GhosttyTerminalView { GhosttyTerminalView( surfaceID: UUID(), sessionName: "s", launchesClaudeCode: launchesClaudeCode, - backend: backend, initialPrompt: initialPrompt, workingDirectory: nil, + backend: backend, loopType: loopType, initialPrompt: initialPrompt, workingDirectory: nil, projectPath: "/tmp/proj", onProcessExited: { _ in }) } @@ -62,6 +62,14 @@ struct AttachedSessionBriefingTests { #expect(prompt.contains(briefing)) } + @Test + func unattendedCodexLeavesFirstLaunchToTheDaemon() { + #expect(surface(.codex, loopType: .goalBased).defersCodexLaunchToDaemon) + #expect(surface(.codex, loopType: .timeBased).defersCodexLaunchToDaemon) + #expect(!surface(.codex).defersCodexLaunchToDaemon) + #expect(!surface(.claudeCode, loopType: .goalBased).defersCodexLaunchToDaemon) + } + @Test func noBriefingMeansTheCommandAndPromptOfBefore() { let command =