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
4 changes: 3 additions & 1 deletion GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: return hasActiveDependents ? .waiting : .idle
Expand Down
4 changes: 3 additions & 1 deletion GraphcodeKit/Sources/Domain/Presence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion GraphcodeKit/Sources/Sessions/ClaudeCodeTrust.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ public enum ClaudeCodeTrust {
}

/// Sets `projects[directory].hasTrustDialogAccepted = true` unless it is already set.
/// Never throws and never clobbers: a config it cannot parse is left exactly as found.
/// Never throws and never clobbers: a config it cannot parse is left exactly as found,
/// and so is a config whose `projects` (or the project's own entry) holds something
/// other than the expected object — replacing it would trade one known value for a guess.
public static func ensureTrusted(directory: String, configURL: URL = configURL) {
guard !directory.isEmpty else { return }
let fileManager = FileManager.default
Expand All @@ -30,7 +32,9 @@ public enum ClaudeCodeTrust {
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 entry = projects[directory] as? [String: Any] ?? [:]
guard (entry["hasTrustDialogAccepted"] as? Bool) != true else { return }
entry["hasTrustDialogAccepted"] = true
Expand Down
2 changes: 2 additions & 0 deletions GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 38 additions & 4 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,14 +313,30 @@ public enum ZmxSessionLauncher {
of: node, at: remote,
liveWithoutLabel: PresenceReading(presence: .idle, confidence: .heuristic))
}
guard ZmxLocator.isInstalled, await sessionExists(node) else { return .absent }
guard ZmxLocator.isInstalled else { return .absent }
// The task state, not the session record, is the truth a husk hides: `zmx ls`
// prints `ended=`/`exit_code=` only for a completed task (`sessionTaskState`), so
// an exit read here is zmx's own bookkeeping — never a marker a transcript could
// have quoted. An exited task's code rides the reading; the wrapper shell left
// behind has nothing to say.
switch await sessionTaskState(node) {
case .absent: return .absent
case .exited(let code):
return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code)
case .alive: break
}
guard
let session = try? PTYProcessSession(
executable: ZmxLocator.binaryURL.path,
arguments: presenceLabelArguments(forNode: node))
else { return PresenceReading(presence: .idle, confidence: .heuristic) }
else {
return 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)
}
guard succeeded, let reported = parsePresenceLabel(output) else {
return PresenceReading(presence: .idle, confidence: .heuristic)
}
Expand Down Expand Up @@ -353,7 +369,13 @@ public enum ZmxSessionLauncher {
of: node, at: remote,
liveWithoutLabel: PresenceReading(presence: .busy, confidence: .scanned))
}
guard ZmxLocator.isInstalled, await sessionExists(node) else { return .absent }
guard ZmxLocator.isInstalled else { return .absent }
switch await sessionTaskState(node) {
case .absent: return .absent
case .exited(let code):
return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code)
case .alive: break
}
guard
let session = try? PTYProcessSession(
executable: ZmxLocator.binaryURL.path,
Expand Down Expand Up @@ -1275,6 +1297,7 @@ public enum ZmxSessionLauncher {
enum RemoteSessionStatus: Equatable {
case unreachable
case absent
case exited(code: Int)
case live(label: String?)
}

Expand All @@ -1296,9 +1319,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))
}
Expand Down Expand Up @@ -1329,6 +1356,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)
Expand All @@ -1352,6 +1384,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)
Expand Down
15 changes: 15 additions & 0 deletions graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
ClaudeCodeTrust.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
Expand Down Expand Up @@ -320,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`.
Expand Down
12 changes: 10 additions & 2 deletions graphcode/Tests/AttachedSessionBriefingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

Expand Down Expand Up @@ -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 =
Expand Down
10 changes: 10 additions & 0 deletions graphcode/Tests/ClaudeCodeTrustTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ struct ClaudeCodeTrustTests {
#expect(try String(contentsOf: url, encoding: .utf8) == "{not json at all")
}

/// A `projects` value the seed does not understand is left exactly as found too:
/// replacing it with a dictionary would trade the user's real state for a guess.
@Test
func anUnexpectedProjectsShapeIsLeftUntouched() throws {
let url = temporaryConfig(#"{"projects":"unexpected"}"#)
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
ClaudeCodeTrust.ensureTrusted(directory: "/tmp/project", configURL: url)
#expect(try String(contentsOf: url, encoding: .utf8) == #"{"projects":"unexpected"}"#)
}

@Test
func anEmptyDirectoryIsNeverWritten() throws {
let url = temporaryConfig(#"{"projects":{}}"#)
Expand Down
29 changes: 23 additions & 6 deletions graphcode/Tests/GraphcodeCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions graphcode/Tests/PresenceReportingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,16 @@ 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 aGoneSessionOnAnUnattendedLoopPastTheGraceIsFailed() {
// Issue #215: a goal loop whose agent exited on its first turn showed IDLE — the
Expand Down Expand Up @@ -404,6 +414,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
Expand Down
16 changes: 16 additions & 0 deletions graphcode/Tests/RemoteSessionLaunchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,28 @@ struct RemoteSessionLaunchTests {
#expect(invocation.first == "/usr/bin/ssh")
let remoteCommand = try #require(invocation.last)
#expect(remoteCommand.contains("send"))
// The send is gated on the husk-aware alive check (#215): a session whose task has
// ended must fail it, or the keystrokes land at the husk's shell prompt.
#expect(remoteCommand.contains("ls 2>/dev/null"))
#expect(remoteCommand.contains("ended="))
#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(
Expand Down
Loading