diff --git a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift index 76b95509..3c2a8ace 100644 --- a/GraphcodeKit/Sources/Domain/BackendCapabilities.swift +++ b/GraphcodeKit/Sources/Domain/BackendCapabilities.swift @@ -278,11 +278,10 @@ extension CLISessionBackendKind { /// (`GhosttyTerminalView.command`), so a loop labelled Codex opened a Claude Code /// session. Silently running a different agent than the one the picker says is worse /// 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 — except where a developer - /// points `NodRuntimeLocation.overrideVariable` at a runtime (or a fake of one). - public var isSpiked: Bool { self != .nod || NodRuntimeLocation.developmentOverride != nil } + /// Every CLI, now that each has an adapter and a row read off its real binary. Nod ships + /// inside the app, so it is launchable wherever its ramp is on and its runtime is + /// present (`NodRuntimeLocation.isAvailable`) — and hosts nothing anywhere else. + public var isSpiked: Bool { self != .nod || NodRuntimeLocation.isAvailable() } /// 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/NodProtocol.swift b/GraphcodeKit/Sources/Domain/NodProtocol.swift index b03bffff..67d3a82b 100644 --- a/GraphcodeKit/Sources/Domain/NodProtocol.swift +++ b/GraphcodeKit/Sources/Domain/NodProtocol.swift @@ -671,6 +671,13 @@ public enum NodRuntimeLocation { /// launches the session: the app for a pane, `graphcoded` for an unattended loop. public static let overrideVariable = "GRAPHCODE_NOD_PATH" + /// Whether this process can launch Nod. Domain cannot see the support directory or the + /// app bundle, so each process installs the real answer at startup + /// (`NodRuntimeLocator.installAvailability`); until then only the override counts. + nonisolated(unsafe) public static var isAvailable: @Sendable () -> Bool = { + developmentOverride != nil + } + /// The override, when it names an executable file. public static var developmentOverride: String? { guard let path = ProcessInfo.processInfo.environment[overrideVariable], !path.isEmpty, diff --git a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift index 707467cf..a816ac06 100644 --- a/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift +++ b/GraphcodeKit/Sources/Sessions/NodRuntimeLocator.swift @@ -21,9 +21,13 @@ public enum NodRuntimeLocator { .appendingPathComponent("ramp.on") } + /// The test host shares ~/.graphcode with whatever Nod this Mac has installed; its + /// answers must come from the overrides above, never from that install. + static let isTestHost = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + /// Whether Nod may be created or launched at all on this install. public static var isRampedOn: Bool { - rampOverride ?? FileManager.default.fileExists(atPath: rampFlag.path) + rampOverride ?? (!isTestHost && FileManager.default.fileExists(atPath: rampFlag.path)) } /// The runtime's directory inside the app bundle — `graphcode-nod` with what it loads @@ -46,9 +50,16 @@ public enum NodRuntimeLocator { bundle.bundleURL.appendingPathComponent("\(bundledDirectory)/\(name)"), SupportDirectory.binDirectory.appendingPathComponent("\(installedDirectory)/\(name)"), ] + guard !isTestHost else { return nil } return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } } + /// Makes `CLISessionBackendKind.nod.isSpiked` mean "this process could launch it": the + /// ramp is on and a runtime is present. Called once by the app, the daemon and the CLI. + public static func installAvailability() { + NodRuntimeLocation.isAvailable = { binaryURL() != nil } + } + /// `$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 { diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 640f60ea..d6e76c30 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -1532,7 +1532,7 @@ public enum ZmxSessionLauncher { node.backend == .copilotCLI ? nil : SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName let resumeArgs = - nodArguments(forNode: node, projectPath: projectPath, settings: settings) + nodArguments(forNode: node, projectPath: projectPath, settings: settings, fresh: false) + node.backend.launchArguments( prompt: nil, tier: tier, settings: settings, workspacePaths: Self.workspacePaths(forNode: node, projectPath: projectPath), @@ -1566,18 +1566,20 @@ 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. `inheritFile` is - /// for a fresh launch only — a resumed conversation already has what it inherited. + /// written where `--goal-file` points. Empty for every other backend. The lineage brief + /// rides a fresh launch only — a resumed conversation already has what it inherited. + /// A composite child is unattended like a timed loop: nobody is watching it to answer. static func nodArguments( forNode node: LoopNode, projectPath: String?, settings: GraphcodeSettings, - inheritFile: String? = nil + fresh: Bool = true ) -> [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, inheritFile: inheritFile, - unattended: node.loopType == .timeBased) + goalFile: NodRuntimeLocator.writeGoal(of: node)?.path, + inheritFile: fresh ? node.lineage?.briefPath : nil, + unattended: node.loopType == .timeBased || node.lineage?.kind == .compositeChild) } /// A Nod session with nothing to say yet — a main loop with no starting note. Unlike a diff --git a/Makefile b/Makefile index 45f49303..cc527b2b 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ dev-generate dev-build-app dev-build-daemon dev-install-daemon \ dev-install-zmx dev-run-app dev-status \ third-party build-zmx install-zmx build-ghostty vendor-sdk \ - build-cli install-cli release-dmg notarize signing-doctor tap-bump + build-cli install-cli release-dmg notarize signing-doctor tap-bump build-nod SCHEME_APP := graphcode SCHEME_DAEMON := graphcoded @@ -78,6 +78,14 @@ doctor: # --------------------------------------------------------------------------- third-party: build-zmx build-ghostty +# GraphCode Nod's runtime: graphcode-nod plus both engines' agent runtimes, the folder the +# app carries at Contents/Helpers/nod (NodRuntime/README.md). Needs bun. +NOD_BUILD_DIR := $(BUILD_DIR)/nod +build-nod: + @command -v bun >/dev/null || { echo "bun missing — brew install oven-sh/bun/bun"; exit 1; } + rm -rf "$(NOD_BUILD_DIR)" + cd NodRuntime && sh scripts/package.sh "$(NOD_BUILD_DIR)" bun-darwin-arm64 + build-zmx: @test -d ThirdParty/zmx || { echo "ThirdParty/zmx missing — run: git submodule update --init --recursive"; exit 1; } @# -Doptimize is load-bearing: zig defaults to Debug, whose ghostty-vt runs @@ -399,7 +407,7 @@ NOTARY_PROFILE ?= graphcode # quarantines it. Never publish the output of a NOTARIZE=0 run. NOTARIZE ?= 1 -release-dmg: generate build-zmx +release-dmg: generate build-zmx build-nod @set -e; \ for scheme in $(SCHEME_APP) $(SCHEME_DAEMON) $(SCHEME_CLI); do \ echo "building $$scheme (Release, arm64)"; \ @@ -418,6 +426,8 @@ release-dmg: generate build-zmx cp "$$PRODUCTS/graphcoded" "$(DMG_STAGE)/graphcode.app/Contents/Resources/bin/graphcoded"; \ cp "$$PRODUCTS/graphcode" "$(DMG_STAGE)/graphcode.app/Contents/Resources/bin/graphcode"; \ cp "$(BUILD_DIR)/zmx/bin/zmx" "$(DMG_STAGE)/graphcode.app/Contents/Resources/bin/zmx"; \ + mkdir -p "$(DMG_STAGE)/graphcode.app/Contents/Helpers"; \ + ditto "$(NOD_BUILD_DIR)" "$(DMG_STAGE)/graphcode.app/Contents/Helpers/nod"; \ APP="$(DMG_STAGE)/graphcode.app"; \ echo "signing the bundle (embedding helpers invalidates the outer signature)"; \ if [ -z "$(SIGN_ID)" ]; then \ @@ -432,6 +442,12 @@ release-dmg: generate build-zmx --sign "$(SIGN_ID)" "$$APP/Contents/Resources/bin/$$helper" >/dev/null 2>&1 \ || { echo "failed to sign helper $$helper"; exit 1; }; \ done; \ + for helper in graphcode-nod copilot-runtime runtime.node; do \ + codesign --force --options runtime --timestamp \ + --entitlements NodRuntime/packaging/entitlements.plist \ + --sign "$(SIGN_ID)" "$$APP/Contents/Helpers/nod/$$helper" >/dev/null 2>&1 \ + || { echo "failed to sign Nod's $$helper"; exit 1; }; \ + done; \ if [ -d "$$APP/Contents/Frameworks" ]; then \ find "$$APP/Contents/Frameworks" -type f \( -name '*.dylib' -o -perm -u+x \) -print0 \ | xargs -0 -I{} codesign --force --options runtime --timestamp \ diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 638e4d95..a4ad7116 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -31,6 +31,8 @@ func fail(_ message: String, code: Int32 = ExitCode.usage) -> Never { exit(code) } +NodRuntimeLocator.installAvailability() + let command: GraphcodeCommand do { command = try GraphcodeCommand.parse(Array(CommandLine.arguments.dropFirst())) diff --git a/graphcode/Sources/Features/Nod/NodChatFeature.swift b/graphcode/Sources/Features/Nod/NodChatFeature.swift index 2b29760b..d0706f6b 100644 --- a/graphcode/Sources/Features/Nod/NodChatFeature.swift +++ b/graphcode/Sources/Features/Nod/NodChatFeature.swift @@ -49,7 +49,7 @@ struct NodChatFeature { branch: String? = nil, goal: String? = nil ) { self.nodeID = nodeID - self.stateDirectory = stateDirectory ?? NodStateDirectory.url(forNode: nodeID) + self.stateDirectory = stateDirectory ?? NodRuntimeLocator.stateDirectory(forNodeID: nodeID) self.loopTitle = loopTitle self.loopType = loopType self.branch = branch diff --git a/graphcode/Sources/Features/Nod/NodClient.swift b/graphcode/Sources/Features/Nod/NodClient.swift index eb995572..2caa4199 100644 --- a/graphcode/Sources/Features/Nod/NodClient.swift +++ b/graphcode/Sources/Features/Nod/NodClient.swift @@ -13,14 +13,6 @@ struct NodClient: Sendable { var send: @Sendable (_ stateDirectory: URL, _ command: NodCommand) async throws -> Void } -enum NodStateDirectory { - /// `$NOD_STATE` — `/nod//`, which the runtime is launched with. - static func url(forNode id: UUID, supportDirectory: URL = SupportDirectory.url) -> URL { - supportDirectory.appendingPathComponent("nod", isDirectory: true) - .appendingPathComponent(id.uuidString, isDirectory: true) - } -} - /// Splits appended bytes into records, holding a torn final line back until the rest of /// it arrives. Pure, so the tail's edge cases are testable without a file. struct NodEventTail: Equatable { diff --git a/graphcode/Sources/GraphcodeApp.swift b/graphcode/Sources/GraphcodeApp.swift index de332a85..a7f8a8f8 100644 --- a/graphcode/Sources/GraphcodeApp.swift +++ b/graphcode/Sources/GraphcodeApp.swift @@ -25,6 +25,13 @@ struct GraphcodeApp: App { unsetenv("ZMX_SESSION") AgentEnvironment.scrubInheritedAgentIdentity() SupportDirectory.prepare() + // From the cached ramp, so a fresh install need not wait on the network fetch. Not as a + // test host: the suite shares ~/.graphcode with whatever Nod this Mac has installed, and + // its hosting assertions must not depend on that. + if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { + FeatureRamps.publishNodFlag(enabled: FeatureRamps.isEnabled(.nod)) + NodRuntimeLocator.installAvailability() + } let nodCards = NodLiveCardState() NodCardWiring.provider = nodCards NodCardWiring.answerer = nodCards diff --git a/graphcode/Tests/NodBackendTests.swift b/graphcode/Tests/NodBackendTests.swift index 2fbc7c9a..099d731b 100644 --- a/graphcode/Tests/NodBackendTests.swift +++ b/graphcode/Tests/NodBackendTests.swift @@ -31,7 +31,10 @@ struct NodBackendTests { /// Until NodRuntime ships a binary, a loop labelled Nod would open nothing — the exact /// failure `isSpiked` exists to refuse. @Test - func nodHostsNothingUntilTheRuntimeShips() { + func nodHostsNothingWhereItCannotLaunch() { + // The test host never installs `NodRuntimeLocator.installAvailability`, so this is the + // answer for a process with no ramp flag or runtime: only the override would count. + #expect(NodRuntimeLocation.developmentOverride == nil) #expect(!CLISessionBackendKind.nod.isSpiked) #expect(CLISessionBackendKind.nod.executableName == "graphcode-nod") for loopType in LoopType.allCases { diff --git a/graphcode/Tests/NodLaunchTests.swift b/graphcode/Tests/NodLaunchTests.swift index 6e0552af..7f4f5462 100644 --- a/graphcode/Tests/NodLaunchTests.swift +++ b/graphcode/Tests/NodLaunchTests.swift @@ -280,11 +280,12 @@ struct NodLaunchArgumentTests { @Test func aTimedLoopIsUnattendedAndTheEnvironmentNamesItsProject() { - let timed = LoopNode( + var timed = LoopNode( id: nodeID, title: "Nightly", loopType: .timeBased, triggerPrompt: "/loop 1h deps", backend: .nod) + timed.lineage = LoopLineage(kind: .fork, sourceNodeID: UUID(), briefPath: "/b.md") let argv = ZmxSessionLauncher.nodArguments( - forNode: timed, projectPath: nil, settings: GraphcodeSettings(), inheritFile: "/b.md") + forNode: timed, projectPath: nil, settings: GraphcodeSettings()) #expect(Array(argv.suffix(3)) == ["--inherit", "/b.md", "--unattended"]) let goal = LoopNode(id: nodeID, title: "Cap", loopType: .turnBased, backend: .nod) #expect( @@ -298,6 +299,24 @@ struct NodLaunchArgumentTests { "NOD_STATE": NodRuntimeLocator.stateDirectory(forNodeID: nodeID).path, "NOD_NODE_ID": nodeID.uuidString, "NOD_PROJECT_PATH": "/p", ]) + } + + /// A brief is what a fresh conversation starts from; a resumed one already has it, and + /// sending it again would replay the handoff as a new turn. + @Test + func theLineageBriefRidesAFreshLaunchOnly() { + var child = LoopNode(id: nodeID, title: "Server caps", loopType: .goalBased, backend: .nod) + child.lineage = LoopLineage( + kind: .compositeChild, sourceNodeID: UUID(), briefPath: "/briefs/child.json") + + let fresh = ZmxSessionLauncher.nodArguments( + forNode: child, projectPath: nil, settings: GraphcodeSettings()) + let resumed = ZmxSessionLauncher.nodArguments( + forNode: child, projectPath: nil, settings: GraphcodeSettings(), fresh: false) + + #expect(Array(fresh.suffix(3)) == ["--inherit", "/briefs/child.json", "--unattended"]) + #expect(!resumed.contains("--inherit")) + #expect(resumed.last == "--unattended") #expect(NodRuntimeLocator.environment(forNodeID: nodeID)["NOD_PROJECT_PATH"] == nil) } diff --git a/graphcode/Tests/NodTranscriptTests.swift b/graphcode/Tests/NodTranscriptTests.swift index 90c1a066..c8a7e166 100644 --- a/graphcode/Tests/NodTranscriptTests.swift +++ b/graphcode/Tests/NodTranscriptTests.swift @@ -275,13 +275,16 @@ extension [NodTranscript.Item] { @Suite struct NodEventTailTests { - /// The runtime's launcher names the directory with `uuidString` as is; a lowercased copy + /// The pane reads the directory the launcher names: `uuidString` as is. A lowercased copy /// would split the pane from the runtime on a case-sensitive volume. @Test func theStateDirectoryIsTheUppercaseNodeID() throws { let id = try #require(UUID(uuidString: "9b3408f9-9b16-447f-a439-fc2aa8c02d06")) - let url = NodStateDirectory.url(forNode: id, supportDirectory: URL(fileURLWithPath: "/s")) - #expect(url.path == "/s/nod/9B3408F9-9B16-447F-A439-FC2AA8C02D06") + let url = NodRuntimeLocator.stateDirectory(forNodeID: id) + #expect(url.lastPathComponent == "9B3408F9-9B16-447F-A439-FC2AA8C02D06") + #expect( + url.deletingLastPathComponent().path + == SupportDirectory.url.appendingPathComponent("nod").path) } @Test diff --git a/graphcoded/Sources/main.swift b/graphcoded/Sources/main.swift index 529d4964..2f56f5ae 100644 --- a/graphcoded/Sources/main.swift +++ b/graphcoded/Sources/main.swift @@ -410,6 +410,7 @@ import GraphcodeKit // directory. Has to happen before anything reads or writes — including the socket bind // immediately below. SupportDirectory.prepare() + NodRuntimeLocator.installAvailability() let supportDirectory = SupportDirectory.url let socketURL = DaemonSocketPath.url