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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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")
}
}

Expand Down
66 changes: 63 additions & 3 deletions GraphcodeKit/Sources/DaemonBootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions GraphcodeKit/Sources/Domain/BackendCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 43 additions & 9 deletions GraphcodeKit/Sources/Domain/BackendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down Expand Up @@ -165,10 +165,32 @@ 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, inheritFile: String? = nil,
unattended: Bool = false
) -> [String] {
guard self == .nod else { return [] }
let nod = settings.nod
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
/// 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
Expand All @@ -182,9 +204,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"
}
}

Expand Down Expand Up @@ -234,17 +256,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 <id>` 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 []
}
}

Expand Down Expand Up @@ -320,3 +341,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"
}
}
}
4 changes: 2 additions & 2 deletions GraphcodeKit/Sources/Domain/CLISessionBackendKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
33 changes: 33 additions & 0 deletions GraphcodeKit/Sources/Domain/NodProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ 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/<node-uuid>` without it, which is wrong for any workspace whose
/// support directory was moved (`GRAPHCODE_SUPPORT_DIR`).
public static let stateDirectoryVariable = "NOD_STATE"
/// 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 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
Expand Down Expand Up @@ -590,3 +606,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
}
}
Loading
Loading