From 36ea9555e465513f32c811dcc82f494504f1862e Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 2 Oct 2026 14:21:18 -0700 Subject: [PATCH] Launch pane-started Nod loops with their lineage, like the daemon A pane can start a Nod session before graphcoded does. It now passes the lineage brief with --inherit on a fresh start only (never on resume) and launches a composite child --unattended, matching ZmxSessionLauncher.nodArguments. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../LoopWorkspace/LoopWorkspaceView.swift | 1 + .../Ghostty/GhosttyTerminalView+Nod.swift | 8 ++++-- .../Ghostty/GhosttyTerminalView+Remote.swift | 3 +- .../Ghostty/GhosttyTerminalView.swift | 7 +++-- graphcode/Tests/NodLaunchTests.swift | 28 +++++++++++++++++++ 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index d4c03370..01d0af8e 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -370,6 +370,7 @@ struct LoopWorkspaceView: View { backend: store.node.backend, pinnedModelTier: store.node.modelTier, loopType: store.node.loopType, + lineage: store.node.lineage, // Only the agent surface of an unattended node starts from a prompt (a time-based // loop's `/loop`, a goal-based loop's goal); a turn-based loop's session opens // bare, and extra tabs/splits are plain shells either way. A succeeded loop's goal is diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift index 532a303c..1fa952c6 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Nod.swift @@ -5,8 +5,9 @@ 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]? { + /// rather than a command that cannot run. The lineage brief rides a fresh launch only, + /// as it does from `ZmxSessionLauncher.nodArguments`. + func nodLaunchPrefix(settings: GraphcodeSettings, fresh: Bool = true) -> [String]? { guard remoteLocation == nil, let executable = NodRuntimeLocator.binaryURL()?.path, let nodeID = SurfaceRef.nodeID(fromZmxSessionName: sessionName) else { return nil } @@ -17,7 +18,8 @@ extension GhosttyTerminalView { workingDirectory: effectiveWorkingDirectory, goalFile: loopType == .goalBased && FileManager.default.fileExists(atPath: goalFile) ? goalFile : nil, - unattended: loopType == .timeBased) + inheritFile: fresh ? lineage?.briefPath : nil, + unattended: loopType == .timeBased || lineage?.kind == .compositeChild) let environment = NodRuntimeLocator.environment(forNodeID: nodeID, projectPath: projectPath) .sorted { $0.key < $1.key } .map { "\($0.key)=\(PresenceHooks.singleQuoted($0.value))" } diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift index 7c21e604..2b42c981 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView+Remote.swift @@ -260,7 +260,8 @@ extension GhosttyTerminalView { settings: GraphcodeSettings, briefingPath: String? = nil, hooksFile: URL? = nil, remoteSettingsPath: String?, isRemote: Bool = false ) -> [String]? { - guard backend.supportsResume, var parts = launchPrefix(settings: settings) else { + guard backend.supportsResume, var parts = launchPrefix(settings: settings, fresh: false) + else { return nil } let presence = backend.presenceArguments( diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index cdf7b8b8..d0a9db2e 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -33,6 +33,9 @@ struct GhosttyTerminalView: NSViewRepresentable { /// the settings file on every SwiftUI body pass. var pinnedModelTier: ModelTier? var loopType: LoopType = .turnBased + /// Where a Nod loop came from — its brief and whether it runs unattended — so a pane that + /// starts the session launches it exactly as `graphcoded` would. + var lineage: LoopLineage? /// The prompt this surface's Claude Code session should start with — a time-based /// node's `/loop …` directive (see `LoopNode.triggerPrompt`). `nil` for a turn-based /// loop's session, which starts bare, and for every plain-shell surface. @@ -222,8 +225,8 @@ struct GhosttyTerminalView: NSViewRepresentable { /// permissions — shared by the fresh launch above and the reboot resume /// (`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) } + func launchPrefix(settings: GraphcodeSettings, fresh: Bool = true) -> [String]? { + if backend == .nod { return nodLaunchPrefix(settings: settings, fresh: fresh) } guard let executable = backend.executableName else { return nil } let tier = ModelTier.resolved( pinned: pinnedModelTier, for: loopType, autoSelecting: settings.autoSelectsModel) diff --git a/graphcode/Tests/NodLaunchTests.swift b/graphcode/Tests/NodLaunchTests.swift index 7f4f5462..208e323c 100644 --- a/graphcode/Tests/NodLaunchTests.swift +++ b/graphcode/Tests/NodLaunchTests.swift @@ -205,6 +205,34 @@ struct NodSessionLogTests { struct NodLaunchArgumentTests { private let nodeID = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + /// A pane can start a Nod session before the daemon does; it must launch it the same way — + /// the lineage brief on a fresh start only, and a composite child unattended. + @Test + func aPaneStartedNodLoopCarriesItsLineageLikeTheDaemon() throws { + NodRuntimeLocator.binaryOverride = URL(fileURLWithPath: "/r/graphcode-nod") + NodRuntimeLocator.rampOverride = true + defer { + NodRuntimeLocator.binaryOverride = nil + NodRuntimeLocator.rampOverride = nil + } + let pane = GhosttyTerminalView( + surfaceID: UUID(), + sessionName: SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName, + launchesClaudeCode: true, backend: .nod, loopType: .goalBased, + lineage: LoopLineage( + kind: .compositeChild, sourceNodeID: UUID(), briefPath: "/briefs/child.json"), + workingDirectory: nil, onProcessExited: { _ in }) + + let fresh = try #require(pane.launchPrefix(settings: GraphcodeSettings())) + let resumed = try #require(pane.launchPrefix(settings: GraphcodeSettings(), fresh: false)) + + #expect(fresh.contains("'--inherit'")) + #expect(fresh.contains("'/briefs/child.json'")) + #expect(fresh.contains("'--unattended'")) + #expect(!resumed.contains("'--inherit'")) + #expect(resumed.contains("'--unattended'")) + } + @Test func theRuntimeTakesItsPromptAndBriefingByFlag() { let nod = CLISessionBackendKind.nod