Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 49 additions & 11 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions graphcode/Tests/AttachedSessionBriefingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
7 changes: 7 additions & 0 deletions graphcode/Tests/RemoteLoopSurvivalTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
23 changes: 23 additions & 0 deletions graphcode/Tests/ZmxSessionLauncherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading