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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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))" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions graphcode/Tests/NodLaunchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading