From 60d744fd74295b801696d2b3bf85884b895f668c Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:17:18 -0700 Subject: [PATCH 1/6] Start Nod launch wiring Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi From 5adb42a61c6b3218e7c16c23f76d684bec896a31 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:26:22 -0700 Subject: [PATCH 2/6] Launch Nod sessions and read their event log graphcode-nod runs in zmx like every backend: resolved from the app bundle (GRAPHCODE_NOD_PATH overrides), launched with --node/--engine/--loop-type and NOD_STATE, resumed with --resume . One fold over events.jsonl (NodSessionFold) answers presence, the live line, usage, summary beats and the goal verdict; an open permissionAsked is Needs you. Messages go through control.sock as queued sends. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Sources/Domain/BackendCapabilities.swift | 5 +- .../Sources/Domain/BackendCommand.swift | 48 ++++-- .../Domain/CLISessionBackendKind.swift | 4 +- GraphcodeKit/Sources/Domain/NodProtocol.swift | 28 ++++ .../Sources/Domain/NodSessionFold.swift | 139 ++++++++++++++++++ .../Sources/Sessions/CLISessionBackend.swift | 20 ++- .../Sources/Sessions/GoalVerdictReader.swift | 5 +- .../Sources/Sessions/NodControlClient.swift | 123 ++++++++++++++++ .../Sources/Sessions/NodRuntimeLocator.swift | 58 ++++++++ .../Sources/Sessions/NodSessionLog.swift | 136 +++++++++++++++++ .../Sources/Sessions/ProviderPath.swift | 4 + .../Sources/Sessions/ZmxSessionLauncher.swift | 70 +++++++-- NodRuntime/PROTOCOL.md | 27 +++- Package.swift | 3 + .../Ghostty/GhosttyTerminalView+Nod.swift | 26 ++++ .../Ghostty/GhosttyTerminalView.swift | 7 +- 16 files changed, 667 insertions(+), 36 deletions(-) create mode 100644 GraphcodeKit/Sources/Domain/NodSessionFold.swift create mode 100644 GraphcodeKit/Sources/Sessions/NodControlClient.swift create mode 100644 GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift create mode 100644 GraphcodeKit/Sources/Sessions/NodSessionLog.swift create mode 100644 graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift diff --git a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift index ad8120ab..76b95509 100644 --- a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift +++ b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift @@ -280,8 +280,9 @@ extension CLISessionBackendKind { /// than refusing, so this now gates every loop type. /// Every CLI, now that each has an adapter and a row read off its real binary. Nod is /// the next backend the concept was kept for: its row is designed, but nothing can - /// launch it until NodRuntime ships, so it hosts nothing yet. - public var isSpiked: Bool { self != .nod } + /// launch it until NodRuntime ships, so it hosts nothing yet — except where a developer + /// points `NodRuntimeLocation.overrideVariable` at a runtime (or a fake of one). + public var isSpiked: Bool { self != .nod || NodRuntimeLocation.developmentOverride != nil } /// Whether this backend can host that loop type at all. The refusal /// docs/04-cli-backends.md asks `OrchestratorClient` to make, kept next to the diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index 9aee1ca1..3d249d26 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -18,9 +18,9 @@ extension CLISessionBackendKind { case .codex: return "codex" case .openCode: return "opencode" case .pi: return "pi" - // Launchable once NodRuntime ships its `graphcode-nod` binary — until then `isSpiked` - // keeps every loop type off it. - case .nod: return nil + // Never looked up on PATH: the launchers resolve it from the app bundle + // (`NodRuntimeLocator`), and `isSpiked` keeps loop types off it until it is there. + case .nod: return "graphcode-nod" } } @@ -165,10 +165,28 @@ extension CLISessionBackendKind { preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt)) ] case .nod: - return model + // The briefing by path, like Claude Code's: the runtime reads it into its system + // prompt, so the prompt stays the human's own words. + return model + (briefingPath.map { ["--briefing", $0] } ?? []) + ["--prompt", prompt] } } + /// What a Nod session is launched with ahead of `launchArguments`: which node it is + /// (where its state lives, what the graphcode MCP server reports as "self"), the loop + /// type that picks its model, and the engine. Empty for every CLI. + public func nodArguments( + nodeID: UUID, loopType: LoopType, settings: GraphcodeSettings, + workingDirectory: String? = nil, goalFile: String? = nil + ) -> [String] { + guard self == .nod else { return [] } + let nod = settings.nod + return ["--node", nodeID.uuidString] + + (workingDirectory.map { ["--cwd", $0] } ?? []) + + ["--engine", nod.engine.rawValue, "--loop-type", loopType.nodArgument] + + (nod.model(for: loopType).map { ["--model", $0] } ?? []) + + (goalFile.map { ["--goal-file", $0] } ?? []) + } + /// pi reads a positional argument that starts with `-` as an option and one that starts /// with `@` as a file to attach (`cli/args.js`), so a goal opening with a bullet or a /// mention would never reach the agent. A leading space keeps it a message. `--` is not @@ -182,9 +200,9 @@ extension CLISessionBackendKind { /// same answer `launchArguments` gives. public var promptFlag: String? { switch self { - case .claudeCode, .codex, .pi, .nod: return nil + case .claudeCode, .codex, .pi: return nil case .copilotCLI: return "--interactive" - case .openCode: return "--prompt" + case .openCode, .nod: return "--prompt" } } @@ -234,17 +252,16 @@ extension CLISessionBackendKind { /// support changes both paths together rather than one silently drifting. public var supportsResume: Bool { self == .claudeCode || self == .copilotCLI || self == .codex || self == .openCode - || self == .pi + || self == .pi || self == .nod } /// The argv that picks `sessionID` back up. OpenCode's `--session` and Codex's /// `resume ` both name the exact conversation rather than selecting the last one. public func resumeArguments(sessionID: String) -> [String] { switch self { - case .claudeCode, .copilotCLI: return ["--resume", sessionID] + case .claudeCode, .copilotCLI, .nod: return ["--resume", sessionID] case .codex: return ["resume", sessionID] case .openCode, .pi: return ["--session", sessionID] - case .nod: return [] } } @@ -320,3 +337,16 @@ extension CLISessionBackendKind { } } } + +extension LoopType { + /// `graphcode-nod --loop-type`'s spelling — NodRuntime/README.md. + public var nodArgument: String { + switch self { + case .sketch: return "main" + case .goalBased: return "goal" + case .timeBased: return "timed" + case .turnBased: return "turn" + case .composite: return "composite" + } + } +} diff --git a/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift b/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift index 25c7cba8..de752ac5 100644 --- a/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift +++ b/GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift @@ -21,8 +21,8 @@ public enum CLISessionBackendKind: String, Codable, CaseIterable, Sendable { /// Whether the daemon can read this backend's own goal verdict. public var recordsGoalVerdict: Bool { switch self { - case .claudeCode, .codex, .copilotCLI: return true - case .openCode, .pi, .nod: return false + case .claudeCode, .codex, .copilotCLI, .nod: return true + case .openCode, .pi: return false } } } diff --git a/GraphcodeKit/Sources/Domain/NodProtocol.swift b/GraphcodeKit/Sources/Domain/NodProtocol.swift index 782a0aab..16e81d3a 100644 --- a/GraphcodeKit/Sources/Domain/NodProtocol.swift +++ b/GraphcodeKit/Sources/Domain/NodProtocol.swift @@ -10,6 +10,17 @@ public enum NodProtocol { /// Bumped only for a change an older reader would misread. Additive fields and new /// event types do not bump it: readers decode unknown types as `.unknown` instead. public static let version = 1 + + /// The variable graphcode sets to the runtime's state directory. The runtime falls back + /// to `~/.graphcode/nod/` without it, which is wrong for any workspace whose + /// support directory was moved (`GRAPHCODE_SUPPORT_DIR`). + public static let stateDirectoryVariable = "NOD_STATE" + public static let eventsFileName = "events.jsonl" + public static let controlSocketName = "control.sock" + public static let conversationFileName = "conversation.json" + /// The goal a goal loop is launched with, written by graphcode beside the event log and + /// passed as `--goal-file`. + public static let goalFileName = "goal.md" } // MARK: - Shared vocabulary @@ -590,3 +601,20 @@ extension NodProtocol { } } } + +/// Where `graphcode-nod` comes from. The bundle and support-directory halves are resolved +/// in `NodRuntimeLocator`; this is the part every layer can see. +public enum NodRuntimeLocation { + /// Points graphcode at a runtime outside the app bundle — a development build, or the + /// fake runtime the tests drive. Read from the environment of whichever process + /// launches the session: the app for a pane, `graphcoded` for an unattended loop. + public static let overrideVariable = "GRAPHCODE_NOD_PATH" + + /// The override, when it names an executable file. + public static var developmentOverride: String? { + guard let path = ProcessInfo.processInfo.environment[overrideVariable], !path.isEmpty, + FileManager.default.isExecutableFile(atPath: path) + else { return nil } + return path + } +} diff --git a/GraphcodeKit/Sources/Domain/NodSessionFold.swift b/GraphcodeKit/Sources/Domain/NodSessionFold.swift new file mode 100644 index 00000000..29d067d3 --- /dev/null +++ b/GraphcodeKit/Sources/Domain/NodSessionFold.swift @@ -0,0 +1,139 @@ +import Foundation + +/// How far a goal loop's evaluator says it has got — the card's progress bar. +public struct NodGoalProgress: Equatable, Sendable { + public var met: Int + public var total: Int + + public init(met: Int, total: Int) { + self.met = met + self.total = total + } +} + +/// What a Nod loop's canvas card shows beyond what every backend reports. Folded from the +/// event log by the daemon, so the card never reads `events.jsonl` itself. +public struct NodCardState: Equatable, Sendable { + public var goalProgress: NodGoalProgress? + /// The newest permission ask nobody has answered; `answerableFromCard` says whether the + /// card may offer Allow once or must open the chat. + public var pendingAsk: NodEvent.PermissionAsked? + + public init(goalProgress: NodGoalProgress? = nil, pendingAsk: NodEvent.PermissionAsked? = nil) { + self.goalProgress = goalProgress + self.pendingAsk = pendingAsk + } +} + +/// The one fold over `events.jsonl` every reading of a Nod session comes from: presence, +/// the live line, usage, the goal verdict and the card state. Only the current run +/// counts — a `sessionStarted` resets everything, because what an earlier process asked +/// or spent says nothing about the one running now. +public struct NodSessionFold: Equatable, Sendable { + public private(set) var session: NodEvent.SessionStarted? + /// What the turn boundaries say, before asks and failures are considered. + public private(set) var turnPresence: Presence? + public private(set) var openAsks: [NodEvent.PermissionAsked] = [] + /// Cleared by the next turn: a failure is a banner until Nod runs again. + public private(set) var failure: NodEvent.Failure? + public private(set) var activity: String? + public private(set) var usage: NodEvent.Usage? + public private(set) var usageAt: Date? + public private(set) var goalCheck: NodEvent.GoalCheck? + public private(set) var goalCheckAt: Date? + public private(set) var lastSeq: Int? + /// The tool call the live line came from, so its result can clear it. `nil` when the + /// line came from an `activity` event, which stands until the turn ends. + private var activityCallID: String? + + public init() {} + + public init(records: some Sequence) { + for record in records { apply(record) } + } + + public mutating func apply(_ record: NodEventRecord) { + lastSeq = record.seq + switch record.event { + case .sessionStarted(let started): + self = NodSessionFold() + lastSeq = record.seq + session = started + turnPresence = .idle + case .turnStarted: + turnPresence = .busy + failure = nil + setActivity(nil) + case .toolCall(let call): + turnPresence = .busy + setActivity(call.title, callID: call.callID) + case .toolResult(let result): + if result.status != .running, activityCallID == result.callID { setActivity(nil) } + case .activity(let line): + setActivity(line.line) + case .permissionAsked(let ask): + openAsks.removeAll { $0.askID == ask.askID } + openAsks.append(ask) + case .permissionResolved(let resolved): + openAsks.removeAll { $0.askID == resolved.askID } + case .failure(let failure): + self.failure = failure + case .turnEnded: + turnPresence = .idle + setActivity(nil) + case .usage(let usage): + self.usage = usage + usageAt = record.at + case .goalCheck(let check): + goalCheck = check + goalCheckAt = record.at + case .userMessage, .assistantText, .hunkStaged, .hunkResolved, .planProposed, .mailDraft, + .compacted, .unknown: + break + } + } + + private mutating func setActivity(_ line: String?, callID: String? = nil) { + activity = line + activityCallID = callID + } + + /// Needs you while an ask is open or a failure stopped the run. A full context is not + /// one: Nod compacts on its own and carries on. + public var presence: Presence? { + if !openAsks.isEmpty { return .awaitingInput } + if let failure, failure.kind != .contextFull { return .awaitingInput } + return turnPresence + } + + /// The card's live line: what Nod is doing, or what it is waiting on. + public var activityLine: String? { + if let ask = openAsks.last { return "asks to run \(ask.subject)" } + if let failure, failure.kind != .contextFull { return failure.message } + return activity + } + + public var usageSample: UsageSample? { + guard let usage else { return nil } + return UsageSample( + inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, costUSD: usage.costUSD, + reportedAt: usageAt) + } + + public var goalVerdict: GoalVerdict? { + guard let goalCheck, let progress = goalProgress else { return nil } + return GoalVerdict( + met: goalCheck.met, detail: "\(progress.met) of \(progress.total) clauses met", + recordedAt: goalCheckAt) + } + + public var goalProgress: NodGoalProgress? { + guard let goalCheck else { return nil } + return NodGoalProgress( + met: goalCheck.clauses.filter(\.met).count, total: goalCheck.clauses.count) + } + + public var cardState: NodCardState { + NodCardState(goalProgress: goalProgress, pendingAsk: openAsks.last) + } +} diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index 49fd9b00..4a063ef5 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -141,7 +141,10 @@ extension CLISessionBackend { await ZmxSessionLauncher.restart(node, projectPath: projectPath) }, sendInput: { node, text, projectPath in - await ZmxSessionLauncher.send(text, to: node, projectPath: projectPath) + if kind == .nod, let delivered = await NodSessionLog.deliver(text, to: node) { + return delivered + } + return await ZmxSessionLauncher.send(text, to: node, projectPath: projectPath) }, // The one operation that is genuinely per-backend, because how a session can be // asked what it is doing is the thing the three CLIs differ on most. Claude Code @@ -155,14 +158,18 @@ extension CLISessionBackend { return await CopilotSessionLog.presence(of: node, projectPath: projectPath) case .codex: return await ZmxSessionLauncher.codexPresence(of: node, projectPath: projectPath) - case .openCode, .pi, .nod: + case .openCode, .pi: // Its plugin (pi's extension) writes the same labels Claude Code's hooks do, so the same reader // serves both — see `OpenCodePresencePlugin`. return await ZmxSessionLauncher.presence(of: node, projectPath: projectPath) + case .nod: + return await NodSessionLog.presence(of: node, projectPath: projectPath) } }, usage: { node, projectPath in - await ZmxSessionLauncher.usage(of: node, projectPath: projectPath) + kind == .nod + ? await NodSessionLog.usage(of: node, projectPath: projectPath) + : await ZmxSessionLauncher.usage(of: node, projectPath: projectPath) }, // Per-backend for the same reason `presence` is, and it was not: the label this // used to read for all three is written by a hook only Claude Code has, so Copilot @@ -176,8 +183,10 @@ extension CLISessionBackend { return await CopilotSessionLog.activity(of: node, projectPath: projectPath) case .codex: return await CodexSessionLog.activity(of: node, projectPath: projectPath) - case .openCode, .pi, .nod: + case .openCode, .pi: return await ZmxSessionLauncher.activity(of: node, projectPath: projectPath) + case .nod: + return await NodSessionLog.activity(of: node, projectPath: projectPath) } }, // Every backend narrates before it acts, and all three write that narration to disk @@ -212,8 +221,7 @@ extension CLISessionBackend { // card without one rather than with a rail that guesses. reading = nil case .nod: - // Nod's turns are already structured events; its rail will read those. - reading = nil + reading = await NodSessionLog.summary(of: node, projectPath: projectPath) } // The optional second pass, which is the only part of this that costs anything. // Off, `applied` returns what it was given untouched. diff --git a/GraphcodeKit/Sources/Sessions/GoalVerdictReader.swift b/GraphcodeKit/Sources/Sessions/GoalVerdictReader.swift index b31d5766..91e233e1 100644 --- a/GraphcodeKit/Sources/Sessions/GoalVerdictReader.swift +++ b/GraphcodeKit/Sources/Sessions/GoalVerdictReader.swift @@ -11,6 +11,7 @@ import Foundation /// | Claude Code | transcript `goal_status` attachment, `met: true` without `sentinel` | /// | Codex | `~/.codex/goals_1.sqlite` `thread_goals.status = 'complete'` | /// | Copilot CLI | `events.jsonl` `session.autopilot_objective_changed`, `status: "completed"` | +/// | Nod | `$NOD_STATE/events.jsonl` newest `goalCheck` of the current run | /// /// OpenCode and pi record nothing goal-specific, so they have no reading here. Remote /// projects have none yet either: their records live on the other machine. @@ -42,7 +43,9 @@ public enum GoalVerdictReader { return copilotVerdict( lines: CopilotSessionLog.tailLines( ofLogAt: directory.appendingPathComponent("events.jsonl"))) - case .openCode, .pi, .nod: + case .nod: + return NodSessionLog.verdict(of: node) + case .openCode, .pi: return nil } } diff --git a/GraphcodeKit/Sources/Sessions/NodControlClient.swift b/GraphcodeKit/Sources/Sessions/NodControlClient.swift new file mode 100644 index 00000000..1047fb3a --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/NodControlClient.swift @@ -0,0 +1,123 @@ +import Foundation + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +/// Speaks `control.sock`: one `NodCommand` per line out, one `{"ok":…}` line back. +/// +/// One connection per command. The runtime answers each line in order, so a shared +/// connection would buy nothing but a reader that has to match replies to callers. +public enum NodControlClient { + public enum Failure: Error, Equatable, Sendable { + case unreachable + case refused(String) + case malformedReply + } + + /// Bounds both the write and the reply. A runtime wedged mid-turn must not hold the + /// daemon's delivery, which falls back to typing the message instead. + static let timeout: TimeInterval = 2 + + public static func send(_ command: NodCommand, toNodeID nodeID: UUID) async -> Result< + Void, Failure + > { + await send(command, socketPath: NodRuntimeLocator.controlSocket(forNodeID: nodeID).path) + } + + public static func send(_ command: NodCommand, socketPath: String) async -> Result< + Void, Failure + > { + guard var line = try? NodProtocol.makeEncoder().encode(command) else { + return .failure(.malformedReply) + } + line.append(UInt8(ascii: "\n")) + let request = line + return await Task.detached { exchange(request, socketPath: socketPath) }.value + } + + static func parseReply(_ data: Data) -> Result { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let ok = object["ok"] as? Bool + else { return .failure(.malformedReply) } + return ok ? .success(()) : .failure(.refused(object["error"] as? String ?? "")) + } + + #if canImport(Darwin) || canImport(Glibc) + private static func exchange(_ request: Data, socketPath: String) -> Result { + guard FileManager.default.fileExists(atPath: socketPath) else { + return .failure(.unreachable) + } + #if canImport(Darwin) + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + #else + let descriptor = socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0) + #endif + guard descriptor >= 0 else { return .failure(.unreachable) } + defer { close(descriptor) } + var interval = timeval(tv_sec: Int(timeout), tv_usec: 0) + let size = socklen_t(MemoryLayout.size) + setsockopt(descriptor, SOL_SOCKET, SO_RCVTIMEO, &interval, size) + setsockopt(descriptor, SOL_SOCKET, SO_SNDTIMEO, &interval, size) + #if canImport(Darwin) + var one: Int32 = 1 + setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + #endif + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let capacity = MemoryLayout.size(ofValue: address.sun_path) + guard socketPath.utf8.count < capacity else { return .failure(.unreachable) } + #if canImport(Darwin) + address.sun_len = UInt8(MemoryLayout.size) + #endif + withUnsafeMutablePointer(to: &address.sun_path) { field in + field.withMemoryRebound(to: CChar.self, capacity: capacity) { pointer in + _ = socketPath.withCString { strncpy(pointer, $0, capacity - 1) } + } + } + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard connected == 0 else { return .failure(.unreachable) } + + #if canImport(Darwin) + let flags: Int32 = 0 + #else + let flags = Int32(MSG_NOSIGNAL) + #endif + var written = 0 + while written < request.count { + let count = request.withUnsafeBytes { bytes in + #if canImport(Darwin) + Darwin.send(descriptor, bytes.baseAddress! + written, request.count - written, flags) + #else + Glibc.send(descriptor, bytes.baseAddress! + written, request.count - written, flags) + #endif + } + guard count > 0 else { return .failure(.unreachable) } + written += count + } + + var reply = Data() + var buffer = [UInt8](repeating: 0, count: 512) + while !reply.contains(UInt8(ascii: "\n")) { + let count = read(descriptor, &buffer, buffer.count) + guard count > 0 else { break } + reply.append(contentsOf: buffer[0.. Result { + .failure(.unreachable) + } + #endif +} diff --git a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift new file mode 100644 index 00000000..72c203f9 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Finds `graphcode-nod` and the directory a node's runtime keeps its state in. +/// +/// Never a PATH lookup. Nod ships inside the app, so the binary a session runs is the one +/// that matches the app's protocol version — a `graphcode-nod` someone put on their PATH +/// could be any version at all. +public enum NodRuntimeLocator { + /// Tried in order: the development override, the running app's bundle, then the + /// support directory's `bin`, which is where `graphcoded` (installed out of the bundle) + /// finds its helpers. + public static func binaryURL(bundle: Bundle = .main) -> URL? { + if let path = NodRuntimeLocation.developmentOverride { + return URL(fileURLWithPath: path) + } + let name = CLISessionBackendKind.nod.executableName ?? "graphcode-nod" + let candidates = [ + bundle.resourceURL?.appendingPathComponent("bin/\(name)"), + SupportDirectory.binDirectory.appendingPathComponent(name), + ] + return candidates.compactMap { $0 } + .first { FileManager.default.isExecutableFile(atPath: $0.path) } + } + + /// `$NOD_STATE` for a node — under the support directory, so a moved workspace keeps + /// its Nod state with everything else it owns. + public static func stateDirectory(forNodeID nodeID: UUID) -> URL { + SupportDirectory.url.appendingPathComponent("nod", isDirectory: true) + .appendingPathComponent(nodeID.uuidString, isDirectory: true) + } + + public static func eventsFile(forNodeID nodeID: UUID) -> URL { + stateDirectory(forNodeID: nodeID).appendingPathComponent(NodProtocol.eventsFileName) + } + + public static func controlSocket(forNodeID nodeID: UUID) -> URL { + stateDirectory(forNodeID: nodeID).appendingPathComponent(NodProtocol.controlSocketName) + } + + public static func environment(forNodeID nodeID: UUID) -> [String: String] { + [NodProtocol.stateDirectoryVariable: stateDirectory(forNodeID: nodeID).path] + } + + /// Writes a goal loop's condition where `--goal-file` points, or `nil` for a node with + /// no goal. Rewritten every launch, so a goal edited between passes is the one evaluated. + public static func writeGoal(of node: LoopNode) -> URL? { + guard node.loopType == .goalBased, let goal = node.goal else { return nil } + let directory = stateDirectory(forNodeID: node.id) + let file = directory.appendingPathComponent(NodProtocol.goalFileName) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data(goal.summary.utf8).write(to: file, options: .atomic) + return file + } catch { + return nil + } + } +} diff --git a/GraphcodeKit/Sources/Sessions/NodSessionLog.swift b/GraphcodeKit/Sources/Sessions/NodSessionLog.swift new file mode 100644 index 00000000..2711ced2 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/NodSessionLog.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Every reading graphcode takes of a Nod session, from the event log the runtime writes +/// (`$NOD_STATE/events.jsonl`) through the one fold, `NodSessionFold`. +/// +/// Presence is `.reported`, not `.scanned` like Copilot's: these events are Nod telling +/// graphcode what it is doing, not a log graphcode reads over a CLI's shoulder. +/// +/// Local only. A remote project's state directory is on the other machine; its readings +/// are `nil` or `.unknown` until Nod runs remotely at all. +public enum NodSessionLog { + /// Big enough to hold the current run of a long session: the fold needs the + /// `sessionStarted` that opened it, and hunks and tool output make records large. + static let tailBytes = 1024 * 1024 + + public static func records(forNodeID nodeID: UUID) -> [NodEventRecord] { + records(inLogAt: NodRuntimeLocator.eventsFile(forNodeID: nodeID)) + } + + static func records(inLogAt url: URL) -> [NodEventRecord] { + let decoder = NodProtocol.makeDecoder() + return SummaryBeatBuilder.tailLines(of: url, bytes: tailBytes).compactMap { + try? decoder.decode(NodEventRecord.self, from: $0) + } + } + + public static func fold(forNodeID nodeID: UUID) -> NodSessionFold { + NodSessionFold(records: records(forNodeID: nodeID)) + } + + static func isRemote(_ projectPath: String?) -> Bool { + projectPath.map { RemoteProjectLocation.parse(projectPath: $0) != nil } ?? false + } + + public static func presence(of node: LoopNode, projectPath: String? = nil) async + -> PresenceReading + { + guard !isRemote(projectPath) else { return .unknown } + guard ZmxLocator.isInstalled else { return .absent } + switch await ZmxSessionLauncher.sessionTaskState(node) { + case .unknown: return .unknown + case .absent: return .absent + case .exited(let code): + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) + case .alive: break + } + let fold = fold(forNodeID: node.id) + bankConversation(of: fold, forNodeID: node.id) + guard let presence = fold.presence else { + // Alive with no `sessionStarted` yet: the runtime is still coming up. + return PresenceReading(presence: .idle, confidence: .heuristic) + } + return PresenceReading(presence: presence, confidence: .reported) + } + + /// Banks the run's conversation id where every resumer already looks + /// (`SessionIDStore`), the job a `SessionStart` hook does for Claude Code. Banked only + /// from a live session, so a loop whose session was killed for good — which clears the + /// store — is not handed its old conversation back by the log it left behind. + static func bankConversation(of fold: NodSessionFold, forNodeID nodeID: UUID) { + guard let id = fold.session?.conversationID, !id.isEmpty, + SessionIDStore.load(forNodeID: nodeID) != id + else { return } + SessionIDStore.save(id, forNodeID: nodeID) + } + + public static func activity(of node: LoopNode, projectPath: String? = nil) async -> String? { + guard !isRemote(projectPath), ZmxLocator.isInstalled, + await ZmxSessionLauncher.sessionExists(node) + else { return nil } + return fold(forNodeID: node.id).activityLine.flatMap(ZmxSessionLauncher.condensedActivity) + } + + public static func usage(of node: LoopNode, projectPath: String? = nil) async -> UsageSample? { + guard !isRemote(projectPath) else { return nil } + return fold(forNodeID: node.id).usageSample + } + + public static func verdict(of node: LoopNode) -> GoalVerdict? { + fold(forNodeID: node.id).goalVerdict + } + + public static func summary(of node: LoopNode, projectPath: String? = nil) async + -> SummaryReading? + { + guard !isRemote(projectPath) else { return nil } + let log = NodRuntimeLocator.eventsFile(forNodeID: node.id) + guard await TranscriptFreshness.shared.hasChanged(log, forNode: node.id) else { return nil } + let reading = reading(of: records(inLogAt: log), metricSamples: node.metricHistory) + return reading.isEmpty ? nil : reading + } + + /// Turns open passes, the finished assistant messages are the narration, tool calls + /// the evidence — the same beats a CLI's transcript yields, without parsing prose. + static func reading(of records: [NodEventRecord], metricSamples: [MetricSample]) + -> SummaryReading + { + var builder = SummaryBeatBuilder() + var drafts: [String: String] = [:] + for record in records { + switch record.event { + case .turnStarted: + builder.noteUserTurn(at: record.at) + case .assistantText(let text): + drafts[text.messageID, default: ""] += text.delta + if text.final, let message = drafts.removeValue(forKey: text.messageID) { + builder.noteNarration(message, at: record.at) + } + case .toolCall(let call): + builder.noteTool(call.title, at: record.at) + case .turnEnded: + builder.noteTurnEnd() + default: + continue + } + } + return SummaryBeatBuilder.reading( + from: builder.beats(), turns: builder.userTurns(), metricSamples: metricSamples, + closing: builder.closingAnswer()) + } +} + +extension NodSessionLog { + /// A `.message` edge, a heartbeat or `graphcode node send`, as a queued `send` — the + /// default, because a message from elsewhere in the graph must never redirect a turn + /// already running. `nil` when the socket is not there to ask, which leaves the caller + /// to type the line into the PTY instead: the runtime reads a plain line as the same + /// queued send, so a runtime still starting up loses nothing. + static func deliver(_ text: String, to node: LoopNode) async -> Bool? { + switch await NodControlClient.send(.send(.init(text: text)), toNodeID: node.id) { + case .success: return true + case .failure(.unreachable): return nil + case .failure: return false + } + } +} diff --git a/GraphcodeKit/Sources/Sessions/ProviderPath.swift b/GraphcodeKit/Sources/Sessions/ProviderPath.swift index e9471c52..638b92fc 100644 --- a/GraphcodeKit/Sources/Sessions/ProviderPath.swift +++ b/GraphcodeKit/Sources/Sessions/ProviderPath.swift @@ -82,6 +82,10 @@ public enum ProviderPath { return nil } guard let executable = node.backend.executableName else { return nil } + if node.backend == .nod { + guard NodRuntimeLocator.binaryURL() == nil else { return nil } + return LaunchFailure(executable: executable, backend: node.backend) + } guard await isOnPath(executable) == false else { return nil } return LaunchFailure(executable: executable, backend: node.backend) } diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index aa29d5c0..2605bb3d 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -874,7 +874,7 @@ public enum ZmxSessionLauncher { return .success(.attached) } var spawnedProcess: Process? - if node.sessionPrompt == nil || node.sessionPrompt?.isEmpty == true { + if node.backend != .nod, node.sessionPrompt == nil || node.sessionPrompt?.isEmpty == true { guard let executable = node.backend.executableName else { return .failure(.unavailable("backend has no executable")) } @@ -1199,12 +1199,16 @@ public enum ZmxSessionLauncher { shedPrompt: ShedPromptReport? = nil ) -> [String]? { guard let prompt = node.sessionPrompt(forProjectPath: projectPath), !prompt.isEmpty else { - return nil + return node.backend == .nod + ? nodRunArguments(forNode: node, projectPath: projectPath, settings: settings) : nil } // A backend graphcode can't launch has no argv. `canHost` already refuses to create // such a node, so this is the belt to that braces — but silently starting the wrong // agent is the failure it exists to prevent, so it's worth both. - guard let executable = node.backend.executableName else { return nil } + guard let executable = executable(forNode: node, projectPath: projectPath) else { + return nil + } + let nodPrefix = nodArguments(forNode: node, projectPath: projectPath, settings: settings) // CR/LF are the one thing quoting can't save us from: zmx terminates the command it // types with `\r`, and the PTY's line discipline would accept the line early at an // embedded one, truncating the prompt. Prompts come from a single-line text field, so @@ -1294,7 +1298,9 @@ public enum ZmxSessionLauncher { ? PresenceHooks.remoteOpenCodeConfigPath : nil let remoteHooksSuffix = Self.remoteHooksSuffix( forBackend: node.backend, isRemote: remote != nil) - let arguments = node.backend.launchArguments( + let arguments = + nodPrefix + + node.backend.launchArguments( prompt: promptWithMemory, tier: tier, briefingPath: briefingPath, settings: settings, workspacePaths: paths, @@ -1310,7 +1316,7 @@ public enum ZmxSessionLauncher { of: executable, arguments: arguments, environment: Self.environment( forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, - remoteHooksPath: remoteEnvironmentPath), + remoteHooksPath: remoteEnvironmentPath, nodeID: node.id), scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) // `zmx` types this command into the session's shell, and a tty in canonical mode @@ -1327,7 +1333,9 @@ public enum ZmxSessionLauncher { guard Self.fitsInATypedCommandLine(command) else { func shed(prompt: String, briefingPath: String?, extraPath: String?) -> [String] { let workspacePaths = paths + (extraPath.map { paths.contains($0) ? [] : [$0] } ?? []) - let arguments = node.backend.launchArguments( + let arguments = + nodPrefix + + node.backend.launchArguments( prompt: prompt, tier: tier, briefingPath: briefingPath, settings: settings, workspacePaths: workspacePaths, hooksFile: hooksFile, @@ -1341,7 +1349,7 @@ public enum ZmxSessionLauncher { of: executable, arguments: arguments, environment: Self.environment( forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, - remoteHooksPath: remoteEnvironmentPath), + remoteHooksPath: remoteEnvironmentPath, nodeID: node.id), scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) } let unbriefedCommand = shed(prompt: promptWithMemory, briefingPath: nil, extraPath: nil) @@ -1440,7 +1448,9 @@ public enum ZmxSessionLauncher { settings: GraphcodeSettings = GraphcodeSettingsStore.load() ) -> [String]? { guard node.backend.supportsResume else { return nil } - guard let executable = node.backend.executableName else { return nil } + guard let executable = executable(forNode: node, projectPath: projectPath) else { + return nil + } let remote = projectPath.flatMap { RemoteProjectLocation.parse(projectPath: $0) } let tier = node.effectiveModelTier(autoSelecting: settings.autoSelectsModel) let hooksFile = remote == nil ? PresenceHooks.write(forBackend: node.backend) : nil @@ -1461,7 +1471,8 @@ public enum ZmxSessionLauncher { node.backend == .copilotCLI ? nil : SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName let resumeArgs = - node.backend.launchArguments( + nodArguments(forNode: node, projectPath: projectPath, settings: settings) + + node.backend.launchArguments( prompt: nil, tier: tier, settings: settings, workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath), hooksFile: hooksFile, @@ -1479,10 +1490,46 @@ public enum ZmxSessionLauncher { briefingPath: Self.resumeBriefingPath( forBackend: node.backend, projectPath: projectPath, isRemote: remote != nil, settings: settings), - hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath), + hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath, nodeID: node.id), scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) } + /// The binary a node's session runs: the backend's CLI by name, which its login shell + /// finds on PATH, or Nod's runtime by absolute path (`NodRuntimeLocator`). Nod has no + /// remote launch yet — the bundle it would run from is on this machine. + static func executable(forNode node: LoopNode, projectPath: String?) -> String? { + guard node.backend == .nod else { return node.backend.executableName } + guard !NodSessionLog.isRemote(projectPath) else { return nil } + return NodRuntimeLocator.binaryURL()?.path + } + + /// `nodArguments` for a launch from here: the node's working directory, and its goal + /// written where `--goal-file` points. Empty for every other backend. + static func nodArguments( + forNode node: LoopNode, projectPath: String?, settings: GraphcodeSettings + ) -> [String] { + guard node.backend == .nod else { return [] } + return node.backend.nodArguments( + nodeID: node.id, loopType: node.loopType, settings: settings, + workingDirectory: workingDirectory(forNode: node, projectPath: projectPath), + goalFile: NodRuntimeLocator.writeGoal(of: node)?.path) + } + + /// A Nod session with nothing to say yet — a main loop with no starting note. Unlike a + /// CLI's, its launch still carries arguments: which node it is, and where its state lives. + static func nodRunArguments( + forNode node: LoopNode, projectPath: String?, settings: GraphcodeSettings + ) -> [String]? { + guard let executable = executable(forNode: node, projectPath: projectPath) else { + return nil + } + return ["run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d"] + + loginShellInvocation( + of: executable, + arguments: nodArguments(forNode: node, projectPath: projectPath, settings: settings), + environment: environment(forBackend: .nod, briefingPath: nil, nodeID: node.id)) + } + /// Stands in for a remote session ID that this machine cannot know: the ID was written /// by a hook on the remote host and is read back there, so what travels in the argv is /// a shell variable reference, not a value. `remoteQuotedCommand` is the one place that @@ -1527,8 +1574,9 @@ public enum ZmxSessionLauncher { /// (`briefingEnvironment`) and OpenCode's presence plugin (`presenceEnvironment`). static func environment( forBackend backend: CLISessionBackendKind, briefingPath: String?, hooksFile: URL? = nil, - remoteHooksPath: String? = nil + remoteHooksPath: String? = nil, nodeID: UUID? = nil ) -> [String: String] { + if backend == .nod, let nodeID { return NodRuntimeLocator.environment(forNodeID: nodeID) } let briefing = backend.briefingEnvironment(briefingPath: briefingPath) if backend == .openCode, let remoteHooksPath { return briefing.merging(["OPENCODE_CONFIG": remoteHooksPath]) { $1 } diff --git a/NodRuntime/PROTOCOL.md b/NodRuntime/PROTOCOL.md index 7767833a..09554c90 100644 --- a/NodRuntime/PROTOCOL.md +++ b/NodRuntime/PROTOCOL.md @@ -8,6 +8,27 @@ fields are ignored. Unknown event types decode as `unknown` and are skipped by r adding an event never breaks an older app. Bump `v` only for a change an old reader would misread. +## Launch + +graphcode starts `graphcode-nod` inside the node's zmx session: + +``` +graphcode-nod --node [--cwd ] --engine claude|copilot --loop-type main|goal|timed|turn|composite + [--model ] [--goal-file ] [--briefing ] [--prompt ] [--resume ] +``` + +- `NOD_STATE` (`NodProtocol.stateDirectoryVariable`) is always set, to + `/nod/`. Use it rather than computing `~/.graphcode/...`: a + workspace can move its support directory. Create it if missing. +- `--goal-file` is `$NOD_STATE/goal.md`, the goal's condition as plain text, rewritten on + every launch. +- `--resume` is the `conversationID` of an earlier `sessionStarted`. graphcode banks it + from the event log while the session is live, so a reboot or restart resumes. +- The binary comes from `Contents/Resources/bin/graphcode-nod` in the app bundle, then the + support directory's `bin/`. `GRAPHCODE_NOD_PATH` overrides both for development. +- A plain line on the PTY is a queued `send`. graphcode falls back to typing when + `control.sock` is not there yet, so read stdin from the first moment. + ## Events — `events.jsonl` Every line carries `v`, `seq` (strictly increasing from 1) and `at`. @@ -22,16 +43,16 @@ Every line carries `v`, `seq` (strictly increasing from 1) and `at`. | `toolResult` | `callID`, `status` (`running` `ok` `error`), `summary`, `output?`, `durationMs?` | | | `hunkStaged` | `turn`, `hunkID`, `file`, `header`, `diff`, `added`, `removed`, `autoAccepted` | written to disk only on accept | | `hunkResolved` | `hunkID`, `decision` (`accept` `reject` `comment`), `note?` | | -| `permissionAsked` | `askID`, `kind` (`shell` `network` `editOutsideWorktree` `messageLoop` `mcpTool`), `subject`, `reason`, `answerableFromCard` | the loop is in Needs you until resolved | +| `permissionAsked` | `askID`, `kind` (`shell` `network` `editOutsideWorktree` `messageLoop` `mcpTool`), `subject`, `reason`, `answerableFromCard` | the loop is in Needs you until resolved, or until the next `sessionStarted` | | `permissionResolved` | `askID`, `decision` (`allowOnce` `alwaysAllow` `deny`) | | | `goalCheck` | `turn`, `evaluatorModel`, `clauses[{text, met, evidence?}]`, `met` | run each time Nod tries to stop | | `turnEnded` | `turn`, `filesChanged`, `added`, `removed`, `summary?` | | -| `usage` | `inputTokens`, `outputTokens`, `costUSD?`, `premiumRequests?`, `contextUsed` (0…1) | | +| `usage` | `inputTokens`, `outputTokens`, `costUSD?`, `premiumRequests?`, `contextUsed` (0…1) | running totals for the run, so the newest one is the answer | | `planProposed` | `planID`, `title`, `steps[{id, text, files[], size?, editedByHuman}]` | | | `mailDraft` | `draftID`, `toNodeID`, `inReplyTo?`, `text` | sent only on `sendDraft` | | `compacted` | `fromTurn`, `throughTurn` | | | `activity` | `line` | the canvas card's live line | -| `failure` | `kind` (`signInExpired` `contextFull` `spendCap` `permissionUnavailable` `engineError`), `message` | shown as a banner above the composer | +| `failure` | `kind` (`signInExpired` `contextFull` `spendCap` `permissionUnavailable` `engineError`), `message` | shown as a banner above the composer; every kind but `contextFull` puts the loop in Needs you until the next `turnStarted` | ## Commands — `control.sock` diff --git a/Package.swift b/Package.swift index 27b770cb..0bbafd28 100644 --- a/Package.swift +++ b/Package.swift @@ -59,6 +59,9 @@ import PackageDescription "Sessions/RemoteTranscriptProbe.swift", "Sessions/GoalVerdictReader.swift", "Sessions/MermaidBoardParser.swift", + "Sessions/NodControlClient.swift", + "Sessions/NodRuntimeLocator.swift", + "Sessions/NodSessionLog.swift", "Sessions/OrphanedSessionReaper.swift", "Sessions/PiPresenceExtension.swift", "Sessions/ProviderPath.swift", diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift new file mode 100644 index 00000000..a8c46ea9 --- /dev/null +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift @@ -0,0 +1,26 @@ +import Foundation +import GraphcodeKit + +extension GhosttyTerminalView { + /// Nod's half of `launchPrefix`: the runtime by absolute path, its state directory in + /// the environment, and the same `nodArguments` the daemon launches with. `nil` for a + /// remote project or a runtime that is not there, which leaves the pane a plain shell + /// rather than a command that cannot run. + func nodLaunchPrefix(settings: GraphcodeSettings) -> [String]? { + guard remoteLocation == nil, let executable = NodRuntimeLocator.binaryURL()?.path, + let nodeID = SurfaceRef.nodeID(fromZmxSessionName: sessionName) + else { return nil } + let goalFile = NodRuntimeLocator.stateDirectory(forNodeID: nodeID) + .appendingPathComponent(NodProtocol.goalFileName).path + let arguments = backend.nodArguments( + nodeID: nodeID, loopType: loopType, settings: settings, + workingDirectory: effectiveWorkingDirectory, + goalFile: loopType == .goalBased && FileManager.default.fileExists(atPath: goalFile) + ? goalFile : nil) + let environment = NodRuntimeLocator.environment(forNodeID: nodeID) + .sorted { $0.key < $1.key } + .map { "\($0.key)=\(PresenceHooks.singleQuoted($0.value))" } + return ["exec", "env"] + environment + [PresenceHooks.singleQuoted(executable)] + + arguments.map(PresenceHooks.singleQuoted) + } +} diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 96e12356..cdf7b8b8 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -204,6 +204,8 @@ struct GhosttyTerminalView: NSViewRepresentable { if let briefingPath { if backend == .claudeCode { parts.append("--append-system-prompt-file \(briefingPath)") + } else if backend == .nod { + parts.append("--briefing \(briefingPath)") } else if backend.briefingNeedsDirectoryGrant { parts.append("--add-dir \((briefingPath as NSString).deletingLastPathComponent)") } @@ -221,6 +223,7 @@ struct GhosttyTerminalView: NSViewRepresentable { /// (`resumeCommand`), so a flag every session needs cannot land in one and not the /// other. func launchPrefix(settings: GraphcodeSettings) -> [String]? { + if backend == .nod { return nodLaunchPrefix(settings: settings) } guard let executable = backend.executableName else { return nil } let tier = ModelTier.resolved( pinned: pinnedModelTier, for: loopType, autoSelecting: settings.autoSelectsModel) @@ -306,8 +309,8 @@ struct GhosttyTerminalView: NSViewRepresentable { func sessionEnvironment(briefingPath: String?, hooksFile: URL? = nil) -> [String: String] { var environment = backend.presenceEnvironment(hooksFile: hooksFile) guard var prompt = initialPrompt else { return environment } - if backend != .claudeCode, backend.briefingEnvironment(briefingPath: briefingPath).isEmpty, - let briefingPath + if backend != .claudeCode, backend != .nod, + backend.briefingEnvironment(briefingPath: briefingPath).isEmpty, let briefingPath { prompt = SessionPrompt.composed( preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt) From 28df8d41c6a94d90642f0031fde3c14bd81f39ba Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:43:40 -0700 Subject: [PATCH 3/6] Poll Nod's goal verdict, and prove the wiring end to end The goal poller armed a verdict read only for backends with a typed directive, so a Nod goal loop never resolved; and the live line was read only for busy sessions, so a loop waiting on a permission never said what it asked. Both now include Nod. Also NOD_PROJECT_PATH/NOD_GRAPH_FILE and --inherit for the graph layer, the README's NOD_STATE path, tests for the fold, readers, argv and control socket, a fake graphcode-nod, and scripts/nod-daemon-e2e.py driving an isolated daemon. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Sources/Domain/BackendCommand.swift | 3 +- GraphcodeKit/Sources/Domain/NodProtocol.swift | 4 + .../Sources/Domain/NodSessionFold.swift | 4 +- GraphcodeKit/Sources/GraphStore.swift | 6 +- GraphcodeKit/Sources/ProjectPersistence.swift | 6 + .../Sources/Sessions/NodRuntimeLocator.swift | 21 +- .../Sources/Sessions/ZmxSessionLauncher.swift | 15 +- NodRuntime/PROTOCOL.md | 8 +- NodRuntime/README.md | 3 +- .../Ghostty/GhosttyTerminalView+Nod.swift | 2 +- graphcode/Tests/NodBackendTests.swift | 2 +- graphcode/Tests/NodLaunchTests.swift | 397 ++++++++++++++++++ scripts/fake-graphcode-nod | 143 +++++++ scripts/nod-daemon-e2e.py | 232 ++++++++++ 14 files changed, 830 insertions(+), 16 deletions(-) create mode 100644 graphcode/Tests/NodLaunchTests.swift create mode 100755 scripts/fake-graphcode-nod create mode 100755 scripts/nod-daemon-e2e.py diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index 3d249d26..597576f8 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -176,7 +176,7 @@ extension CLISessionBackendKind { /// type that picks its model, and the engine. Empty for every CLI. public func nodArguments( nodeID: UUID, loopType: LoopType, settings: GraphcodeSettings, - workingDirectory: String? = nil, goalFile: String? = nil + workingDirectory: String? = nil, goalFile: String? = nil, inheritFile: String? = nil ) -> [String] { guard self == .nod else { return [] } let nod = settings.nod @@ -185,6 +185,7 @@ extension CLISessionBackendKind { + ["--engine", nod.engine.rawValue, "--loop-type", loopType.nodArgument] + (nod.model(for: loopType).map { ["--model", $0] } ?? []) + (goalFile.map { ["--goal-file", $0] } ?? []) + + (inheritFile.map { ["--inherit", $0] } ?? []) } /// pi reads a positional argument that starts with `-` as an option and one that starts diff --git a/GraphcodeKit/Sources/Domain/NodProtocol.swift b/GraphcodeKit/Sources/Domain/NodProtocol.swift index 16e81d3a..2e6b4083 100644 --- a/GraphcodeKit/Sources/Domain/NodProtocol.swift +++ b/GraphcodeKit/Sources/Domain/NodProtocol.swift @@ -15,6 +15,10 @@ public enum NodProtocol { /// to `~/.graphcode/nod/` without it, which is wrong for any workspace whose /// support directory was moved (`GRAPHCODE_SUPPORT_DIR`). public static let stateDirectoryVariable = "NOD_STATE" + /// The node's project, and the file its graph is saved in — read-only, for the graphcode + /// MCP server's siblings and edges. Unset for a node outside any project. + public static let projectPathVariable = "NOD_PROJECT_PATH" + public static let graphFileVariable = "NOD_GRAPH_FILE" public static let eventsFileName = "events.jsonl" public static let controlSocketName = "control.sock" public static let conversationFileName = "conversation.json" diff --git a/GraphcodeKit/Sources/Domain/NodSessionFold.swift b/GraphcodeKit/Sources/Domain/NodSessionFold.swift index 29d067d3..29da397b 100644 --- a/GraphcodeKit/Sources/Domain/NodSessionFold.swift +++ b/GraphcodeKit/Sources/Domain/NodSessionFold.swift @@ -1,7 +1,7 @@ import Foundation /// How far a goal loop's evaluator says it has got — the card's progress bar. -public struct NodGoalProgress: Equatable, Sendable { +public struct NodGoalProgress: Codable, Equatable, Sendable { public var met: Int public var total: Int @@ -13,7 +13,7 @@ public struct NodGoalProgress: Equatable, Sendable { /// What a Nod loop's canvas card shows beyond what every backend reports. Folded from the /// event log by the daemon, so the card never reads `events.jsonl` itself. -public struct NodCardState: Equatable, Sendable { +public struct NodCardState: Codable, Equatable, Sendable { public var goalProgress: NodGoalProgress? /// The newest permission ask nobody has answered; `answerableFromCard` says whether the /// card may offer Allow once or must open the chat. diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 3f09cd06..8c293655 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1616,8 +1616,10 @@ public actor GraphStore { guard let onReadActivity else { return false } var changed = false for node in graph.nodes { + // A Nod loop waiting on a human says what it is waiting for, exactly. let working = node.presence?.presence == .busy + || (node.backend == .nod && node.presence?.presence == .awaitingInput) let reported = working ? await onReadActivity(node, graph.project.path) : nil guard graph.nodes[id: node.id]?.activity != reported else { continue } graph.nodes[id: node.id]?.activity = reported @@ -4146,9 +4148,11 @@ public actor GraphStore { let hasPredicate = goal.effectivePredicate != nil && (onEvaluatePredicate != nil || onCheckPredicate != nil) let hasBudget = goal.tokenBudget != nil && onReadUsage != nil + // Nod's goal is a field rather than a typed directive, but its evaluator records a + // verdict all the same. let hasVerdict = goal.effectivePredicate == nil && onReadGoalVerdict != nil - && node.backend.capabilities.goalDirective != nil + && (node.backend.capabilities.goalDirective != nil || node.backend == .nod) guard hasPredicate || hasVerdict || goal.stallAfterSeconds != nil || hasBudget else { return } goalPollers[node.id]?.cancel() let nodeID = node.id diff --git a/GraphcodeKit/Sources/ProjectPersistence.swift b/GraphcodeKit/Sources/ProjectPersistence.swift index 1934d9b6..fcdb1eb0 100644 --- a/GraphcodeKit/Sources/ProjectPersistence.swift +++ b/GraphcodeKit/Sources/ProjectPersistence.swift @@ -169,6 +169,12 @@ public struct ProjectPersistence: Sendable { /// must be deterministic across launches, but Windows also rejects `:`, `\`, and several /// other characters that occur in perfectly valid project paths. Hashing keeps names /// short, safe, and collision-resistant without leaking a path into a directory listing. + /// Where a project's graph is saved — read-only for anything outside the daemon, which + /// rewrites it on every change. + public func graphFileURL(forProjectPath path: String) -> URL { + fileURL(forProjectPath: path) + } + private func fileURL(forProjectPath path: String) -> URL { let key = platformPaths.persistenceKey(forProjectPath: path) return projectsDirectory.appendingPathComponent("\(key).json") diff --git a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift index 72c203f9..b0b35440 100644 --- a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift +++ b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift @@ -6,10 +6,15 @@ import Foundation /// that matches the app's protocol version — a `graphcode-nod` someone put on their PATH /// could be any version at all. public enum NodRuntimeLocator { + /// Wins over everything below. A `var` only so tests can launch against a fixture + /// without touching the process environment the rest of the suite reads. + static var binaryOverride: URL? + /// Tried in order: the development override, the running app's bundle, then the /// support directory's `bin`, which is where `graphcoded` (installed out of the bundle) /// finds its helpers. public static func binaryURL(bundle: Bundle = .main) -> URL? { + if let binaryOverride { return binaryOverride } if let path = NodRuntimeLocation.developmentOverride { return URL(fileURLWithPath: path) } @@ -37,8 +42,20 @@ public enum NodRuntimeLocator { stateDirectory(forNodeID: nodeID).appendingPathComponent(NodProtocol.controlSocketName) } - public static func environment(forNodeID nodeID: UUID) -> [String: String] { - [NodProtocol.stateDirectoryVariable: stateDirectory(forNodeID: nodeID).path] + /// The session's environment: its state directory, and — for a node in a project — the + /// project and the graph file the runtime's graphcode MCP server reads siblings and + /// edges from. + public static func environment(forNodeID nodeID: UUID, projectPath: String? = nil) + -> [String: String] + { + var environment = [NodProtocol.stateDirectoryVariable: stateDirectory(forNodeID: nodeID).path] + if let projectPath { + environment[NodProtocol.projectPathVariable] = projectPath + environment[NodProtocol.graphFileVariable] = + ProjectPersistence(baseDirectory: SupportDirectory.url) + .graphFileURL(forProjectPath: projectPath).path + } + return environment } /// Writes a goal loop's condition where `--goal-file` points, or `nil` for a node with diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 2605bb3d..a0a5e4ec 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1316,7 +1316,7 @@ public enum ZmxSessionLauncher { of: executable, arguments: arguments, environment: Self.environment( forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, - remoteHooksPath: remoteEnvironmentPath, nodeID: node.id), + remoteHooksPath: remoteEnvironmentPath, nodeID: node.id, projectPath: projectPath), scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) // `zmx` types this command into the session's shell, and a tty in canonical mode @@ -1349,7 +1349,7 @@ public enum ZmxSessionLauncher { of: executable, arguments: arguments, environment: Self.environment( forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, - remoteHooksPath: remoteEnvironmentPath, nodeID: node.id), + remoteHooksPath: remoteEnvironmentPath, nodeID: node.id, projectPath: projectPath), scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) } let unbriefedCommand = shed(prompt: promptWithMemory, briefingPath: nil, extraPath: nil) @@ -1490,7 +1490,7 @@ public enum ZmxSessionLauncher { briefingPath: Self.resumeBriefingPath( forBackend: node.backend, projectPath: projectPath, isRemote: remote != nil, settings: settings), - hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath, nodeID: node.id), + hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath, nodeID: node.id, projectPath: projectPath), scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) } @@ -1527,7 +1527,8 @@ public enum ZmxSessionLauncher { + loginShellInvocation( of: executable, arguments: nodArguments(forNode: node, projectPath: projectPath, settings: settings), - environment: environment(forBackend: .nod, briefingPath: nil, nodeID: node.id)) + environment: environment( + forBackend: .nod, briefingPath: nil, nodeID: node.id, projectPath: projectPath)) } /// Stands in for a remote session ID that this machine cannot know: the ID was written @@ -1574,9 +1575,11 @@ public enum ZmxSessionLauncher { /// (`briefingEnvironment`) and OpenCode's presence plugin (`presenceEnvironment`). static func environment( forBackend backend: CLISessionBackendKind, briefingPath: String?, hooksFile: URL? = nil, - remoteHooksPath: String? = nil, nodeID: UUID? = nil + remoteHooksPath: String? = nil, nodeID: UUID? = nil, projectPath: String? = nil ) -> [String: String] { - if backend == .nod, let nodeID { return NodRuntimeLocator.environment(forNodeID: nodeID) } + if backend == .nod, let nodeID { + return NodRuntimeLocator.environment(forNodeID: nodeID, projectPath: projectPath) + } let briefing = backend.briefingEnvironment(briefingPath: briefingPath) if backend == .openCode, let remoteHooksPath { return briefing.merging(["OPENCODE_CONFIG": remoteHooksPath]) { $1 } diff --git a/NodRuntime/PROTOCOL.md b/NodRuntime/PROTOCOL.md index 09554c90..d8ecb733 100644 --- a/NodRuntime/PROTOCOL.md +++ b/NodRuntime/PROTOCOL.md @@ -14,12 +14,18 @@ graphcode starts `graphcode-nod` inside the node's zmx session: ``` graphcode-nod --node [--cwd ] --engine claude|copilot --loop-type main|goal|timed|turn|composite - [--model ] [--goal-file ] [--briefing ] [--prompt ] [--resume ] + [--model ] [--goal-file ] [--inherit ] [--briefing ] + [--prompt ] [--resume ] ``` - `NOD_STATE` (`NodProtocol.stateDirectoryVariable`) is always set, to `/nod/`. Use it rather than computing `~/.graphcode/...`: a workspace can move its support directory. Create it if missing. +- `NOD_PROJECT_PATH` and `NOD_GRAPH_FILE` are set for a node in a project: the project + path, and the graph file the daemon saves (read it, never write it — the daemon + rewrites it on every change). +- `--inherit ` hands a fresh composite child or fork the brief it starts from. It is + never passed on `--resume`. - `--goal-file` is `$NOD_STATE/goal.md`, the goal's condition as plain text, rewritten on every launch. - `--resume` is the `conversationID` of an earlier `sessionStarted`. graphcode banks it diff --git a/NodRuntime/README.md b/NodRuntime/README.md index 0aca2377..47314104 100644 --- a/NodRuntime/README.md +++ b/NodRuntime/README.md @@ -38,7 +38,8 @@ app does not parse that text. It renders the chat pane from the event log. | PTY stdin (`zmx send`) | daemon → runtime | A plain line is `send {delivery: queue}`, so `.message` edges and `graphcode node send` work unchanged | | zmx labels | runtime → daemon | The same presence/activity/session-id labels Claude Code's hooks write (`PresenceHooks`), so the existing readers serve Nod | -`$NOD_STATE` is `~/.graphcode/nod//`. It also holds `conversation.json` +`$NOD_STATE` is `/nod//` (`~/.graphcode/nod//` in the +default workspace), and graphcode always sets it. It also holds `conversation.json` (engine, model, conversation id) for resume. The wire types are `GraphcodeKit/Sources/Domain/NodProtocol.swift`. PROTOCOL.md is the diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift index a8c46ea9..827e3e21 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift @@ -17,7 +17,7 @@ extension GhosttyTerminalView { workingDirectory: effectiveWorkingDirectory, goalFile: loopType == .goalBased && FileManager.default.fileExists(atPath: goalFile) ? goalFile : nil) - let environment = NodRuntimeLocator.environment(forNodeID: nodeID) + let environment = NodRuntimeLocator.environment(forNodeID: nodeID, projectPath: projectPath) .sorted { $0.key < $1.key } .map { "\($0.key)=\(PresenceHooks.singleQuoted($0.value))" } return ["exec", "env"] + environment + [PresenceHooks.singleQuoted(executable)] diff --git a/graphcode/Tests/NodBackendTests.swift b/graphcode/Tests/NodBackendTests.swift index 59d1e863..2fbc7c9a 100644 --- a/graphcode/Tests/NodBackendTests.swift +++ b/graphcode/Tests/NodBackendTests.swift @@ -33,7 +33,7 @@ struct NodBackendTests { @Test func nodHostsNothingUntilTheRuntimeShips() { #expect(!CLISessionBackendKind.nod.isSpiked) - #expect(CLISessionBackendKind.nod.executableName == nil) + #expect(CLISessionBackendKind.nod.executableName == "graphcode-nod") for loopType in LoopType.allCases { #expect(!CLISessionBackendKind.nod.canHost(loopType)) } diff --git a/graphcode/Tests/NodLaunchTests.swift b/graphcode/Tests/NodLaunchTests.swift new file mode 100644 index 00000000..a38ea199 --- /dev/null +++ b/graphcode/Tests/NodLaunchTests.swift @@ -0,0 +1,397 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +#if canImport(Darwin) + import Darwin +#endif + +private let start = Date(timeIntervalSince1970: 1_790_000_000) + +private func records(_ events: [NodEvent]) -> [NodEventRecord] { + events.enumerated().map { + NodEventRecord(seq: $0.offset + 1, at: start.addingTimeInterval(Double($0.offset)), event: $0.element) + } +} + +private let started = NodEvent.sessionStarted( + .init(engine: .claudeAgentSDK, model: "sonnet", conversationID: "conv-1", resumed: false)) + +private func ask(_ id: String, fromCard: Bool = false) -> NodEvent { + .permissionAsked( + .init( + askID: id, kind: .network, subject: "swift package resolve", reason: "network", + answerableFromCard: fromCard)) +} + +@Suite +struct NodSessionFoldTests { + @Test + func turnBoundariesSayBusyThenIdle() { + var fold = NodSessionFold(records: records([started])) + #expect(fold.presence == .idle) + fold = NodSessionFold(records: records([started, .turnStarted(.init(turn: 1, origin: .user))])) + #expect(fold.presence == .busy) + fold = NodSessionFold( + records: records([ + started, .turnStarted(.init(turn: 1, origin: .user)), + .turnEnded(.init(turn: 1, filesChanged: 0, added: 0, removed: 0, summary: nil)), + ])) + #expect(fold.presence == .idle) + #expect(NodSessionFold().presence == nil) + } + + @Test + func anOpenAskIsNeedsYouUntilItIsAnswered() { + let asked = records([started, .turnStarted(.init(turn: 1, origin: .user)), ask("a1")]) + let fold = NodSessionFold(records: asked) + #expect(fold.presence == .awaitingInput) + #expect(fold.activityLine == "asks to run swift package resolve") + #expect(fold.cardState.pendingAsk?.askID == "a1") + #expect(fold.cardState.pendingAsk?.answerableFromCard == false) + + let answered = NodSessionFold( + records: records([ + started, .turnStarted(.init(turn: 1, origin: .user)), ask("a1"), + .permissionResolved(.init(askID: "a1", decision: .allowOnce)), + ])) + #expect(answered.presence == .busy) + #expect(answered.cardState.pendingAsk == nil) + } + + /// A new run is a new process: an ask the last one left open asks nothing of anybody. + @Test + func aNewRunForgetsTheLastRunsAsksAndSpend() { + let fold = NodSessionFold( + records: records([ + started, ask("a1"), + .usage(.init(inputTokens: 10, outputTokens: 2, contextUsed: 0.1)), + started, + ])) + #expect(fold.presence == .idle) + #expect(fold.openAsks.isEmpty) + #expect(fold.usageSample == nil) + } + + @Test + func failuresAreNeedsYouExceptAFullContext() { + func presence(after kind: NodFailureKind) -> Presence? { + NodSessionFold( + records: records([ + started, .turnStarted(.init(turn: 1, origin: .user)), + .failure(.init(kind: kind, message: "stopped")), + ]) + ).presence + } + #expect(presence(after: .signInExpired) == .awaitingInput) + #expect(presence(after: .spendCap) == .awaitingInput) + #expect(presence(after: .permissionUnavailable) == .awaitingInput) + #expect(presence(after: .engineError) == .awaitingInput) + #expect(presence(after: .contextFull) == .busy) + + let resumed = NodSessionFold( + records: records([ + started, .failure(.init(kind: .signInExpired, message: "expired")), + .turnStarted(.init(turn: 2, origin: .user)), + ])) + #expect(resumed.presence == .busy) + } + + @Test + func theLiveLineFollowsToolsUntilTheyFinish() { + let call = NodEvent.toolCall(.init(turn: 1, callID: "c1", tool: "Grep", title: "Search \"UsageGate\"")) + var events: [NodEvent] = [started, .turnStarted(.init(turn: 1, origin: .user)), call] + #expect(NodSessionFold(records: records(events)).activityLine == "Search \"UsageGate\"") + events.append(.toolResult(.init(callID: "c1", status: .ok, summary: "6 hits"))) + #expect(NodSessionFold(records: records(events)).activityLine == nil) + events.append(.activity(.init(line: "Running swift test · turn 1"))) + events.append(.toolResult(.init(callID: "c9", status: .ok, summary: ""))) + #expect(NodSessionFold(records: records(events)).activityLine == "Running swift test · turn 1") + events.append(.turnEnded(.init(turn: 1, filesChanged: 1, added: 3, removed: 1, summary: nil))) + #expect(NodSessionFold(records: records(events)).activityLine == nil) + } + + @Test + func usageIsTheNewestRunningTotal() { + let fold = NodSessionFold( + records: records([ + started, + .usage(.init(inputTokens: 10, outputTokens: 2, costUSD: 0.01, contextUsed: 0.1)), + .usage(.init(inputTokens: 30, outputTokens: 5, costUSD: 0.04, contextUsed: 0.2)), + ])) + #expect( + fold.usageSample + == UsageSample( + inputTokens: 30, outputTokens: 5, costUSD: 0.04, reportedAt: start.addingTimeInterval(2))) + } + + @Test + func theNewestGoalCheckIsTheVerdictAndTheProgress() { + func check(_ met: [Bool]) -> NodEvent { + .goalCheck( + .init( + turn: 1, evaluatorModel: "haiku", + clauses: met.enumerated().map { NodGoalClause(text: "c\($0.offset)", met: $0.element) }, + met: met.allSatisfy { $0 })) + } + let notYet = NodSessionFold(records: records([started, check([true, false])])) + #expect(notYet.goalVerdict?.met == false) + #expect(notYet.cardState.goalProgress == NodGoalProgress(met: 1, total: 2)) + + let met = NodSessionFold(records: records([started, check([true, false]), check([true, true])])) + #expect(met.goalVerdict?.met == true) + #expect(met.goalVerdict?.detail == "2 of 2 clauses met") + #expect(met.goalVerdict?.recordedAt == start.addingTimeInterval(2)) + #expect(NodSessionFold(records: records([started])).goalVerdict == nil) + } + + @Test + func unknownEventsChangeNothing() { + let known = NodSessionFold(records: records([started, .turnStarted(.init(turn: 1, origin: .user))])) + var withUnknown = known + withUnknown.apply(NodEventRecord(seq: 3, at: start, event: .unknown("fromTheFuture"))) + #expect(withUnknown.presence == known.presence) + #expect(withUnknown.activityLine == known.activityLine) + } +} + +@Suite +struct NodSessionLogTests { + @Test + func readsTheLogSkippingATornLastLine() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("nod-\(UUID().uuidString).jsonl") + defer { try? FileManager.default.removeItem(at: url) } + let encoder = NodProtocol.makeEncoder() + var data = Data() + for record in records([started, .turnStarted(.init(turn: 1, origin: .user))]) { + data.append(try encoder.encode(record)) + data.append(UInt8(ascii: "\n")) + } + data.append(Data(#"{"v":1,"seq":3,"at":"2026-"#.utf8)) + try data.write(to: url) + let read = NodSessionLog.records(inLogAt: url) + #expect(read.map(\.seq) == [1, 2]) + #expect(NodSessionFold(records: read).presence == .busy) + } + + @Test + func summaryBeatsComeFromTurnsNarrationAndTools() { + let reading = NodSessionLog.reading( + of: records([ + started, + .turnStarted(.init(turn: 1, origin: .user)), + .assistantText(.init(turn: 1, messageID: "m1", delta: "Found it. ExportRoute ", final: false)), + .assistantText( + .init(turn: 1, messageID: "m1", delta: "is registered before UsageGate runs.", final: true)), + .toolCall(.init(turn: 1, callID: "c1", tool: "Read", title: "Read UsageGate.swift")), + .turnEnded(.init(turn: 1, filesChanged: 0, added: 0, removed: 0, summary: nil)), + ]), metricSamples: []) + #expect(!reading.isEmpty) + #expect(reading.turns == [start.addingTimeInterval(1)]) + #expect(reading.closing?.contains("ExportRoute is registered before UsageGate runs.") == true) + } +} + +@Suite(.serialized) +struct NodLaunchArgumentTests { + private let nodeID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + + @Test + func theRuntimeTakesItsPromptAndBriefingByFlag() { + let nod = CLISessionBackendKind.nod + #expect( + nod.launchArguments(prompt: "fix the cap", tier: .standard, briefingPath: "/b/AGENTS.md") + == ["--briefing", "/b/AGENTS.md", "--prompt", "fix the cap"]) + #expect(nod.launchArguments(prompt: nil, tier: .standard) == []) + #expect(nod.resumeArguments(sessionID: "conv-1") == ["--resume", "conv-1"]) + #expect(nod.supportsResume) + #expect(nod.promptFlag == "--prompt") + #expect(nod.recordsGoalVerdict) + } + + @Test + func nodArgumentsNameTheNodeEngineLoopTypeAndModel() { + var settings = GraphcodeSettings() + settings.nod.engine = .copilotSDK + settings.nod.modelsByLoopType = [LoopType.goalBased.rawValue: "opus"] + #expect( + CLISessionBackendKind.nod.nodArguments( + nodeID: nodeID, loopType: .goalBased, settings: settings, workingDirectory: "/w", + goalFile: "/s/goal.md") + == [ + "--node", nodeID.uuidString, "--cwd", "/w", "--engine", "copilot", "--loop-type", "goal", + "--model", "opus", "--goal-file", "/s/goal.md", + ]) + #expect( + CLISessionBackendKind.nod.nodArguments( + nodeID: nodeID, loopType: .sketch, settings: GraphcodeSettings()) + == ["--node", nodeID.uuidString, "--engine", "claude", "--loop-type", "main"]) + for kind in CLISessionBackendKind.allCases where kind != .nod { + #expect(kind.nodArguments(nodeID: nodeID, loopType: .goalBased, settings: settings).isEmpty) + } + #expect(LoopType.timeBased.nodArgument == "timed") + #expect(LoopType.turnBased.nodArgument == "turn") + #expect(LoopType.composite.nodArgument == "composite") + } + + @Test + func theDaemonLaunchesTheBundledRuntimeWithItsStateDirectory() throws { + NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/Apps/GraphCode.app/bin/graphcode-nod") + defer { NodRuntimeLocator.binaryOverride = nil } + let node = LoopNode( + id: nodeID, title: "Cap", loopType: .turnBased, checkDescription: "tests pass", + backend: .nod) + let arguments = try #require( + ZmxSessionLauncher.arguments(forNode: node, settings: GraphcodeSettings())) + #expect(Array(arguments.prefix(3)) == ["run", "graphcode-\(nodeID.uuidString)", "-d"]) + let script = arguments[7] + let state = NodRuntimeLocator.stateDirectory(forNodeID: nodeID).path + #expect(script.hasPrefix("exec env NOD_STATE=\"\(state)\" /Apps/GraphCode.app/bin/graphcode-nod ")) + let argv = Array(arguments.dropFirst(9)) + #expect(Array(argv.prefix(6)) == ["--node", nodeID.uuidString, "--engine", "claude", "--loop-type", "turn"]) + #expect(argv.suffix(2).first == "--prompt") + + let resume = try #require( + ZmxSessionLauncher.resumeArguments( + forNode: node, sessionID: "conv-1", settings: GraphcodeSettings())) + #expect(resume[7] == script) + #expect(Array(resume.suffix(2)) == ["--resume", "conv-1"]) + #expect(!resume.contains("--prompt")) + } + + @Test + func aMainLoopWithNothingToSayStillLaunches() throws { + NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/r/graphcode-nod") + defer { NodRuntimeLocator.binaryOverride = nil } + let node = LoopNode(id: nodeID, title: "Chat", loopType: .sketch, backend: .nod) + let arguments = try #require( + ZmxSessionLauncher.arguments(forNode: node, settings: GraphcodeSettings())) + #expect( + Array(arguments.dropFirst(9)) + == ["--node", nodeID.uuidString, "--engine", "claude", "--loop-type", "main"]) + // A CLI with nothing to say still gets no argv — that path is unchanged. + #expect(ZmxSessionLauncher.arguments(forNode: LoopNode(title: "Chat", loopType: .sketch)) == nil) + } + + @Test + func noRuntimeMeansNoLaunchAndARemoteProjectHasNone() { + NodRuntimeLocator.binaryOverride = nil + let node = LoopNode(id: nodeID, title: "Chat", loopType: .sketch, backend: .nod) + if NodRuntimeLocator.binaryURL() == nil { + #expect(ZmxSessionLauncher.arguments(forNode: node, settings: GraphcodeSettings()) == nil) + } + NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/r/graphcode-nod") + defer { NodRuntimeLocator.binaryOverride = nil } + #expect(ZmxSessionLauncher.executable(forNode: node, projectPath: "ssh://host/repo") == nil) + } + + @Test + func aGoalLoopsConditionIsWrittenWhereGoalFilePoints() throws { + let node = LoopNode( + id: UUID(), title: "Cap", loopType: .goalBased, + goal: GoalSpec(summary: "Every paid route goes through UsageGate"), backend: .nod) + let file = try #require(NodRuntimeLocator.writeGoal(of: node)) + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + #expect(file.lastPathComponent == "goal.md") + #expect(try String(contentsOf: file, encoding: .utf8) == "Every paid route goes through UsageGate") + #expect(NodRuntimeLocator.writeGoal(of: LoopNode(title: "t", backend: .nod)) == nil) + } +} + +private func failure(of result: Result) + -> NodControlClient.Failure? +{ + if case .failure(let failure) = result { return failure } + return nil +} + +@Suite +struct NodControlClientTests { + @Test + func repliesAreOkOrARefusalWithItsReason() { + #expect(failure(of: NodControlClient.parseReply(Data(#"{"ok":true}"#.utf8))) == nil) + #expect( + failure(of: NodControlClient.parseReply(Data(#"{"ok":false,"error":"busy"}"#.utf8))) + == .refused("busy")) + #expect(failure(of: NodControlClient.parseReply(Data("nope".utf8))) == .malformedReply) + } + + @Test + func aMissingSocketIsUnreachableSoTheCallerTypesInstead() async { + let result = await NodControlClient.send( + .send(.init(text: "hi")), socketPath: "/tmp/nod-\(UUID().uuidString).sock") + #expect(failure(of: result) == .unreachable) + } + + #if canImport(Darwin) + /// A real socket: the line the runtime reads is one queued `send`, and its answer is + /// what the caller gets back. + @Test + func sendsOneQueuedLineAndReadsTheAnswer() async throws { + let path = "/tmp/nod-\(UUID().uuidString.prefix(8)).sock" + let server = try OneShotUnixServer(path: path, reply: #"{"ok":true}"# + "\n") + defer { server.close() } + let result = await NodControlClient.send(.send(.init(text: "ship it")), socketPath: path) + #expect(failure(of: result) == nil) + let line = try #require(server.received()) + let command = try NodProtocol.makeDecoder().decode(NodCommand.self, from: Data(line.utf8)) + #expect(command == .send(.init(text: "ship it", delivery: .queue))) + } + #endif +} + +#if canImport(Darwin) + /// Accepts one connection, records one line, answers with `reply`. + private final class OneShotUnixServer: @unchecked Sendable { + private let descriptor: Int32 + private let path: String + private let done = DispatchSemaphore(value: 0) + private var line: String? + + init(path: String, reply: String) throws { + self.path = path + unlink(path) + descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutablePointer(to: &address.sun_path) { field in + field.withMemoryRebound(to: CChar.self, capacity: 104) { pointer in + _ = path.withCString { strncpy(pointer, $0, 103) } + } + } + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(descriptor, 1) == 0 else { throw POSIXError(.EADDRINUSE) } + let listener = descriptor + Thread.detachNewThread { [self] in + let client = accept(listener, nil, nil) + var bytes = [UInt8]() + var buffer = [UInt8](repeating: 0, count: 256) + while !bytes.contains(UInt8(ascii: "\n")) { + let count = read(client, &buffer, buffer.count) + guard count > 0 else { break } + bytes += buffer[0.. String? { + _ = done.wait(timeout: .now() + 5) + return line + } + + func close() { + Darwin.close(descriptor) + unlink(path) + } + } +#endif diff --git a/scripts/fake-graphcode-nod b/scripts/fake-graphcode-nod new file mode 100755 index 00000000..86e240c0 --- /dev/null +++ b/scripts/fake-graphcode-nod @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""A stand-in for graphcode-nod that speaks NodRuntime/PROTOCOL.md without a model. + +Point GRAPHCODE_NOD_PATH at it to drive graphcode's Nod wiring end to end before the +real runtime ships. Every message runs one turn. A message containing ASK opens a +permission ask, ALLOW answers it, FAIL reports an expired sign-in, and DONE makes the +next goal check pass. argv.json in $NOD_STATE records how it was launched. +""" + +import json +import os +import socket +import sys +import threading +import uuid +from datetime import datetime, timezone + + +def parse(argv): + options, flags = {}, {"-p"} + index = 0 + while index < len(argv): + key = argv[index] + if key.startswith("-") and index + 1 < len(argv): + options[key.lstrip("-")] = argv[index + 1] + index += 2 + else: + index += 1 + return options + + +args = parse(sys.argv[1:]) +if "p" in args: + print("fake summary") + sys.exit(0) + +node = args.get("node", "unknown") +state = os.environ.get("NOD_STATE") or os.path.expanduser(f"~/.graphcode/nod/{node}") +os.makedirs(state, exist_ok=True) +events_path = os.path.join(state, "events.jsonl") +lock = threading.Lock() +goal_met = False +turn = 0 + + +def last_seq(): + try: + with open(events_path) as log: + lines = [line for line in log.read().splitlines() if line.strip()] + return json.loads(lines[-1])["seq"] if lines else 0 + except (OSError, ValueError, KeyError): + return 0 + + +seq = last_seq() + + +def emit(event_type, **fields): + global seq + with lock: + seq += 1 + record = {"v": 1, "seq": seq, "type": event_type, + "at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), **fields} + with open(events_path, "a") as log: + log.write(json.dumps(record, sort_keys=True) + "\n") + + +with open(os.path.join(state, "argv.json"), "a") as out: + out.write(json.dumps(sys.argv[1:]) + "\n") + +conversation = args.get("resume") or str(uuid.uuid4()) +with open(os.path.join(state, "conversation.json"), "w") as out: + json.dump({"engine": args.get("engine", "claude"), "model": args.get("model", "fake"), + "conversationID": conversation}, out) +emit("sessionStarted", engine=args.get("engine", "claude"), model=args.get("model", "fake"), + conversationID=conversation, resumed="resume" in args) +print(f"nod (fake) · {conversation}", flush=True) + + +def run_turn(text, origin): + global turn, goal_met + with lock: + turn += 1 + current = turn + emit("userMessage", id=f"u{current}", text=text, delivery="queue", attachments=[]) + emit("turnStarted", turn=current, origin=origin) + emit("toolCall", turn=current, callID=f"c{current}", tool="Shell", title=f"Echo turn {current}") + emit("activity", line=f"Echoing · turn {current}") + emit("toolResult", callID=f"c{current}", status="ok", summary="exit 0", durationMs=5) + if "ASK" in text: + emit("permissionAsked", askID=f"a{current}", kind="network", subject="swift package resolve", + reason="Fetches dependencies over the network.", answerableFromCard=False) + print(f"? asks to run swift package resolve", flush=True) + return + if "ALLOW" in text: + emit("permissionResolved", askID=f"a{current - 1}", decision="allowOnce") + if "FAIL" in text: + emit("failure", kind="signInExpired", message="Claude sign-in expired.") + return + if "DONE" in text: + goal_met = True + emit("assistantText", turn=current, messageID=f"m{current}", delta=f"Handled: {text}", final=True) + emit("usage", inputTokens=100 * current, outputTokens=10 * current, costUSD=0.01 * current, + contextUsed=0.01 * current) + if args.get("loop-type") == "goal": + emit("goalCheck", turn=current, evaluatorModel="fake", + clauses=[{"text": "said DONE", "met": goal_met}], met=goal_met) + emit("turnEnded", turn=current, filesChanged=0, added=0, removed=0, summary=f"Handled {text}") + print(f"› {text}\n done (turn {current})", flush=True) + + +def serve(): + path = os.path.join(state, "control.sock") + if os.path.exists(path): + os.unlink(path) + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(path) + server.listen(4) + while True: + client, _ = server.accept() + with client, client.makefile("rw") as stream: + for line in stream: + try: + command = json.loads(line) + except ValueError: + stream.write(json.dumps({"ok": False, "error": "not JSON"}) + "\n") + stream.flush() + continue + with open(os.path.join(state, "commands.jsonl"), "a") as log: + log.write(json.dumps(command, sort_keys=True) + "\n") + stream.write(json.dumps({"ok": True}) + "\n") + stream.flush() + if command.get("type") == "send": + threading.Thread(target=run_turn, + args=(command.get("text", ""), command.get("delivery", "queue"))).start() + + +threading.Thread(target=serve, daemon=True).start() +if "prompt" in args: + run_turn(args["prompt"], "user") +for line in sys.stdin: + if line.strip(): + run_turn(line.strip(), "queue") diff --git a/scripts/nod-daemon-e2e.py b/scripts/nod-daemon-e2e.py new file mode 100755 index 00000000..ea6ddbac --- /dev/null +++ b/scripts/nod-daemon-e2e.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""End-to-end check of graphcoded's Nod wiring against scripts/fake-graphcode-nod. + +Runs an isolated daemon (its own support directory and ZMX_DIR, never the installed one) +and drives it through the CLI the way a human or another loop would: + + launch argv · control.sock delivery · reported presence · Needs you from an open ask + · usage · goal verdict resolving the loop · daemon heartbeat into the same conversation + · restart resuming the banked conversation + +Usage: scripts/nod-daemon-e2e.py [zmx binary] +Exit 0 when every check passes. +""" + +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time + +bin_dir = sys.argv[1] +zmx = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/.graphcode/bin/zmx") +root = tempfile.mkdtemp(prefix="gn", dir="/tmp") # short: sun_path is 104 bytes +support, project, zmx_dir = f"{root}/s", f"{root}/proj", f"{root}/zmx" +for directory in (f"{support}/bin", project, zmx_dir): + os.makedirs(directory) +shutil.copy(zmx, f"{support}/bin/zmx") +fake = f"{root}/graphcode-nod" +shutil.copy(os.path.join(os.path.dirname(__file__), "fake-graphcode-nod"), fake) +os.chmod(fake, 0o755) +env = dict(os.environ, GRAPHCODE_SUPPORT_DIR=support, ZMX_DIR=zmx_dir, GRAPHCODE_NOD_PATH=fake) +daemon = subprocess.Popen([f"{bin_dir}/graphcoded"], env=env, cwd=root, + stdout=open(f"{root}/daemon.log", "w"), stderr=subprocess.STDOUT) +failures = [] + + +def cli(*args): + result = subprocess.run([f"{bin_dir}/graphcode", *args], env=env, capture_output=True, text=True) + return result.stdout + result.stderr + + +def check(name, condition, detail=""): + print(("PASS " if condition else "FAIL ") + name + (f" — {detail}" if detail and not condition else ""), + flush=True) + if not condition: + failures.append(name) + + +nodes = {} +lock = threading.Lock() + + +def watch(): + # Presence is polled only while a client is attached, so this stays connected. + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + for _ in range(50): + try: + sock.connect(f"{support}/graphcoded.sock") + break + except OSError: + time.sleep(0.2) + frame = json.dumps({"openProject": {"path": project}}).encode() + sock.sendall(struct.pack(">I", len(frame)) + frame) + + def read(count): + data = b"" + while len(data) < count: + chunk = sock.recv(count - len(data)) + if not chunk: + raise EOFError + data += chunk + return data + + def tick(): + # What the app does on its timer: the one command that refreshes presence, usage, + # activity and the summary together. + while True: + time.sleep(3) + command = json.dumps( + {"graphCommand": {"projectPath": project, "command": {"refreshUsage": {}}}}).encode() + try: + sock.sendall(struct.pack(">I", len(command)) + command) + except OSError: + return + + threading.Thread(target=tick, daemon=True).start() + try: + while True: + event = json.loads(read(struct.unpack(">I", read(4))[0])) + if "graphChanged" in event: + with lock: + for node in event["graphChanged"]["_0"]["nodes"]: + nodes[node["title"]] = node + except (EOFError, OSError): + return + + +def wait_for(title, predicate, seconds=60): + deadline = time.time() + seconds + while time.time() < deadline: + with lock: + node = nodes.get(title) + if node and predicate(node): + return node + time.sleep(0.5) + with lock: + return nodes.get(title) + + +def state_dir(node_id): + return f"{support}/nod/{node_id}" + + +def events(node_id): + try: + with open(f"{state_dir(node_id)}/events.jsonl") as log: + return [json.loads(line) for line in log if line.strip()] + except OSError: + return [] + + +def presence(node): + return (node or {}).get("presence") or {} + + +def node_id_of(title): + for line in cli("status", project).splitlines(): + if line.strip().endswith(title): + return line.split()[0] + return None + + +try: + time.sleep(1) + threading.Thread(target=watch, daemon=True).start() + + # Goal loop: launch, delivery, presence, Needs you, usage, verdict. + print(cli("node", "create", project, "--title", "NodGoal", "--type", "goal", + "--goal", "Reply DONE when told to", "--backend", "nod").strip()) + goal_id = node_id_of("NodGoal") + check("goal loop created", goal_id is not None) + node = wait_for("NodGoal", lambda n: presence(n).get("presence") == "idle") + with open(f"{state_dir(goal_id)}/argv.json") as out: + argv = json.loads(out.readline()) + check("launch argv names node, cwd, engine, loop type, goal file", + argv[:8] == ["--node", goal_id, "--cwd", project, "--engine", "claude", "--loop-type", "goal"] + and argv[8:10] == ["--goal-file", f"{state_dir(goal_id)}/goal.md"], json.dumps(argv)) + check("prompt rides behind --prompt", argv[-2] == "--prompt", json.dumps(argv)) + with open(f"{state_dir(goal_id)}/goal.md") as goal: + check("goal file holds the condition", goal.read() == "Reply DONE when told to") + check("presence is reported idle after the first turn", + presence(node) == {"presence": "idle", "confidence": "reported"}, json.dumps(presence(node))) + + print(cli("node", "send", project, goal_id, "please ASK first").strip()) + with open(f"{state_dir(goal_id)}/commands.jsonl") as log: + command = json.loads(log.readline()) + check("node send arrives on control.sock as a queued send", + command["type"] == "send" and command["delivery"] == "queue" and "ASK" in command["text"], + json.dumps(command)) + node = wait_for("NodGoal", lambda n: presence(n).get("presence") == "awaitingInput") + check("an open permissionAsked is Needs you", presence(node).get("presence") == "awaitingInput", + json.dumps(presence(node))) + node = wait_for("NodGoal", lambda n: (n.get("activity") or "").startswith("asks to run")) + check("the live line says what it asks", (node or {}).get("activity") == "asks to run swift package resolve", + str((node or {}).get("activity"))) + + cli("node", "send", project, goal_id, "ALLOW it") + node = wait_for("NodGoal", lambda n: presence(n).get("presence") == "idle" and n.get("usage")) + check("answering the ask leaves Needs you", presence(node).get("presence") == "idle", + json.dumps(presence(node))) + usage = (node or {}).get("usage") or {} + check("usage is the runtime's running total", usage.get("inputTokens") == 300, json.dumps(usage)) + + cli("node", "send", project, goal_id, "say DONE") + node = wait_for("NodGoal", lambda n: "succeeded" in (n.get("state") or {}), 150) + check("a met goalCheck resolves the loop", "succeeded" in ((node or {}).get("state") or {}), + json.dumps((node or {}).get("state"))) + + # Timed loop: the daemon's heartbeat re-enters the same conversation. + print(cli("node", "create", project, "--title", "NodTimed", "--type", "time", + "--prompt", "/loop 10s check the inbox", "--backend", "nod").strip()) + timed_id = node_id_of("NodTimed") + deadline = time.time() + 60 + while time.time() < deadline and not any( + e["type"] == "userMessage" and "Heartbeat" in e["text"] for e in events(timed_id)): + time.sleep(1) + timed = events(timed_id) + starts = [e for e in timed if e["type"] == "sessionStarted"] + check("a heartbeat reaches the timed loop", any( + e["type"] == "userMessage" and "Heartbeat" in e["text"] for e in timed)) + check("…in the same conversation", len(starts) == 1, f"{len(starts)} sessionStarted") + conversation = starts[0]["conversationID"] if starts else None + + # Restart: the banked conversation is resumed, not replaced. + deadline = time.time() + 30 + banked = f"{support}/sessions/{timed_id}.id" + while time.time() < deadline and not os.path.exists(banked): + time.sleep(1) + with open(banked) if os.path.exists(banked) else open(os.devnull) as out: + check("the conversation id is banked for resume", out.read().strip() == conversation) + print(cli("node", "restart", project, timed_id).strip()) + deadline = time.time() + 30 + while time.time() < deadline and len([e for e in events(timed_id) if e["type"] == "sessionStarted"]) < 2: + time.sleep(1) + restarted = [e for e in events(timed_id) if e["type"] == "sessionStarted"] + check("restart resumes the same conversation", + len(restarted) == 2 and restarted[1]["resumed"] and restarted[1]["conversationID"] == conversation, + json.dumps(restarted[1:] if restarted else [])) + with open(f"{state_dir(timed_id)}/argv.json") as out: + launches = [json.loads(line) for line in out if line.strip()] + check("the relaunch carries --resume and no prompt", + len(launches) == 2 and launches[1][-2:] == ["--resume", conversation] and "--prompt" not in launches[1], + json.dumps(launches[1:])) +finally: + for title, node_id in (("NodGoal", node_id_of("NodGoal")), ("NodTimed", node_id_of("NodTimed"))): + if node_id: + cli("node", "delete", project, node_id) + daemon.terminate() + daemon.wait(timeout=10) + subprocess.run([f"{support}/bin/zmx", "kill", "--all"], env=env, capture_output=True) + +print(f"{'FAILED' if failures else 'OK'}: {len(failures)} failed") +if not failures: + shutil.rmtree(root, ignore_errors=True) +else: + print(f"kept {root} for inspection") +sys.exit(1 if failures else 0) From 3150ed9dab457bd8e2c04fb180ed869d8b06b0ab Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 21:56:58 -0700 Subject: [PATCH 4/6] Install Nod's runtime with the helpers, and mark unattended loops graphcoded runs from the support directory with no bundle, so the app now copies Contents/Helpers/nod to /bin/nod with its other helpers (copied, not linked, so a moved app keeps working). Timed loops launch --unattended; nodArguments takes --inherit for fresh launches. The session env carries NOD_NODE_ID and NOD_PROJECT_PATH instead of a graph file path. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- GraphcodeKit/Sources/DaemonBootstrap.swift | 66 ++++++- .../Sources/Domain/BackendCommand.swift | 4 +- GraphcodeKit/Sources/Domain/NodProtocol.swift | 7 +- GraphcodeKit/Sources/ProjectPersistence.swift | 6 - .../Sources/Sessions/NodRuntimeLocator.swift | 35 ++-- .../Sources/Sessions/ZmxSessionLauncher.swift | 38 ++-- NodRuntime/PROTOCOL.md | 17 +- .../Ghostty/GhosttyTerminalView+Nod.swift | 3 +- graphcode/Tests/NodLaunchTests.swift | 176 +++++++++++++++++- scripts/fake-graphcode-nod | 7 +- scripts/nod-daemon-e2e.py | 8 +- 11 files changed, 299 insertions(+), 68 deletions(-) diff --git a/GraphcodeKit/Sources/DaemonBootstrap.swift b/GraphcodeKit/Sources/DaemonBootstrap.swift index 1c159fdf..84207cd3 100644 --- a/GraphcodeKit/Sources/DaemonBootstrap.swift +++ b/GraphcodeKit/Sources/DaemonBootstrap.swift @@ -78,7 +78,39 @@ import Foundation else { return nil } return "\(name):\(size):\(Int(modified.timeIntervalSince1970))" } - .joined(separator: "\n") + .joined(separator: "\n") + nodStamp(besideHelpersIn: directory) + } + + /// Nod's runtime, which a bundle carries as a directory beside the helpers rather than + /// among them, or `nil` for a bundle built before it shipped — optional, so that bundle + /// still installs. + static func bundledNodDirectory(besideHelpersIn bundled: URL) -> URL? { + let directory = bundled.deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(NodRuntimeLocator.bundledDirectory, isDirectory: true) + let binary = directory.appendingPathComponent("graphcode-nod") + return FileManager.default.isExecutableFile(atPath: binary.path) ? directory : nil + } + + private static func nodStamp(besideHelpersIn directory: URL) -> String { + guard let nod = bundledNodDirectory(besideHelpersIn: directory), + let attributes = try? FileManager.default.attributesOfItem( + atPath: nod.appendingPathComponent("graphcode-nod").path), + let size = attributes[.size] as? Int, + let modified = attributes[.modificationDate] as? Date + else { return "" } + return "\nnod:\(size):\(Int(modified.timeIntervalSince1970))" + } + + /// Whether the runtime a bundle carries is installed where `graphcoded` looks for it. + /// A bundle with none has nothing to install. + static func nodRuntimeInstalled( + from bundled: URL, in directory: URL = SupportDirectory.binDirectory + ) -> Bool { + guard bundledNodDirectory(besideHelpersIn: bundled) != nil else { return true } + let binary = directory.appendingPathComponent(NodRuntimeLocator.installedDirectory) + .appendingPathComponent("graphcode-nod") + return FileManager.default.isExecutableFile(atPath: binary.path) } /// Whether every helper is actually present and runnable where it was installed. @@ -127,7 +159,9 @@ import Foundation let expected = stamp(forHelpersIn: bundled) let current = try? String(contentsOf: stampURL, encoding: .utf8) - if current == expected, helpersInstalled(), launchAgentIsCurrent() { + if current == expected, helpersInstalled(), nodRuntimeInstalled(from: bundled), + launchAgentIsCurrent() + { // Everything a file can record is right. The one thing no file records is whether // launchd still has the agent, and it routinely does not: an agent loaded the // legacy way is not re-bootstrapped into the next login session, so a reboot or a @@ -182,7 +216,11 @@ import Foundation let binDirectory = workspace.url.appendingPathComponent("bin", isDirectory: true) let stampFile = workspace.url.appendingPathComponent("installed-helpers.txt") let installed = try? String(contentsOf: stampFile, encoding: .utf8) - if installed == expected, helpersInstalled(in: binDirectory) { continue } + if installed == expected, helpersInstalled(in: binDirectory), + nodRuntimeInstalled(from: bundled, in: binDirectory) + { + continue + } // Direction matters: any packaged copy that gets launched runs this — an old DMG // still sitting in ~/Downloads included — and "different" alone would let it // rewrite every closed workspace *backward* and bounce their daemons, ping-ponging @@ -303,6 +341,28 @@ import Foundation try? fileManager.removeItem(at: target) try fileManager.moveItem(at: staged, to: target) } + if let nod = bundledNodDirectory(besideHelpersIn: bundled) { + try installNodRuntime( + from: nod, + to: destination.appendingPathComponent(NodRuntimeLocator.installedDirectory)) + } + } + + /// The whole directory, copied rather than linked: a link into the bundle breaks the + /// moment the app is moved or translocated, and `graphcoded` would lose every Nod loop + /// with it. Staged and swapped like a helper, for the same reasons. + static func installNodRuntime(from bundled: URL, to target: URL) throws { + let fileManager = FileManager.default + let staged = target.deletingLastPathComponent() + .appendingPathComponent("\(target.lastPathComponent).new.\(getpid())") + try? fileManager.removeItem(at: staged) + defer { try? fileManager.removeItem(at: staged) } + try fileManager.copyItem(at: bundled, to: staged) + clearQuarantine(staged) + let contents = fileManager.enumerator(at: staged, includingPropertiesForKeys: nil) + while let item = contents?.nextObject() as? URL { clearQuarantine(item) } + try? fileManager.removeItem(at: target) + try fileManager.moveItem(at: staged, to: target) } /// Drops `com.apple.quarantine` from an installed helper. Failure is ignored: a file diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index 597576f8..f7d206df 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -176,7 +176,8 @@ extension CLISessionBackendKind { /// type that picks its model, and the engine. Empty for every CLI. public func nodArguments( nodeID: UUID, loopType: LoopType, settings: GraphcodeSettings, - workingDirectory: String? = nil, goalFile: String? = nil, inheritFile: String? = nil + workingDirectory: String? = nil, goalFile: String? = nil, inheritFile: String? = nil, + unattended: Bool = false ) -> [String] { guard self == .nod else { return [] } let nod = settings.nod @@ -186,6 +187,7 @@ extension CLISessionBackendKind { + (nod.model(for: loopType).map { ["--model", $0] } ?? []) + (goalFile.map { ["--goal-file", $0] } ?? []) + (inheritFile.map { ["--inherit", $0] } ?? []) + + (unattended ? ["--unattended"] : []) } /// pi reads a positional argument that starts with `-` as an option and one that starts diff --git a/GraphcodeKit/Sources/Domain/NodProtocol.swift b/GraphcodeKit/Sources/Domain/NodProtocol.swift index 2e6b4083..6e0f4b3c 100644 --- a/GraphcodeKit/Sources/Domain/NodProtocol.swift +++ b/GraphcodeKit/Sources/Domain/NodProtocol.swift @@ -15,10 +15,11 @@ public enum NodProtocol { /// to `~/.graphcode/nod/` without it, which is wrong for any workspace whose /// support directory was moved (`GRAPHCODE_SUPPORT_DIR`). public static let stateDirectoryVariable = "NOD_STATE" - /// The node's project, and the file its graph is saved in — read-only, for the graphcode - /// MCP server's siblings and edges. Unset for a node outside any project. + /// Which node this is and which project it belongs to, for the graphcode MCP server, + /// which asks `graphcoded` about siblings and edges. The project is unset for a node + /// outside any project. + public static let nodeIDVariable = "NOD_NODE_ID" public static let projectPathVariable = "NOD_PROJECT_PATH" - public static let graphFileVariable = "NOD_GRAPH_FILE" public static let eventsFileName = "events.jsonl" public static let controlSocketName = "control.sock" public static let conversationFileName = "conversation.json" diff --git a/GraphcodeKit/Sources/ProjectPersistence.swift b/GraphcodeKit/Sources/ProjectPersistence.swift index fcdb1eb0..1934d9b6 100644 --- a/GraphcodeKit/Sources/ProjectPersistence.swift +++ b/GraphcodeKit/Sources/ProjectPersistence.swift @@ -169,12 +169,6 @@ public struct ProjectPersistence: Sendable { /// must be deterministic across launches, but Windows also rejects `:`, `\`, and several /// other characters that occur in perfectly valid project paths. Hashing keeps names /// short, safe, and collision-resistant without leaking a path into a directory listing. - /// Where a project's graph is saved — read-only for anything outside the daemon, which - /// rewrites it on every change. - public func graphFileURL(forProjectPath path: String) -> URL { - fileURL(forProjectPath: path) - } - private func fileURL(forProjectPath path: String) -> URL { let key = platformPaths.persistenceKey(forProjectPath: path) return projectsDirectory.appendingPathComponent("\(key).json") diff --git a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift index b0b35440..fcf6c852 100644 --- a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift +++ b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift @@ -10,9 +10,15 @@ public enum NodRuntimeLocator { /// without touching the process environment the rest of the suite reads. static var binaryOverride: URL? - /// Tried in order: the development override, the running app's bundle, then the - /// support directory's `bin`, which is where `graphcoded` (installed out of the bundle) - /// finds its helpers. + /// The runtime's directory inside the app bundle — `graphcode-nod` with what it loads + /// beside it (NodRuntime/scripts/package.sh). + public static let bundledDirectory = "Contents/Helpers/nod" + /// Where `DaemonBootstrap` copies that directory, under the support directory's `bin`. + public static let installedDirectory = "nod" + + /// Tried in order: the development override, the running app's bundle, then the copy + /// `DaemonBootstrap` installs under the support directory's `bin` — the only one + /// `graphcoded` can see, since it runs from there with no bundle of its own. public static func binaryURL(bundle: Bundle = .main) -> URL? { if let binaryOverride { return binaryOverride } if let path = NodRuntimeLocation.developmentOverride { @@ -20,11 +26,10 @@ public enum NodRuntimeLocator { } let name = CLISessionBackendKind.nod.executableName ?? "graphcode-nod" let candidates = [ - bundle.resourceURL?.appendingPathComponent("bin/\(name)"), - SupportDirectory.binDirectory.appendingPathComponent(name), + bundle.bundleURL.appendingPathComponent("\(bundledDirectory)/\(name)"), + SupportDirectory.binDirectory.appendingPathComponent("\(installedDirectory)/\(name)"), ] - return candidates.compactMap { $0 } - .first { FileManager.default.isExecutableFile(atPath: $0.path) } + return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } } /// `$NOD_STATE` for a node — under the support directory, so a moved workspace keeps @@ -42,19 +47,15 @@ public enum NodRuntimeLocator { stateDirectory(forNodeID: nodeID).appendingPathComponent(NodProtocol.controlSocketName) } - /// The session's environment: its state directory, and — for a node in a project — the - /// project and the graph file the runtime's graphcode MCP server reads siblings and - /// edges from. + /// The session's environment: its state directory, which node it is, and its project. public static func environment(forNodeID nodeID: UUID, projectPath: String? = nil) -> [String: String] { - var environment = [NodProtocol.stateDirectoryVariable: stateDirectory(forNodeID: nodeID).path] - if let projectPath { - environment[NodProtocol.projectPathVariable] = projectPath - environment[NodProtocol.graphFileVariable] = - ProjectPersistence(baseDirectory: SupportDirectory.url) - .graphFileURL(forProjectPath: projectPath).path - } + var environment = [ + NodProtocol.stateDirectoryVariable: stateDirectory(forNodeID: nodeID).path, + NodProtocol.nodeIDVariable: nodeID.uuidString, + ] + environment[NodProtocol.projectPathVariable] = projectPath return environment } diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index a0a5e4ec..e26b7672 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1301,13 +1301,13 @@ public enum ZmxSessionLauncher { let arguments = nodPrefix + node.backend.launchArguments( - prompt: promptWithMemory, tier: tier, briefingPath: briefingPath, - settings: settings, - workspacePaths: paths, - hooksFile: hooksFile, - sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, - zmxPath: reportingPath, - sessionsDirectory: sessionsDirectory) + prompt: promptWithMemory, tier: tier, briefingPath: briefingPath, + settings: settings, + workspacePaths: paths, + hooksFile: hooksFile, + sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, + zmxPath: reportingPath, + sessionsDirectory: sessionsDirectory) let command = [ "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", @@ -1336,12 +1336,12 @@ public enum ZmxSessionLauncher { let arguments = nodPrefix + node.backend.launchArguments( - prompt: prompt, tier: tier, briefingPath: briefingPath, settings: settings, - workspacePaths: workspacePaths, - hooksFile: hooksFile, - sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, - zmxPath: reportingPath, - sessionsDirectory: sessionsDirectory) + prompt: prompt, tier: tier, briefingPath: briefingPath, settings: settings, + workspacePaths: workspacePaths, + hooksFile: hooksFile, + sessionName: SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, + zmxPath: reportingPath, + sessionsDirectory: sessionsDirectory) return [ "run", SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName, "-d", ] @@ -1490,7 +1490,8 @@ public enum ZmxSessionLauncher { briefingPath: Self.resumeBriefingPath( forBackend: node.backend, projectPath: projectPath, isRemote: remote != nil, settings: settings), - hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath, nodeID: node.id, projectPath: projectPath), + hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath, nodeID: node.id, + projectPath: projectPath), scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) } @@ -1504,15 +1505,18 @@ public enum ZmxSessionLauncher { } /// `nodArguments` for a launch from here: the node's working directory, and its goal - /// written where `--goal-file` points. Empty for every other backend. + /// written where `--goal-file` points. Empty for every other backend. `inheritFile` is + /// for a fresh launch only — a resumed conversation already has what it inherited. static func nodArguments( - forNode node: LoopNode, projectPath: String?, settings: GraphcodeSettings + forNode node: LoopNode, projectPath: String?, settings: GraphcodeSettings, + inheritFile: String? = nil ) -> [String] { guard node.backend == .nod else { return [] } return node.backend.nodArguments( nodeID: node.id, loopType: node.loopType, settings: settings, workingDirectory: workingDirectory(forNode: node, projectPath: projectPath), - goalFile: NodRuntimeLocator.writeGoal(of: node)?.path) + goalFile: NodRuntimeLocator.writeGoal(of: node)?.path, inheritFile: inheritFile, + unattended: node.loopType == .timeBased) } /// A Nod session with nothing to say yet — a main loop with no starting note. Unlike a diff --git a/NodRuntime/PROTOCOL.md b/NodRuntime/PROTOCOL.md index d8ecb733..da28a2b5 100644 --- a/NodRuntime/PROTOCOL.md +++ b/NodRuntime/PROTOCOL.md @@ -14,24 +14,27 @@ graphcode starts `graphcode-nod` inside the node's zmx session: ``` graphcode-nod --node [--cwd ] --engine claude|copilot --loop-type main|goal|timed|turn|composite - [--model ] [--goal-file ] [--inherit ] [--briefing ] - [--prompt ] [--resume ] + [--model ] [--goal-file ] [--inherit ] [--unattended] + [--briefing ] [--prompt ] [--resume ] ``` - `NOD_STATE` (`NodProtocol.stateDirectoryVariable`) is always set, to `/nod/`. Use it rather than computing `~/.graphcode/...`: a workspace can move its support directory. Create it if missing. -- `NOD_PROJECT_PATH` and `NOD_GRAPH_FILE` are set for a node in a project: the project - path, and the graph file the daemon saves (read it, never write it — the daemon - rewrites it on every change). +- `NOD_NODE_ID` is always set, and `NOD_PROJECT_PATH` is set for a node in a project, for + the graphcode MCP server, which asks `graphcoded`'s socket about siblings and edges. +- `--unattended` marks a loop nobody watches: timed loops, and composite children. A + permission it would have to ask about fails the run (`permissionUnavailable`). - `--inherit ` hands a fresh composite child or fork the brief it starts from. It is never passed on `--resume`. - `--goal-file` is `$NOD_STATE/goal.md`, the goal's condition as plain text, rewritten on every launch. - `--resume` is the `conversationID` of an earlier `sessionStarted`. graphcode banks it from the event log while the session is live, so a reboot or restart resumes. -- The binary comes from `Contents/Resources/bin/graphcode-nod` in the app bundle, then the - support directory's `bin/`. `GRAPHCODE_NOD_PATH` overrides both for development. +- The binary is `Contents/Helpers/nod/graphcode-nod` in the app bundle for the app's own + panes. `graphcoded` runs `/bin/nod/graphcode-nod`: the app copies the whole + `Helpers/nod` directory there when it installs its helpers. `GRAPHCODE_NOD_PATH` + overrides both for development. - A plain line on the PTY is a queued `send`. graphcode falls back to typing when `control.sock` is not there yet, so read stdin from the first moment. diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift index 827e3e21..532a303c 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift @@ -16,7 +16,8 @@ extension GhosttyTerminalView { nodeID: nodeID, loopType: loopType, settings: settings, workingDirectory: effectiveWorkingDirectory, goalFile: loopType == .goalBased && FileManager.default.fileExists(atPath: goalFile) - ? goalFile : nil) + ? goalFile : nil, + unattended: loopType == .timeBased) let environment = NodRuntimeLocator.environment(forNodeID: nodeID, projectPath: projectPath) .sorted { $0.key < $1.key } .map { "\($0.key)=\(PresenceHooks.singleQuoted($0.value))" } diff --git a/graphcode/Tests/NodLaunchTests.swift b/graphcode/Tests/NodLaunchTests.swift index a38ea199..310c4264 100644 --- a/graphcode/Tests/NodLaunchTests.swift +++ b/graphcode/Tests/NodLaunchTests.swift @@ -11,7 +11,8 @@ private let start = Date(timeIntervalSince1970: 1_790_000_000) private func records(_ events: [NodEvent]) -> [NodEventRecord] { events.enumerated().map { - NodEventRecord(seq: $0.offset + 1, at: start.addingTimeInterval(Double($0.offset)), event: $0.element) + NodEventRecord( + seq: $0.offset + 1, at: start.addingTimeInterval(Double($0.offset)), event: $0.element) } } @@ -100,7 +101,8 @@ struct NodSessionFoldTests { @Test func theLiveLineFollowsToolsUntilTheyFinish() { - let call = NodEvent.toolCall(.init(turn: 1, callID: "c1", tool: "Grep", title: "Search \"UsageGate\"")) + let call = NodEvent.toolCall( + .init(turn: 1, callID: "c1", tool: "Grep", title: "Search \"UsageGate\"")) var events: [NodEvent] = [started, .turnStarted(.init(turn: 1, origin: .user)), call] #expect(NodSessionFold(records: records(events)).activityLine == "Search \"UsageGate\"") events.append(.toolResult(.init(callID: "c1", status: .ok, summary: "6 hits"))) @@ -139,7 +141,8 @@ struct NodSessionFoldTests { #expect(notYet.goalVerdict?.met == false) #expect(notYet.cardState.goalProgress == NodGoalProgress(met: 1, total: 2)) - let met = NodSessionFold(records: records([started, check([true, false]), check([true, true])])) + let met = NodSessionFold( + records: records([started, check([true, false]), check([true, true])])) #expect(met.goalVerdict?.met == true) #expect(met.goalVerdict?.detail == "2 of 2 clauses met") #expect(met.goalVerdict?.recordedAt == start.addingTimeInterval(2)) @@ -148,7 +151,8 @@ struct NodSessionFoldTests { @Test func unknownEventsChangeNothing() { - let known = NodSessionFold(records: records([started, .turnStarted(.init(turn: 1, origin: .user))])) + let known = NodSessionFold( + records: records([started, .turnStarted(.init(turn: 1, origin: .user))])) var withUnknown = known withUnknown.apply(NodEventRecord(seq: 3, at: start, event: .unknown("fromTheFuture"))) #expect(withUnknown.presence == known.presence) @@ -182,9 +186,11 @@ struct NodSessionLogTests { of: records([ started, .turnStarted(.init(turn: 1, origin: .user)), - .assistantText(.init(turn: 1, messageID: "m1", delta: "Found it. ExportRoute ", final: false)), .assistantText( - .init(turn: 1, messageID: "m1", delta: "is registered before UsageGate runs.", final: true)), + .init(turn: 1, messageID: "m1", delta: "Found it. ExportRoute ", final: false)), + .assistantText( + .init( + turn: 1, messageID: "m1", delta: "is registered before UsageGate runs.", final: true)), .toolCall(.init(turn: 1, callID: "c1", tool: "Read", title: "Read UsageGate.swift")), .turnEnded(.init(turn: 1, filesChanged: 0, added: 0, removed: 0, summary: nil)), ]), metricSamples: []) @@ -248,9 +254,15 @@ struct NodLaunchArgumentTests { #expect(Array(arguments.prefix(3)) == ["run", "graphcode-\(nodeID.uuidString)", "-d"]) let script = arguments[7] let state = NodRuntimeLocator.stateDirectory(forNodeID: nodeID).path - #expect(script.hasPrefix("exec env NOD_STATE=\"\(state)\" /Apps/GraphCode.app/bin/graphcode-nod ")) + #expect( + script.hasPrefix( + "exec env NOD_NODE_ID=\"\(nodeID.uuidString)\" NOD_STATE=\"\(state)\" " + + "/Apps/GraphCode.app/bin/graphcode-nod ")) let argv = Array(arguments.dropFirst(9)) - #expect(Array(argv.prefix(6)) == ["--node", nodeID.uuidString, "--engine", "claude", "--loop-type", "turn"]) + #expect( + Array(argv.prefix(6)) == [ + "--node", nodeID.uuidString, "--engine", "claude", "--loop-type", "turn", + ]) #expect(argv.suffix(2).first == "--prompt") let resume = try #require( @@ -261,6 +273,29 @@ struct NodLaunchArgumentTests { #expect(!resume.contains("--prompt")) } + @Test + func aTimedLoopIsUnattendedAndTheEnvironmentNamesItsProject() { + let timed = LoopNode( + id: nodeID, title: "Nightly", loopType: .timeBased, triggerPrompt: "/loop 1h deps", + backend: .nod) + let argv = ZmxSessionLauncher.nodArguments( + forNode: timed, projectPath: nil, settings: GraphcodeSettings(), inheritFile: "/b.md") + #expect(Array(argv.suffix(3)) == ["--inherit", "/b.md", "--unattended"]) + let goal = LoopNode(id: nodeID, title: "Cap", loopType: .turnBased, backend: .nod) + #expect( + !ZmxSessionLauncher.nodArguments( + forNode: goal, projectPath: nil, settings: GraphcodeSettings() + ) + .contains("--unattended")) + #expect( + NodRuntimeLocator.environment(forNodeID: nodeID, projectPath: "/p") + == [ + "NOD_STATE": NodRuntimeLocator.stateDirectory(forNodeID: nodeID).path, + "NOD_NODE_ID": nodeID.uuidString, "NOD_PROJECT_PATH": "/p", + ]) + #expect(NodRuntimeLocator.environment(forNodeID: nodeID)["NOD_PROJECT_PATH"] == nil) + } + @Test func aMainLoopWithNothingToSayStillLaunches() throws { NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/r/graphcode-nod") @@ -272,7 +307,8 @@ struct NodLaunchArgumentTests { Array(arguments.dropFirst(9)) == ["--node", nodeID.uuidString, "--engine", "claude", "--loop-type", "main"]) // A CLI with nothing to say still gets no argv — that path is unchanged. - #expect(ZmxSessionLauncher.arguments(forNode: LoopNode(title: "Chat", loopType: .sketch)) == nil) + #expect( + ZmxSessionLauncher.arguments(forNode: LoopNode(title: "Chat", loopType: .sketch)) == nil) } @Test @@ -295,7 +331,8 @@ struct NodLaunchArgumentTests { let file = try #require(NodRuntimeLocator.writeGoal(of: node)) defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } #expect(file.lastPathComponent == "goal.md") - #expect(try String(contentsOf: file, encoding: .utf8) == "Every paid route goes through UsageGate") + #expect( + try String(contentsOf: file, encoding: .utf8) == "Every paid route goes through UsageGate") #expect(NodRuntimeLocator.writeGoal(of: LoopNode(title: "t", backend: .nod)) == nil) } } @@ -395,3 +432,122 @@ struct NodControlClientTests { } } #endif + +@Suite +struct NodGraphStoreTests { + private func store( + _ nodes: [LoopNode], verdict: GoalVerdict? = nil, presence: Presence = .idle + ) -> GraphStore { + var graph = LoopGraph(project: ProjectRef(path: "", name: "p")) + for node in nodes { graph.nodes.append(node) } + return GraphStore( + graph: graph, + onReadActivity: { _, _ in "asks to run swift package resolve" }, + onReadPresence: { _, _ in PresenceReading(presence: presence, confidence: .reported) }, + onReadGoalVerdict: { _, _ in verdict }) + } + + /// Nod's goal has no directive, which used to mean no verdict poller at all: the loop + /// met its goal and ran on. + @Test + func aNodGoalLoopIsResolvedByItsRecordedVerdict() async { + let node = LoopNode( + title: "Cap", loopType: .goalBased, + goal: GoalSpec(summary: "Every paid route is gated", pollIntervalSeconds: 1), + backend: .nod, state: .running) + let store = store([node], verdict: GoalVerdict(met: true, detail: "1 of 1 clauses met")) + await store.ensureUnattendedSessions() + for _ in 0..<40 where await store.graph.nodes[id: node.id]?.state != .succeeded { + try? await Task.sleep(for: .milliseconds(100)) + } + let resolved = await store.graph.nodes[id: node.id] + #expect(resolved?.state == .succeeded) + #expect(resolved?.resolution?.basis == .nativeGoal) + #expect(resolved?.resolution?.detail == "1 of 1 clauses met") + } + + @Test + func aNodLoopWaitingOnAHumanSaysWhatItAsks() async { + let nod = LoopNode(title: "Nod", loopType: .turnBased, backend: .nod, state: .running) + let claude = LoopNode(title: "Claude", loopType: .turnBased, state: .running) + let store = store([nod, claude], presence: .awaitingInput) + await store.handle(.refreshUsage) + let graph = await store.graph + #expect(graph.nodes[id: nod.id]?.activity == "asks to run swift package resolve") + #expect(graph.nodes[id: claude.id]?.activity == nil) + } +} + +#if os(macOS) + /// `graphcoded` runs from the support directory with no bundle of its own, so a Nod loop + /// it launches can only find the runtime the app copied there. + @Suite + struct NodRuntimeInstallTests { + private func makeApp(withNod: Bool) throws -> URL { + let app = FileManager.default.temporaryDirectory + .appendingPathComponent("nod-\(UUID().uuidString).app", isDirectory: true) + let bin = app.appendingPathComponent("Contents/Resources/bin", isDirectory: true) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + for name in ["graphcoded", "zmx", "graphcode"] { + FileManager.default.createFile( + atPath: bin.appendingPathComponent(name).path, contents: Data(name.utf8), + attributes: [.posixPermissions: 0o755]) + } + if withNod { + let nod = app.appendingPathComponent(NodRuntimeLocator.bundledDirectory) + try FileManager.default.createDirectory(at: nod, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: nod.appendingPathComponent("graphcode-nod").path, contents: Data("nod".utf8), + attributes: [.posixPermissions: 0o755]) + FileManager.default.createFile( + atPath: nod.appendingPathComponent("runtime.node").path, contents: Data("rt".utf8)) + } + return app + } + + @Test + func theWholeRuntimeDirectoryIsCopiedBesideTheHelpers() throws { + let app = try makeApp(withNod: true) + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("nod-bin-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: app) + try? FileManager.default.removeItem(at: destination) + } + let bundled = app.appendingPathComponent("Contents/Resources/bin") + #expect(!DaemonBootstrap.nodRuntimeInstalled(from: bundled, in: destination)) + #expect(DaemonBootstrap.stamp(forHelpersIn: bundled).contains("\nnod:")) + + try DaemonBootstrap.installHelpers(from: bundled, to: destination) + + let installed = destination.appendingPathComponent("nod/graphcode-nod") + #expect(FileManager.default.isExecutableFile(atPath: installed.path)) + #expect( + FileManager.default.fileExists( + atPath: destination.appendingPathComponent("nod/runtime.node").path)) + let type = try FileManager.default.attributesOfItem(atPath: installed.path)[.type] + #expect(type as? FileAttributeType == .typeRegular) + #expect(DaemonBootstrap.nodRuntimeInstalled(from: bundled, in: destination)) + + try DaemonBootstrap.installHelpers(from: bundled, to: destination) + #expect(FileManager.default.isExecutableFile(atPath: installed.path)) + } + + @Test + func aBundleWithoutNodStillInstallsAndOwesNothing() throws { + let app = try makeApp(withNod: false) + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("nod-bin-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: app) + try? FileManager.default.removeItem(at: destination) + } + let bundled = app.appendingPathComponent("Contents/Resources/bin") + #expect(!DaemonBootstrap.stamp(forHelpersIn: bundled).contains("nod:")) + try DaemonBootstrap.installHelpers(from: bundled, to: destination) + #expect(DaemonBootstrap.nodRuntimeInstalled(from: bundled, in: destination)) + #expect( + !FileManager.default.fileExists(atPath: destination.appendingPathComponent("nod").path)) + } + } +#endif diff --git a/scripts/fake-graphcode-nod b/scripts/fake-graphcode-nod index 86e240c0..a0faf0cf 100755 --- a/scripts/fake-graphcode-nod +++ b/scripts/fake-graphcode-nod @@ -17,11 +17,14 @@ from datetime import datetime, timezone def parse(argv): - options, flags = {}, {"-p"} + options, switches = {}, {"--unattended"} index = 0 while index < len(argv): key = argv[index] - if key.startswith("-") and index + 1 < len(argv): + if key in switches: + options[key.lstrip("-")] = True + index += 1 + elif key.startswith("-") and index + 1 < len(argv): options[key.lstrip("-")] = argv[index + 1] index += 2 else: diff --git a/scripts/nod-daemon-e2e.py b/scripts/nod-daemon-e2e.py index ea6ddbac..90470f01 100755 --- a/scripts/nod-daemon-e2e.py +++ b/scripts/nod-daemon-e2e.py @@ -144,7 +144,10 @@ def node_id_of(title): "--goal", "Reply DONE when told to", "--backend", "nod").strip()) goal_id = node_id_of("NodGoal") check("goal loop created", goal_id is not None) - node = wait_for("NodGoal", lambda n: presence(n).get("presence") == "idle") + # Generous: the first launch waits on a login shell, and on a loaded machine the + # daemon's zmx probes are slow. + node = wait_for("NodGoal", lambda n: presence(n).get("presence") == "idle", 180) + check("the runtime was launched", os.path.exists(f"{state_dir(goal_id)}/argv.json")) with open(f"{state_dir(goal_id)}/argv.json") as out: argv = json.loads(out.readline()) check("launch argv names node, cwd, engine, loop type, goal file", @@ -189,6 +192,9 @@ def node_id_of(title): while time.time() < deadline and not any( e["type"] == "userMessage" and "Heartbeat" in e["text"] for e in events(timed_id)): time.sleep(1) + with open(f"{state_dir(timed_id)}/argv.json") as out: + timed_argv = json.loads(out.readline()) + check("a timed loop is launched --unattended", "--unattended" in timed_argv, json.dumps(timed_argv)) timed = events(timed_id) starts = [e for e in timed if e["type"] == "sessionStarted"] check("a heartbeat reaches the timed loop", any( From dda943374d28776e6913d91940f96c755fc52f48 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 22:21:48 -0700 Subject: [PATCH 5/6] Refuse Nod while its ramp is off, in the daemon and the CLI FeatureRamps.nod is the app's; graphcoded and the CLI have no ramps. The app now mirrors it into /nod/ramp.on on each launch, as it does the codespace multiplex flag, and without it node creation is refused with a reason, and neither launcher resolves a runtime. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Sources/CLI/GraphcodeCommand.swift | 7 ++ GraphcodeKit/Sources/GraphStore.swift | 12 ++++ .../Sources/Sessions/NodRuntimeLocator.swift | 17 +++++ graphcode/Sources/Clients/FeatureRamps.swift | 11 ++- graphcode/Tests/NodLaunchTests.swift | 69 ++++++++++++++++++- scripts/nod-daemon-e2e.py | 11 ++- 6 files changed, 121 insertions(+), 6 deletions(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index b3138690..e9be8da3 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -79,6 +79,8 @@ public enum GraphcodeCommand: Equatable, Sendable { case missingArgument(String) case invalidValue(argument: String, value: String) case invalidDraft + /// `FeatureRamps.nod` is off for this install — `NodRuntimeLocator.isRampedOn`. + case nodNotEnabled } public static let helpText = """ @@ -556,6 +558,9 @@ public enum GraphcodeCommand: Equatable, Sendable { // The same validation the daemon applies, run early so the CLI can say what's // missing instead of exiting 0 on a command that quietly did nothing. + guard draft.effectiveBackend != .nod || NodRuntimeLocator.isRampedOn else { + throw ParseError.nodNotEnabled + } guard draft.isValid else { throw ParseError.invalidDraft } return draft } @@ -1029,6 +1034,8 @@ extension GraphcodeCommand { case .invalidDraft: return "incomplete loop: a turn-based node needs --check, a goal-based one --goal, " + "a time-based one --prompt, and the backend must be able to host that type" + case .nodNotEnabled: + return GraphStore.nodRampRefusal("refused") } } diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 8c293655..e32495ab 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -1053,6 +1053,10 @@ public actor GraphStore { "composites are nested \(subGraphDepth) deep (limit \(Self.maxSubGraphDepth))", broadcastErrors: broadcastErrors) } + guard draft.effectiveBackend != .nod || NodRuntimeLocator.isRampedOn else { + return await reject( + Self.nodRampRefusal("node creation refused"), broadcastErrors: broadcastErrors) + } guard draft.isValid else { return await reject( "node creation refused: draft is invalid", @@ -5015,3 +5019,11 @@ private actor DeliveryAttempt { return await withCheckedContinuation { waiter = $0 } } } + +extension GraphStore { + /// One sentence for every refusal the Nod ramp causes, so the CLI and the app say the + /// same thing. + static func nodRampRefusal(_ prefix: String) -> String { + "\(prefix): \(CLISessionBackendKind.nod.fullName) is not enabled on this install yet" + } +} diff --git a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift index fcf6c852..707467cf 100644 --- a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift +++ b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift @@ -10,6 +10,22 @@ public enum NodRuntimeLocator { /// without touching the process environment the rest of the suite reads. static var binaryOverride: URL? + /// Wins over `rampFlag`, for tests only. + static var rampOverride: Bool? + + /// Present while `FeatureRamps.nod` is on for this install. The app mirrors the ramp + /// here because `graphcoded` and the CLI have no ramps of their own; deleting + /// docs/ramps.json's entry is the kill switch for both, from the app's next launch. + public static var rampFlag: URL { + SupportDirectory.url.appendingPathComponent("nod", isDirectory: true) + .appendingPathComponent("ramp.on") + } + + /// Whether Nod may be created or launched at all on this install. + public static var isRampedOn: Bool { + rampOverride ?? FileManager.default.fileExists(atPath: rampFlag.path) + } + /// The runtime's directory inside the app bundle — `graphcode-nod` with what it loads /// beside it (NodRuntime/scripts/package.sh). public static let bundledDirectory = "Contents/Helpers/nod" @@ -20,6 +36,7 @@ public enum NodRuntimeLocator { /// `DaemonBootstrap` installs under the support directory's `bin` — the only one /// `graphcoded` can see, since it runs from there with no bundle of its own. public static func binaryURL(bundle: Bundle = .main) -> URL? { + guard isRampedOn else { return nil } if let binaryOverride { return binaryOverride } if let path = NodRuntimeLocation.developmentOverride { return URL(fileURLWithPath: path) diff --git a/graphcode/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index 93434c4f..04cdc11e 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -98,7 +98,10 @@ enum FeatureRamps { /// change is seen next launch, not whenever the cache expires; failures keep the /// last good configuration. static func refresh() async { - defer { publishCodespaceMultiplexFlag(enabled: isEnabled(.codespaceMultiplex)) } + defer { + publishCodespaceMultiplexFlag(enabled: isEnabled(.codespaceMultiplex)) + publishNodFlag(enabled: isEnabled(.nod)) + } var request = URLRequest(url: rampsURL) request.cachePolicy = .reloadIgnoringLocalCacheData request.timeoutInterval = 10 @@ -124,6 +127,12 @@ enum FeatureRamps { } } + /// The same mirror for `.nod`, which `graphcoded` and the CLI check before creating or + /// launching a Nod loop (`NodRuntimeLocator.isRampedOn`). + static func publishNodFlag(enabled: Bool, flag: URL = NodRuntimeLocator.rampFlag) { + publishCodespaceMultiplexFlag(enabled: enabled, flag: flag) + } + static func cachedConfiguration() -> Configuration? { guard let data = UserDefaults.standard.data(forKey: configurationDefaultsKey) else { return nil } diff --git a/graphcode/Tests/NodLaunchTests.swift b/graphcode/Tests/NodLaunchTests.swift index 310c4264..6e0552af 100644 --- a/graphcode/Tests/NodLaunchTests.swift +++ b/graphcode/Tests/NodLaunchTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import GraphcodeKit +@testable import graphcode #if canImport(Darwin) import Darwin @@ -245,7 +246,11 @@ struct NodLaunchArgumentTests { @Test func theDaemonLaunchesTheBundledRuntimeWithItsStateDirectory() throws { NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/Apps/GraphCode.app/bin/graphcode-nod") - defer { NodRuntimeLocator.binaryOverride = nil } + NodRuntimeLocator.rampOverride = true + defer { + NodRuntimeLocator.binaryOverride = nil + NodRuntimeLocator.rampOverride = nil + } let node = LoopNode( id: nodeID, title: "Cap", loopType: .turnBased, checkDescription: "tests pass", backend: .nod) @@ -299,7 +304,11 @@ struct NodLaunchArgumentTests { @Test func aMainLoopWithNothingToSayStillLaunches() throws { NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/r/graphcode-nod") - defer { NodRuntimeLocator.binaryOverride = nil } + NodRuntimeLocator.rampOverride = true + defer { + NodRuntimeLocator.binaryOverride = nil + NodRuntimeLocator.rampOverride = nil + } let node = LoopNode(id: nodeID, title: "Chat", loopType: .sketch, backend: .nod) let arguments = try #require( ZmxSessionLauncher.arguments(forNode: node, settings: GraphcodeSettings())) @@ -319,10 +328,64 @@ struct NodLaunchArgumentTests { #expect(ZmxSessionLauncher.arguments(forNode: node, settings: GraphcodeSettings()) == nil) } NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/r/graphcode-nod") - defer { NodRuntimeLocator.binaryOverride = nil } + NodRuntimeLocator.rampOverride = true + defer { + NodRuntimeLocator.binaryOverride = nil + NodRuntimeLocator.rampOverride = nil + } #expect(ZmxSessionLauncher.executable(forNode: node, projectPath: "ssh://host/repo") == nil) } + /// The ramp is the kill switch: off, nothing launches even with a runtime in hand. + @Test + func theRampTurnedOffLaunchesNothing() { + NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/r/graphcode-nod") + NodRuntimeLocator.rampOverride = false + defer { + NodRuntimeLocator.binaryOverride = nil + NodRuntimeLocator.rampOverride = nil + } + let node = LoopNode(id: nodeID, title: "Chat", loopType: .sketch, backend: .nod) + #expect(NodRuntimeLocator.binaryURL() == nil) + #expect(ZmxSessionLauncher.arguments(forNode: node, settings: GraphcodeSettings()) == nil) + } + + @Test + func theAppMirrorsTheRampIntoTheFlagTheDaemonReads() throws { + let flag = FileManager.default.temporaryDirectory + .appendingPathComponent("nod-\(UUID().uuidString)/ramp.on") + defer { try? FileManager.default.removeItem(at: flag.deletingLastPathComponent()) } + FeatureRamps.publishNodFlag(enabled: true, flag: flag) + #expect(FileManager.default.fileExists(atPath: flag.path)) + FeatureRamps.publishNodFlag(enabled: false, flag: flag) + #expect(!FileManager.default.fileExists(atPath: flag.path)) + } + + @Test + func theCLIAndTheDaemonRefuseNodWhileTheRampIsOff() async throws { + NodRuntimeLocator.rampOverride = false + defer { NodRuntimeLocator.rampOverride = nil } + let draft = NodeDraft(title: "Chat", loopType: .sketch, backend: .nod) + let store = GraphStore() + let result = await store.handle(.createNode(draft)) + if case .rejected(let message, _) = result { + #expect(message.contains("GraphCode Nod is not enabled")) + } else { + Issue.record("expected a refusal, got \(result)") + } + #expect(await store.graph.nodes.isEmpty) + #expect( + throws: GraphcodeCommand.ParseError.nodNotEnabled, + performing: { + try GraphcodeCommand.parse([ + "node", "create", "/p", "--title", "Chat", "--type", "main", "--backend", "nod", + ]) + }) + #expect( + GraphcodeCommand.describe(.nodNotEnabled) + == "refused: GraphCode Nod is not enabled on this install yet") + } + @Test func aGoalLoopsConditionIsWrittenWhereGoalFilePoints() throws { let node = LoopNode( diff --git a/scripts/nod-daemon-e2e.py b/scripts/nod-daemon-e2e.py index 90470f01..58ea2218 100755 --- a/scripts/nod-daemon-e2e.py +++ b/scripts/nod-daemon-e2e.py @@ -4,7 +4,7 @@ Runs an isolated daemon (its own support directory and ZMX_DIR, never the installed one) and drives it through the CLI the way a human or another loop would: - launch argv · control.sock delivery · reported presence · Needs you from an open ask + ramp refusal · launch argv · control.sock delivery · reported presence · Needs you from an open ask · usage · goal verdict resolving the loop · daemon heartbeat into the same conversation · restart resuming the banked conversation @@ -27,7 +27,7 @@ zmx = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/.graphcode/bin/zmx") root = tempfile.mkdtemp(prefix="gn", dir="/tmp") # short: sun_path is 104 bytes support, project, zmx_dir = f"{root}/s", f"{root}/proj", f"{root}/zmx" -for directory in (f"{support}/bin", project, zmx_dir): +for directory in (f"{support}/bin", f"{support}/nod", project, zmx_dir): os.makedirs(directory) shutil.copy(zmx, f"{support}/bin/zmx") fake = f"{root}/graphcode-nod" @@ -139,6 +139,13 @@ def node_id_of(title): time.sleep(1) threading.Thread(target=watch, daemon=True).start() + # The ramp is off until the app mirrors it into the flag: both refusals say so. + refused = cli("node", "create", project, "--title", "NodOff", "--type", "main", "--backend", "nod") + check("with the ramp off, the CLI refuses a Nod loop", "is not enabled on this install" in refused, + refused.strip()) + check("…and nothing was created", node_id_of("NodOff") is None) + open(f"{support}/nod/ramp.on", "w").close() + # Goal loop: launch, delivery, presence, Needs you, usage, verdict. print(cli("node", "create", project, "--title", "NodGoal", "--type", "goal", "--goal", "Reply DONE when told to", "--backend", "nod").strip()) From 3a5d801fb9e97dd2285c243f2db3eba3bed4ab64 Mon Sep 17 00:00:00 2001 From: scgopi Date: Thu, 1 Oct 2026 23:01:20 -0700 Subject: [PATCH 6/6] Build Nod's argv in steps the Linux type checker can finish Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- GraphcodeKit/Sources/Domain/BackendCommand.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index f7d206df..8e74de28 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -181,13 +181,14 @@ extension CLISessionBackendKind { ) -> [String] { guard self == .nod else { return [] } let nod = settings.nod - return ["--node", nodeID.uuidString] - + (workingDirectory.map { ["--cwd", $0] } ?? []) - + ["--engine", nod.engine.rawValue, "--loop-type", loopType.nodArgument] - + (nod.model(for: loopType).map { ["--model", $0] } ?? []) - + (goalFile.map { ["--goal-file", $0] } ?? []) - + (inheritFile.map { ["--inherit", $0] } ?? []) - + (unattended ? ["--unattended"] : []) + var arguments = ["--node", nodeID.uuidString] + if let workingDirectory { arguments += ["--cwd", workingDirectory] } + arguments += ["--engine", nod.engine.rawValue, "--loop-type", loopType.nodArgument] + if let model = nod.model(for: loopType) { arguments += ["--model", model] } + if let goalFile { arguments += ["--goal-file", goalFile] } + if let inheritFile { arguments += ["--inherit", inheritFile] } + if unattended { arguments.append("--unattended") } + return arguments } /// pi reads a positional argument that starts with `-` as an option and one that starts