@@ -43,6 +43,12 @@ struct GhosttyTerminalView: NSViewRepresentable {
4343 /// opened before the daemon got to it, or with `zmx` not installed.
4444 var initialPrompt : String ?
4545 let workingDirectory : String ?
46+ /// The project whose graph this loop belongs to — what the session's briefing tells it
47+ /// to create further loops *in*. Distinct from `workingDirectory`, which is the
48+ /// worktree when the node has one; a briefing built from the worktree path would have
49+ /// the agent extending a graph that doesn't exist. `nil` for plain-shell surfaces,
50+ /// which get no briefing.
51+ var projectPath : String ?
4652 /// Whether this is *the* surface the user is typing into — its tab is the one on
4753 /// screen **and** it is that tab's focused pane. Every tab stays mounted and a split
4854 /// has two live terminals, so without this the keyboard can end up parked on a surface
@@ -69,10 +75,13 @@ struct GhosttyTerminalView: NSViewRepresentable {
6975 func makeNSView( context: Context ) -> TerminalSurfaceHostView {
7076 let host = TerminalSurfaceHostView ( )
7177 let view = TerminalSurfaceStore . shared. surface ( for: surfaceID) {
72- GhosttyTerminalNSView (
73- command: command,
78+ // Written once per surface build, not held: like `ZmxSessionLauncher`, rewriting
79+ // on launch means the briefing never goes stale against an upgraded graphcode.
80+ let briefingFile = self . briefingFile ( )
81+ return GhosttyTerminalNSView (
82+ command: command ( briefingFile: briefingFile) ,
7483 workingDirectory: workingDirectory,
75- environment: initialPrompt . map { [ Self . promptVariable : $0 ] } ?? [ : ] )
84+ environment: sessionEnvironment ( briefingFile : briefingFile ) )
7685 }
7786 apply ( to: view)
7887 host. adopt ( view)
@@ -127,7 +136,9 @@ struct GhosttyTerminalView: NSViewRepresentable {
127136 /// `settings` is a parameter rather than a read inside the body so a test can state what
128137 /// a human chose and check what the shell is told — the omission this fixes was
129138 /// invisible precisely because there was nothing to assert against.
130- func agentCommand( settings: GraphcodeSettings = GraphcodeSettingsStore . load ( ) ) -> [ String ] ? {
139+ func agentCommand(
140+ settings: GraphcodeSettings = GraphcodeSettingsStore . load ( ) , briefingFile: URL ? = nil
141+ ) -> [ String ] ? {
131142 guard let executable = backend. executableName else { return nil }
132143 let tier = ModelTier . resolved (
133144 pinned: pinnedModelTier, for: loopType, autoSelecting: settings. autoSelectsModel)
@@ -138,16 +149,54 @@ struct GhosttyTerminalView: NSViewRepresentable {
138149 var parts = [ " exec " , executable]
139150 if !model. isEmpty { parts. append ( model) }
140151 if !permissions. isEmpty { parts. append ( permissions) }
152+ // The briefing, delivered the same per-backend way `BackendCommand.launchArguments`
153+ // delivers it for a daemon-started session — before this, only daemon-started loops
154+ // knew they could fan out, and a turn-based loop (which only ever starts here) asked
155+ // to create more loops improvised with its backend's own sub-agents instead (the
156+ // Copilot shape of issue #2). Claude takes the file itself as a flag; Copilot and
157+ // Codex are granted the directory and pointed at the file inside their opening
158+ // prompt — see `sessionEnvironment`, where the pointer rides in the env var and so
159+ // needs no shell quoting.
160+ if let briefingFile {
161+ switch backend {
162+ case . claudeCode:
163+ parts. append ( " --append-system-prompt-file \( briefingFile. path) " )
164+ case . copilotCLI, . codex:
165+ parts. append ( " --add-dir \( briefingFile. deletingLastPathComponent ( ) . path) " )
166+ }
167+ }
141168 if !prompt. isEmpty {
142169 if backend == . copilotCLI { parts. append ( " --interactive " ) }
143170 parts. append ( prompt)
144171 }
145172 return [ " /bin/zsh " , " -i " , " -l " , " -c " , parts. joined ( separator: " " ) ]
146173 }
147174
148- private var command : [ String ] {
175+ /// Where the graph briefing for this session landed, or `nil` when it shouldn't get
176+ /// one: a plain shell, a surface with no opening prompt to carry the pointer, a
177+ /// briefing the human switched off, or nowhere to write.
178+ func briefingFile( settings: GraphcodeSettings = GraphcodeSettingsStore . load ( ) ) -> URL ? {
179+ guard launchesClaudeCode, initialPrompt != nil , settings. briefsSessionsAboutTheGraph
180+ else { return nil }
181+ return SessionBriefing . write ( projectPath: projectPath)
182+ }
183+
184+ /// What rides into the session through the environment: the opening prompt, prefixed
185+ /// for Copilot and Codex with the pointer at the briefing file. In the env var rather
186+ /// than on the command line because the pointer is prose — inside `"$VAR"` it needs no
187+ /// quoting and cannot break the shell string the command is joined into. Claude's
188+ /// prompt stays untouched: its briefing arrives via `--append-system-prompt-file`.
189+ func sessionEnvironment( briefingFile: URL ? ) -> [ String : String ] {
190+ guard var prompt = initialPrompt else { return [ : ] }
191+ if backend != . claudeCode, let briefingFile {
192+ prompt = " \( SessionBriefing . pointer ( toBriefingAt: briefingFile. path) ) \( prompt) "
193+ }
194+ return [ Self . promptVariable: prompt]
195+ }
196+
197+ private func command( briefingFile: URL ? ) -> [ String ] {
149198 let shell = [ " /bin/zsh " , " -l " ]
150- guard let agentCommand = agentCommand ( ) else {
199+ guard let agentCommand = agentCommand ( briefingFile : briefingFile ) else {
151200 // A backend graphcode can't launch gets a plain shell rather than the wrong agent.
152201 // `canHost` already refuses to create such a node, so this is unreachable in
153202 // practice and deliberately inert if it ever isn't.
0 commit comments