diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 5aafc96f..c6a5a3fa 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -487,9 +487,44 @@ public enum ZmxSessionLauncher { /// prefix of another session's name cannot pass. static func aliveCheckCommand(zmxPath: String, forNode node: LoopNode) -> String { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName - return RemoteProjectLocation.shellQuoted(zmxPath) - + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " - + RemoteProjectLocation.shellQuoted("name=\(name)\t") + return daemonReadyCheckCommand( + zmxPath: zmxPath, sessionName: name, + executable: node.backend == .codex ? node.backend.executableName : nil) + } + + public static func daemonReadyCheckCommand( + zmxPath: String, sessionName: String, executable: String? + ) -> String { + let name = RemoteProjectLocation.shellQuoted("name=\(sessionName)\t") + var command = + RemoteProjectLocation.shellQuoted(zmxPath) + + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " + name + if let executable { + command += + " && " + RemoteProjectLocation.shellQuoted(zmxPath) + + " ls 2>/dev/null | grep -v -e $'\\tended=' -e $'\\terr=' | grep -q " + + RemoteProjectLocation.shellQuoted("name=\(sessionName)\t.*cmd=.*\(executable)") + } + return command + } + + public static func waitingAttachCommand( + zmxPath: String, sessionName: String, executable: String? + ) -> [String] { + let check = daemonReadyCheckCommand( + zmxPath: zmxPath, sessionName: sessionName, executable: executable) + let attach = + RemoteProjectLocation.shellQuoted(zmxPath) + + " attach " + RemoteProjectLocation.shellQuoted(sessionName) + // The cap is for a session the daemon never creates — a launch that failed, a loop + // deleted mid-wait. Unbounded, the pane polls `zmx ls` twenty times a second + // forever; bounded, it says so and gives up after a minute. + let script = + "tries=0; until \(check); do tries=$((tries+1)); " + + "if [ \"$tries\" -ge 600 ]; then " + + "echo \"graphcode: '\(sessionName)' never became ready to attach\"; exit 1; fi; " + + "sleep 0.1; done; exec \(attach)" + return ["/bin/zsh", "-i", "-l", "-c", script] } /// Kills the session behind an id that isn't a graph node — a quick chat. Public @@ -1317,16 +1352,19 @@ public enum ZmxSessionLauncher { forNode node: LoopNode, label: String, at location: RemoteProjectLocation ) -> [String] { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName - let check = quotedCommand(["zmx", "get", name]) + let check = quotedCommand(["zmx", "ls"]) let read = quotedCommand(["zmx", "get", name, label]) - let history = quotedCommand(["zmx", "history", name]) + // The pattern is a fixed string, so the tab must be a real one: `grep -F` never + // interprets a `\t` escape, and the two characters would match nothing — every + // probe would read an existing session as absent. let script = - "if \(check) >/dev/null 2>&1; then " - + "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" + "gc_row=$(\(check) 2>/dev/null | grep -F \(quotedCommand(["name=\(name)\t"])) | head -1); " + + "if [ -z \"$gc_row\" ] || printf '%s' \"$gc_row\" | grep -q $'\\terr='; then " + + "echo '\(remoteProbeMarker) absent'; " + + "elif printf '%s' \"$gc_row\" | grep -q $'\\tended='; then " + + "gc_done=$(printf '%s' \"$gc_row\" | sed -n 's/.*\\texit_code=\\([0-9][0-9]*\\).*/\\1/p'); " + + "echo \"\(remoteProbeMarker) exited ${gc_done:-1}\"; " + + "else echo \"\(remoteProbeMarker) live $(\(read) 2>/dev/null)\"; fi" return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)) } diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index cca814d5..9ed029ae 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -300,7 +300,7 @@ struct GhosttyTerminalView: NSViewRepresentable { return environment } - private func command(briefingPath: String?) -> [String] { + func command(briefingPath: String?) -> [String] { // A remote project's surfaces live on the remote host, local zmx or not. if let location = remoteLocation { return remoteCommand(at: location, settings: GraphcodeSettingsStore.load()) @@ -322,13 +322,15 @@ struct GhosttyTerminalView: NSViewRepresentable { // wrapper needed for a plain-shell surface. var command = [ZmxLocator.binaryURL.path, "attach", sessionName] guard launchesClaudeCode else { return command } - 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 + return ZmxSessionLauncher.waitingAttachCommand( + zmxPath: ZmxLocator.binaryURL.path, sessionName: sessionName, + executable: backend.executableName) + } + if let resuming = localResumeOrFreshCommand(agentLaunch: agentCommand) { + return resuming } command += agentCommand return command diff --git a/graphcode/Tests/AttachedSessionBriefingTests.swift b/graphcode/Tests/AttachedSessionBriefingTests.swift index 7f57a5d8..fa6c417b 100644 --- a/graphcode/Tests/AttachedSessionBriefingTests.swift +++ b/graphcode/Tests/AttachedSessionBriefingTests.swift @@ -70,6 +70,17 @@ struct AttachedSessionBriefingTests { #expect(!surface(.claudeCode, loopType: .goalBased).defersCodexLaunchToDaemon) } + @Test + func unattendedCodexDoesNotResumeFromTheApp() { + guard ZmxLocator.isInstalled else { return } + let view = surface(.codex, loopType: .goalBased) + let command = view.command(briefingPath: briefing) + #expect(command.first == "/bin/zsh") + #expect(command.contains { $0.contains("until") && $0.contains("cmd=.*codex") }) + #expect(command.last?.contains("exec") == true) + #expect(command.last?.contains("attach 's'") == true) + } + @Test func noBriefingMeansTheCommandAndPromptOfBefore() { let command = diff --git a/graphcode/Tests/RemoteLoopSurvivalTests.swift b/graphcode/Tests/RemoteLoopSurvivalTests.swift index 0b967647..fae5a79c 100644 --- a/graphcode/Tests/RemoteLoopSurvivalTests.swift +++ b/graphcode/Tests/RemoteLoopSurvivalTests.swift @@ -51,6 +51,13 @@ struct RemoteLoopSurvivalTests { #expect(remoteCommand.contains(ZmxSessionLauncher.remoteProbeMarker)) #expect(remoteCommand.contains("'get'")) #expect(remoteCommand.contains("presence")) + #expect(remoteCommand.contains("'ls'")) + #expect(!remoteCommand.contains("history")) + // `grep -F` never interprets escapes: the name pattern's tab has to be a real one. + // A literal `\t` would match no row and every probe would report absent. + let sessionName = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + #expect(remoteCommand.contains("name=\(sessionName)\t'")) + #expect(!remoteCommand.contains("'name=\(sessionName)\\t'")) #expect( remoteCommand.contains(SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName)) } diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index d3ee62ea..cd5bf439 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -215,6 +215,29 @@ struct ZmxSessionLauncherTests { #expect(command.contains("'/usr/local/bin/zmx'")) } + @Test + func aCodexDaemonCheckRejectsABareAttachShell() { + let node = LoopNode( + title: "Codex", loopType: .goalBased, goal: GoalSpec(summary: "work"), backend: .codex) + let command = ZmxSessionLauncher.aliveCheckCommand( + zmxPath: "/usr/local/bin/zmx", forNode: node) + #expect(command.contains("cmd=.*codex")) + } + + @Test + func appWaitsForTheDaemonBeforeAttachingCodex() { + let command = ZmxSessionLauncher.waitingAttachCommand( + zmxPath: "/usr/local/bin/zmx", sessionName: "graphcode-a", executable: "codex") + #expect(command.first == "/bin/zsh") + #expect(command.last?.contains("until") == true) + #expect(command.last?.contains("cmd=.*codex") == true) + #expect(command.last?.contains("exec '/usr/local/bin/zmx' attach 'graphcode-a'") == true) + // A session the daemon never creates must not be polled at 10 Hz forever: the wait + // gives up after a minute with a message instead of spinning. + #expect(command.last?.contains("-ge 600") == true) + #expect(command.last?.contains("exit 1") == true) + } + @Test func theRemoteSeedPreTrustsClaudeCodesFolder() { // Issue #215's fix on the remote path: a fresh unattended `claude` exits 1 on its