Skip to content

Commit bb60e72

Browse files
scgopiclaude
andcommitted
Brief app-started sessions about the graph too
The briefing that teaches a session to fan work out with `graphcode node create` was only delivered by the daemon's launcher — and a turn-based loop never starts there, so opened from the app it had never heard of the CLI and improvised: a Copilot loop asked to create more loops reached for its own sub-agents instead. GhosttyTerminalView now delivers the briefing the same per-backend way ZmxSessionLauncher does (Claude via --append-system-prompt-file, Copilot/Codex via --add-dir plus a pointer on the opening prompt), with the pointer riding inside the prompt env var where prose needs no shell quoting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 473c93d commit bb60e72

3 files changed

Lines changed: 142 additions & 6 deletions

File tree

graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ struct LoopWorkspaceView: View {
189189
// A node without its own worktree yet still belongs to a project — its shells
190190
// should open there, not wherever the app process happened to launch from.
191191
workingDirectory: store.node.worktreeBinding?.worktreePath ?? store.projectPath,
192+
// The graph's own project, for the session briefing — deliberately not the
193+
// worktree, whose path names a graph that doesn't exist.
194+
projectPath: ref.launchesClaudeCode ? store.projectPath : nil,
192195
// Only *one* surface in the whole workspace is the live one: the showing tab's
193196
// focused pane. Every other surface stays mounted and must not hold the keyboard —
194197
// including the other half of this tab's own split, which is what stops both panes

graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Foundation
2+
import GraphcodeKit
3+
import Testing
4+
5+
@testable import graphcode
6+
7+
/// An app-started session gets the same graph briefing a daemon-started one does.
8+
///
9+
/// The gap this closes: `ZmxSessionLauncher` delivered the briefing for every backend,
10+
/// but `GhosttyTerminalView` — the *only* launcher a turn-based loop ever has, since it
11+
/// doesn't start unattended — delivered nothing. A turn-based Copilot loop asked to fan
12+
/// work out had never heard of `graphcode node create` and improvised with its own
13+
/// sub-agents instead.
14+
@Suite
15+
struct AttachedSessionBriefingTests {
16+
private let briefing = URL(fileURLWithPath: "/tmp/briefings/proj/AGENTS.md")
17+
18+
private func surface(
19+
_ backend: CLISessionBackendKind, launchesClaudeCode: Bool = true,
20+
initialPrompt: String? = "go"
21+
) -> GhosttyTerminalView {
22+
GhosttyTerminalView(
23+
surfaceID: UUID(), sessionName: "s", launchesClaudeCode: launchesClaudeCode,
24+
backend: backend, initialPrompt: initialPrompt, workingDirectory: nil,
25+
projectPath: "/tmp/proj", onProcessExited: { _ in })
26+
}
27+
28+
private func shellCommand(_ backend: CLISessionBackendKind) -> String {
29+
surface(backend).agentCommand(settings: GraphcodeSettings(), briefingFile: briefing)?
30+
.last ?? ""
31+
}
32+
33+
@Test
34+
func claudeTakesTheBriefingAsASystemPromptFile() {
35+
#expect(shellCommand(.claudeCode).contains("--append-system-prompt-file \(briefing.path)"))
36+
// And its prompt is not the delivery mechanism, so it stays untouched.
37+
let environment = surface(.claudeCode).sessionEnvironment(briefingFile: briefing)
38+
#expect(environment["GRAPHCODE_TRIGGER_PROMPT"] == "go")
39+
}
40+
41+
@Test
42+
func copilotIsGrantedTheDirectoryAndPointedAtTheFile() {
43+
let command = shellCommand(.copilotCLI)
44+
// `--add-dir` is the half that is easy to miss: Copilot verifies paths, so without
45+
// it the pointer reads as the agent ignoring an instruction (issue #2's shape).
46+
#expect(command.contains("--add-dir /tmp/briefings/proj"))
47+
#expect(command.contains("--interactive"))
48+
49+
// The pointer rides inside the env var, where prose needs no shell quoting.
50+
let environment = surface(.copilotCLI).sessionEnvironment(briefingFile: briefing)
51+
let prompt = environment["GRAPHCODE_TRIGGER_PROMPT"] ?? ""
52+
#expect(prompt.contains(briefing.path))
53+
#expect(prompt.hasSuffix(" go"))
54+
}
55+
56+
@Test
57+
func codexRidesTheSameWayCopilotDoes() {
58+
#expect(shellCommand(.codex).contains("--add-dir /tmp/briefings/proj"))
59+
let prompt =
60+
surface(.codex).sessionEnvironment(briefingFile: briefing)[
61+
"GRAPHCODE_TRIGGER_PROMPT"] ?? ""
62+
#expect(prompt.contains(briefing.path))
63+
}
64+
65+
@Test
66+
func noBriefingMeansTheCommandAndPromptOfBefore() {
67+
let command =
68+
surface(.copilotCLI).agentCommand(
69+
settings: GraphcodeSettings(), briefingFile: nil)?.last ?? ""
70+
#expect(!command.contains("--add-dir"))
71+
let environment = surface(.copilotCLI).sessionEnvironment(briefingFile: nil)
72+
#expect(environment["GRAPHCODE_TRIGGER_PROMPT"] == "go")
73+
}
74+
75+
@Test
76+
func surfacesThatShouldNotBeBriefedGetNoFile() {
77+
// A plain shell is not a loop; a surface with no opening prompt has nothing to hang
78+
// the pointer on; and the human can switch briefing off entirely.
79+
let disabled = GraphcodeSettings(briefsSessionsAboutTheGraph: false)
80+
#expect(surface(.claudeCode, launchesClaudeCode: false).briefingFile() == nil)
81+
#expect(surface(.copilotCLI, initialPrompt: nil).briefingFile() == nil)
82+
#expect(surface(.copilotCLI).briefingFile(settings: disabled) == nil)
83+
}
84+
}

0 commit comments

Comments
 (0)