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
30 changes: 12 additions & 18 deletions GraphcodeKit/Sources/Domain/BackendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ extension CLISessionBackendKind {
/// cosmetic. `claude` takes `--append-system-prompt-file <path>`, which adds the file's
/// contents to its system prompt and leaves the human's prompt as the only thing in the
/// conversation. `copilot` has no equivalent — its custom instructions come from
/// `AGENTS.md` files it discovers on disk (hence `--no-custom-instructions` to switch
/// that off), which graphcode has no business writing into someone's repository. So
/// Copilot has no equivalent flag and no working equivalent mechanism, so it is told to
/// read the file by a preamble on the prompt and granted access to it with `--add-dir`.
/// instruction files it discovers on disk, which graphcode has no business writing into
/// someone's repository — so it is pointed at a copy in graphcode's own directory through
/// its environment instead (`briefingEnvironment`), which lands in its system prompt
/// just as Claude's flag does.
///
/// Neither carries the prose on the command line. See `SessionBriefing` for why that is
/// load-bearing rather than tidy: the launch command is typed into a terminal, and a
Expand Down Expand Up @@ -125,20 +125,7 @@ extension CLISessionBackendKind {
// for a prompt that actually is one, so an ordinary Copilot session keeps the
// CLI's own defaults.
let experimental = SessionPrompt.mentionsRecurrence(prompt) ? ["--experimental"] : []
guard let briefingPath else {
return model + access + experimental + ["--interactive", prompt]
}
// And the preamble telling it the briefing is there to read. See
// `SessionBriefing.pointer` for why the tidier env-var route was abandoned.
// Ordered by `SessionPrompt`, not concatenated: a time-based node's prompt is the
// `/loop …` directive itself, and Copilot is the one backend that both hosts that
// loop type and receives its briefing as a preamble (issue #179).
return model + access + experimental
+ [
"--interactive",
SessionPrompt.composed(
preamble: SessionBriefing.pointer(toBriefingAt: briefingPath), prompt: prompt),
]
return model + access + experimental + ["--interactive", prompt]
case .codex:
// Same shape as Claude Code — an interactive TUI taking its prompt positionally —
// so the briefing rides the same way Copilot's does: `--add-dir` for access, a
Expand Down Expand Up @@ -266,6 +253,13 @@ extension CLISessionBackendKind {
/// is named by a config file, and `OPENCODE_CONFIG` is the one route that *merges over*
/// the user's own config instead of replacing it (`OPENCODE_CONFIG_DIR` would drop
/// their providers and plugins on the floor — read off the config loader, not the docs).
/// The environment that delivers the briefing at `briefingPath`, for the one backend
/// that takes it that way (`SessionBriefing.copilotInstructionsDirectoryVariable`).
public func briefingEnvironment(briefingPath: String?) -> [String: String] {
guard self == .copilotCLI, let briefingPath else { return [:] }
return SessionBriefing.copilotInstructionsEnvironment(briefingPath: briefingPath)
}

public func presenceEnvironment(hooksFile: URL?) -> [String: String] {
switch self {
case .openCode:
Expand Down
55 changes: 39 additions & 16 deletions GraphcodeKit/Sources/Domain/SessionBriefing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,40 @@ public enum SessionBriefing {
directory.appendingPathComponent(slug(for: projectPath), isDirectory: true)
}

/// The filename Copilot looks for when it searches a directory, and the file Claude Code
/// is handed by path. One file, two delivery mechanisms, no duplication.
/// The file Claude Code is handed by path and the other backends are pointed at.
public static let fileName = "AGENTS.md"

/// The variable Copilot CLI reads for extra instruction directories, and the copy of the
/// briefing it finds there.
///
/// **Copilot searches those directories for `*.instructions.md` files, not `AGENTS.md`.**
/// The first cut pointed the variable at a directory holding only `AGENTS.md`, nothing
/// loaded, and Copilot loops silently never fanned out (issue #2). The measurement that
/// followed was real — Copilot's own session logs from 2026-07-28 show CLI 1.0.75 and a
/// working-directory control that fired — but it tried only `AGENTS.md` and
/// `.github/copilot-instructions.md`, the two names Copilot looks for in a *repository*,
/// and concluded the variable was ignored. `copilot help environment` says only "custom
/// instructions files", which is how both readings survived. Re-measured on 2026-09-15
/// against 1.0.75, 1.0.82, 1.0.83 and 1.0.84-1/-3/-5: an `*.instructions.md` anywhere
/// under a listed directory lands in the system message on every one of them, with or
/// without `applyTo` front matter, while those two names never load from there.
///
/// Resuming rebuilds the system message from the resuming process's environment, so a
/// resumed Copilot session needs the variable as much as a fresh one does.
public static let copilotInstructionsDirectoryVariable = "COPILOT_CUSTOM_INSTRUCTIONS_DIRS"
public static let copilotInstructionsFile = "instructions/graphcode.instructions.md"

/// Points Copilot at the directory holding `briefingPath`, *after* any directories the
/// user's own shell already listed — the variable is comma-separated and theirs first.
/// Expanded by the session's login shell, the only place that knows the user's value
/// and, for a remote `~/` path, the host's home directory.
public static func copilotInstructionsEnvironment(briefingPath: String) -> [String: String] {
let variable = copilotInstructionsDirectoryVariable
var directory = (briefingPath as NSString).deletingLastPathComponent
if directory.hasPrefix("~/") { directory = "$HOME/" + directory.dropFirst(2) }
return [variable: "${\(variable):+$\(variable),}\(directory)"]
}

/// The briefing for a node in `projectPath`'s graph, or `nil` when there's no path to
/// tell it about — every command the briefing describes takes one, so a briefing
/// without it would describe commands the session can't run.
Expand Down Expand Up @@ -272,21 +302,10 @@ public enum SessionBriefing {
"""
}

/// How Copilot CLI is told about the briefing: a one-line preamble on the prompt, and
/// `--add-dir` so the session is allowed to read the file it names.
///
/// **`COPILOT_CUSTOM_INSTRUCTIONS_DIRS` does not work.** `copilot help environment`
/// documents it as "additional directories to search for custom instructions files", and
/// it was the obvious right answer — a real system-level instruction, nothing in the
/// prompt, nothing written into anyone's repository. Measured against 1.0.75 it is simply
/// ignored: an `AGENTS.md` in a directory named by that variable has no effect, in either
/// the `AGENTS.md` or `.github/copilot-instructions.md` layout, while the identical file
/// in the working directory is picked up every time. Shipping on the documentation cost
/// a release where Copilot loops silently never fanned out (issue #2).
/// How a backend with no system-prompt channel is told about the briefing — Codex,
/// OpenCode and pi: a one-line preamble on the prompt. Copilot has a channel
/// (`copilotInstructionsDirectoryVariable`) and no longer takes one.
///
/// `--add-dir` is the half that is easy to miss. Copilot verifies file paths, so a
/// session told to read `~/.graphcode/briefings/…` cannot reach it — the pointer alone
/// looks like the agent ignoring an instruction when it is actually being denied.
/// Deliberately ASCII-only, with plain words on both sides of the path. This string
/// travels through more layers than any other prose graphcode emits — argv, zmx's
/// typed command line, a canonical-mode tty, sometimes ssh — and an em dash sitting
Expand All @@ -313,6 +332,10 @@ public enum SessionBriefing {
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true)
try text.write(to: url, atomically: true, encoding: .utf8)
let instructions = directory.appendingPathComponent(copilotInstructionsFile)
try FileManager.default.createDirectory(
at: instructions.deletingLastPathComponent(), withIntermediateDirectories: true)
try text.write(to: instructions, atomically: true, encoding: .utf8)
return url
} catch {
// A session with no briefing is the pre-briefing behaviour, which works. Failing the
Expand Down
4 changes: 4 additions & 0 deletions GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ public enum RemoteGraphAccess {
briefingDirectory(forProjectPath: projectPath) + "/" + SessionBriefing.fileName
}

public static func copilotInstructionsPath(forProjectPath projectPath: String) -> String {
briefingDirectory(forProjectPath: projectPath) + "/" + SessionBriefing.copilotInstructionsFile
}

/// The remote twin of `NodeMemory.directory(forProjectPath:nodeID:)`. Only the wake
/// digest is delivered there — the log itself stays on the Mac, where the daemon
/// appends to it; the digest is the budgeted, rebuildable view of it.
Expand Down
64 changes: 49 additions & 15 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1223,8 +1223,11 @@ public enum ZmxSessionLauncher {
+ Self.loginShellInvocation(
of: executable, arguments: resumeArgs,
environment: Self.environment(
forBackend: node.backend, briefingPath: nil, hooksFile: hooksFile,
remoteHooksPath: remoteEnvironmentPath),
forBackend: node.backend,
briefingPath: Self.resumeBriefingPath(
forBackend: node.backend, projectPath: projectPath, isRemote: remote != nil,
settings: settings),
hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath),
scriptSuffix: remoteHooksSuffix)
}

Expand Down Expand Up @@ -1260,18 +1263,34 @@ public enum ZmxSessionLauncher {
return paths
}

/// Environment a session needs beyond what its shell provides. Copilot's briefing rides
/// on its argv (see `CLISessionBackendKind.launchArguments`) after the documented
/// environment route turned out not to work; OpenCode's presence plugin is the one
/// thing that genuinely has to travel this way (`presenceEnvironment`).
/// Environment a session needs beyond what its shell provides: Copilot's briefing
/// (`briefingEnvironment`) and OpenCode's presence plugin (`presenceEnvironment`).
static func environment(
forBackend backend: CLISessionBackendKind, briefingPath: String?, hooksFile: URL? = nil,
remoteHooksPath: String? = nil
) -> [String: String] {
let briefing = backend.briefingEnvironment(briefingPath: briefingPath)
if backend == .openCode, let remoteHooksPath {
return ["OPENCODE_CONFIG": remoteHooksPath]
return briefing.merging(["OPENCODE_CONFIG": remoteHooksPath]) { $1 }
}
return backend.presenceEnvironment(hooksFile: hooksFile)
return briefing.merging(backend.presenceEnvironment(hooksFile: hooksFile)) { $1 }
}

/// The briefing a resumed session is launched with. Only Copilot needs one: it rebuilds
/// its system prompt from the resuming process's environment, where every other
/// backend's briefing either rides in the conversation it restores or in a flag the
/// resume never carried.
static func resumeBriefingPath(
forBackend backend: CLISessionBackendKind, projectPath: String?, isRemote: Bool,
settings: GraphcodeSettings
) -> String? {
guard backend == .copilotCLI, settings.briefsSessionsAboutTheGraph, let projectPath else {
return nil
}
return isRemote
? SessionBriefing.text(projectPath: projectPath)
.map { _ in RemoteGraphAccess.briefingPath(forProjectPath: projectPath) }
: SessionBriefing.write(projectPath: projectPath)?.path
}

/// What a remote session's launch appends so its reporter loads — a `$HOME` path only
Expand Down Expand Up @@ -1528,13 +1547,33 @@ public enum ZmxSessionLauncher {
/// because the app's *attach* delivers too, before any node exists to have memory.
/// Public for exactly that caller (`GhosttyTerminalView.remoteCommand`).
public static func remoteDeliveryScript(
forNode node: LoopNode?, at location: RemoteProjectLocation, settings: GraphcodeSettings
forNode node: LoopNode?, backend: CLISessionBackendKind? = nil,
at location: RemoteProjectLocation, settings: GraphcodeSettings
) -> String? {
// The shim's receipt, written only once every file has landed — see
// `installerScript`. It is what lets a later ensure skip a delivery it doesn't need
// without ever claiming a shim the host never received.
RemoteGraphAccess.installerScript(
files: remoteDeliveryFiles(forNode: node, backend: backend, at: location, settings: settings),
receipt: (path: RemoteGraphAccess.shimStampPath, content: RemoteGraphAccess.cliShimStamp))
}

/// `remoteDeliveryScript`'s manifest: home-relative path → content. Copilot's copy of the
/// briefing (`SessionBriefing.copilotInstructionsFile`) goes only to a Copilot session,
/// named by `node` or, for the app's attach, by `backend`.
static func remoteDeliveryFiles(
forNode node: LoopNode?, backend: CLISessionBackendKind? = nil,
at location: RemoteProjectLocation, settings: GraphcodeSettings
) -> [String: String] {
var files = [RemoteGraphAccess.cliInstallPath: RemoteGraphAccess.cliShimSource]
if settings.briefsSessionsAboutTheGraph,
let text = SessionBriefing.text(projectPath: location.projectPath)
{
files[RemoteGraphAccess.briefingPath(forProjectPath: location.projectPath)] = text
if (node?.backend ?? backend) == .copilotCLI {
files[RemoteGraphAccess.copilotInstructionsPath(forProjectPath: location.projectPath)] =
text
}
}
if let node {
let wakeURL = NodeMemory.directory(
Expand All @@ -1555,12 +1594,7 @@ public enum ZmxSessionLauncher {
promptText
}
}
// The shim's receipt, written only once every file above has landed — see
// `installerScript`. It is what lets a later ensure skip a delivery it doesn't need
// without ever claiming a shim the host never received.
return RemoteGraphAccess.installerScript(
files: files,
receipt: (path: RemoteGraphAccess.shimStampPath, content: RemoteGraphAccess.cliShimStamp))
return files
}

/// `quotedCommand`, except that arguments naming graphcode's own remote files —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ extension GhosttyTerminalView {
RemoteProjectLocation.prepareControlSocketDirectory()
let quoted = RemoteProjectLocation.shellQuoted
let delivery =
ZmxSessionLauncher.remoteDeliveryScript(forNode: nil, at: location, settings: settings)
ZmxSessionLauncher.remoteDeliveryScript(
forNode: nil, backend: backend, at: location, settings: settings
)
.map { $0 + "; " } ?? ""
let agentScripts =
launchesClaudeCode
Expand Down Expand Up @@ -209,7 +211,8 @@ extension GhosttyTerminalView {
var script = preparation + "{ "
let nodeID = SurfaceRef.nodeID(fromZmxSessionName: sessionName)
let resumeLaunch = resumeCommand(
settings: settings, remoteSettingsPath: remotePresenceSettingsPath, isRemote: true)
settings: settings, briefingPath: remoteBriefingPath(settings: settings),
remoteSettingsPath: remotePresenceSettingsPath, isRemote: true)
if let nodeID, let resumeLaunch {
let idFile = PresenceHooks.remoteSessionIDExpression(forNodeID: nodeID)
let attempt =
Expand Down Expand Up @@ -243,8 +246,8 @@ extension GhosttyTerminalView {
/// machine wrote. A resumed local session needs them for the same reason a fresh one
/// does: without them the loop reports IDLE for as long as it runs.
func resumeCommand(
settings: GraphcodeSettings, hooksFile: URL? = nil, remoteSettingsPath: String?,
isRemote: Bool = false
settings: GraphcodeSettings, briefingPath: String? = nil, hooksFile: URL? = nil,
remoteSettingsPath: String?, isRemote: Bool = false
) -> [String]? {
guard backend.supportsResume, var parts = launchPrefix(settings: settings) else {
return nil
Expand All @@ -258,6 +261,7 @@ extension GhosttyTerminalView {
.joined(separator: " ")
if !presence.isEmpty { parts.append(presence) }
addRemotePresenceSettings(remoteSettingsPath, to: &parts)
addBriefingEnvironment(briefingPath, to: &parts)
parts += backend.resumeArguments(
sessionID: "\"$\(ZmxSessionLauncher.remoteResumeIDVariable)\"")
return Self.interactiveLoginShell(parts)
Expand Down
Loading
Loading