viewable-one 0.0.0-stage → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/agents.mjs
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
// WHICH ENGINE RUNS A SESSION, and the one place that knows how to drive it.
|
|
2
|
+
//
|
|
3
|
+
// The runner was never Claude-specific in shape - the binary and its arguments
|
|
4
|
+
// came from PM_CLAUDE_BIN and PM_CLAUDE_ARGS, and nothing else in runner.mjs
|
|
5
|
+
// knew what it was running. But the DEFAULTS were, and they were baked into a
|
|
6
|
+
// single fallback array, so adding a second engine meant either an env
|
|
7
|
+
// variable nobody would set or a branch in the middle of runRequest.
|
|
8
|
+
//
|
|
9
|
+
// So each engine is described once, here, and a request says which one it
|
|
10
|
+
// wants. The runner asks this file for a command line and stays ignorant.
|
|
11
|
+
//
|
|
12
|
+
// TWO THINGS THAT ARE NOT THE SAME BETWEEN THEM, and both matter:
|
|
13
|
+
//
|
|
14
|
+
// PERMISSIONS. Claude Code takes an ALLOWLIST; opencode's only headless
|
|
15
|
+
// option is `--auto`, which its own help calls "auto-approve permissions that
|
|
16
|
+
// are not explicitly denied (dangerous!)": a deny list, no allow list, so the
|
|
17
|
+
// shape is inverted. An opencode session is meaningfully less fenced than a
|
|
18
|
+
// Claude Code one, in the same worktree, spending the same way. That is
|
|
19
|
+
// recorded on each entry as `fenced`, so a caller can say so out loud rather
|
|
20
|
+
// than discover it.
|
|
21
|
+
//
|
|
22
|
+
// WHAT `fenced: true` DOES AND DOES NOT MEAN. It used to say here, and in
|
|
23
|
+
// bridge/README.md, that a Claude session "cannot open a shell". That was
|
|
24
|
+
// false when it was written and it is false now: CLAUDE_TOOLS below carries
|
|
25
|
+
// `Bash(git:*)`, `Bash(npm:*)`, `Bash(npx:*)` and `Bash(node:*)`, and
|
|
26
|
+
// `Bash(node:*)` alone is `node -e '<anything>'` - an arbitrary program with
|
|
27
|
+
// the session's own file access. `npx` is arbitrary package execution. Write
|
|
28
|
+
// and Edit are unscoped, so the session may write any path it can reach,
|
|
29
|
+
// which includes its own worktree's .git hooks. A claim a reader trusts and
|
|
30
|
+
// the code does not keep is worse than no claim, so:
|
|
31
|
+
//
|
|
32
|
+
// what the fence DOES stop: an interactive prompt nobody can answer
|
|
33
|
+
// (CLAUDE_DISALLOWED_TOOLS), a tool the session was never offered, and -
|
|
34
|
+
// with the git worktree, not with this list - edits landing on the
|
|
35
|
+
// checkout the user is looking at;
|
|
36
|
+
// what it does NOT stop: arbitrary code execution through node/npx, writes
|
|
37
|
+
// to any path the process can reach, or network access from inside one of
|
|
38
|
+
// those.
|
|
39
|
+
//
|
|
40
|
+
// One consequence is worth naming because it is not obvious: `PM_SPAWN_DEPTH`
|
|
41
|
+
// stops a session running its OWN runner; it does not stop one asking the
|
|
42
|
+
// board, over the hosted endpoint it is connected to, for another session.
|
|
43
|
+
// Since brief 30 a request is a `runs` ROW filed through the app's own RPC
|
|
44
|
+
// under the person's account, never a file in `.glytch/inbox/spawn/` (that
|
|
45
|
+
// mailbox is not read by any runner any more), so the fence is the endpoint's
|
|
46
|
+
// own gate rather than who wrote a file. The bounds that still hold are the
|
|
47
|
+
// ones that never depended on the transport: the wall clock, no
|
|
48
|
+
// retry, and a person reading the frames that appear on the board.
|
|
49
|
+
//
|
|
50
|
+
// NARROWING THE LIST IS A SEPARATE DECISION and is not made here: it changes
|
|
51
|
+
// what a brief can do, so it is the user's to rule on.
|
|
52
|
+
//
|
|
53
|
+
// REACHING THE BOARD. Claude Code reads .mcp.json from the folder it runs in,
|
|
54
|
+
// which is how a spawned session gets get_board, report and ask_user. opencode
|
|
55
|
+
// does not read that file - it keeps its own config, and `opencode mcp list`
|
|
56
|
+
// in a fresh checkout says "No MCP servers configured". A session that cannot
|
|
57
|
+
// reach the board cannot report, cannot ask, and cannot come home to its own
|
|
58
|
+
// frame, which is the whole design. `needsBoardSetup` says which engines have
|
|
59
|
+
// to be wired up before they are worth offering.
|
|
60
|
+
//
|
|
61
|
+
// A THIRD ENGINE THAT IS NOT A THIRD BINARY (2026-09-02). `acp-claude` runs
|
|
62
|
+
// Claude Code through the Agent Client Protocol adapter, which speaks
|
|
63
|
+
// JSON-RPC over stdio rather than stream-json on stdout. Everything that
|
|
64
|
+
// differs lives in lib/acp.mjs; what lives HERE is one row carrying
|
|
65
|
+
// `protocol: "acp"`, and `runnerFor` picks the run function off that row -
|
|
66
|
+
// so runRequest gained no branch. Read the head of acp.mjs for what maps
|
|
67
|
+
// onto what and, more importantly, for the two things ACP does not give: a
|
|
68
|
+
// spend CEILING (there is no field to set one) and a spend FIGURE (only
|
|
69
|
+
// tokens). That is exactly why `claude` keeps its own path and stays the
|
|
70
|
+
// default. Brief 15's ACP amendment says to treat "beside" as permanent
|
|
71
|
+
// rather than as a migration step, and this table is where that is kept.
|
|
72
|
+
//
|
|
73
|
+
// ITS BOARD ACCESS IS ALSO NOT A FILE. Claude Code reads .mcp.json from the
|
|
74
|
+
// folder it runs in; the ACP adapter is handed the board server inline, in
|
|
75
|
+
// `session/new`'s `mcpServers` - verified in the spike, where the model
|
|
76
|
+
// called the stub server and its answer came back in a tool_call_update. So
|
|
77
|
+
// `needsBoardSetup` is false for a second, different reason, and
|
|
78
|
+
// `acpServersFor` below is where the server is described.
|
|
79
|
+
|
|
80
|
+
import { adapterEntry } from "./acp.mjs";
|
|
81
|
+
import { computerAppsOffRule, conversationRules } from "./conversationRules.mjs";
|
|
82
|
+
import { runEngineFor } from "./run-engine.mjs";
|
|
83
|
+
// ONE LIST, not two: spawn.mjs's checkRequest is the validator a request
|
|
84
|
+
// meets first, and this file's draft branch is what actually builds the
|
|
85
|
+
// restricted command line for whichever engine gets past it. Importing
|
|
86
|
+
// rather than repeating the name list means the two cannot drift apart the
|
|
87
|
+
// way a comment and a `switch` always eventually do.
|
|
88
|
+
import { PLAN_MODE_AGENTS } from "./spawn.mjs";
|
|
89
|
+
// THE MCP SERVER KEY, imported rather than repeated (brief 31, Move 3b): the
|
|
90
|
+
// per-session injection below used to name it "board" inline in three
|
|
91
|
+
// places (the ACP server list, Claude's --mcp-config JSON, opencode's
|
|
92
|
+
// OPENCODE_CONFIG_CONTENT); init-connect.mjs's own agent verbs
|
|
93
|
+
// (`claude mcp login glytch`, etc.) already needed the same string as
|
|
94
|
+
// SERVER_KEY, so this imports that one spelling rather than adding a fourth
|
|
95
|
+
// copy of the literal.
|
|
96
|
+
import { SERVER_KEY } from "./init-connect.mjs";
|
|
97
|
+
|
|
98
|
+
/** What a spawned session may do under Claude Code.
|
|
99
|
+
*
|
|
100
|
+
* A list rather than "everything" or "nothing": a session with no permissions
|
|
101
|
+
* does NOTHING and stalls politely into a log nobody reads. This is what a
|
|
102
|
+
* brief on that board actually asks for.
|
|
103
|
+
*
|
|
104
|
+
* BE HONEST ABOUT WHAT SCOPING BUYS. Every Bash entry names its command, and
|
|
105
|
+
* that is a real narrowing - `Bash(rm:*)` is not on this list, and neither is
|
|
106
|
+
* bare `Bash`. It is NOT a fence against arbitrary execution: `Bash(node:*)`
|
|
107
|
+
* covers `node -e '<anything>'` and `Bash(npx:*)` covers running any package,
|
|
108
|
+
* so a session that wants a shell has two. Write and Edit are unscoped, so
|
|
109
|
+
* the session may write any path the process can reach. The comment above
|
|
110
|
+
* this file's AGENTS table has the full statement of what the fence does and
|
|
111
|
+
* does not stop, and why narrowing it is a ruling rather than a patch. */
|
|
112
|
+
export const CLAUDE_TOOLS = [
|
|
113
|
+
"Read",
|
|
114
|
+
// KEPT, BUT IT WAS NEVER THE FIX (2026-08-30). Allowlisting ExitPlanMode
|
|
115
|
+
// (2026-08-29, after p-mteflfvk) was supposed to let a planning session
|
|
116
|
+
// proceed. It did not: spawn-mtf3xbm7 entered plan mode with this exact
|
|
117
|
+
// allowlist and every ExitPlanMode call came back `is_error: "Exit plan
|
|
118
|
+
// mode?"` - plan APPROVAL is an interactive prompt, not a tool permission,
|
|
119
|
+
// and --allowedTools does not cover it. The user typed "I approve" over
|
|
120
|
+
// stdin; the next three ExitPlanMode calls errored the same way. The real
|
|
121
|
+
// fix is CLAUDE_DISALLOWED_TOOLS below: never let a headless session enter
|
|
122
|
+
// plan mode at all. This entry stays so a legacy session resumed mid-plan
|
|
123
|
+
// can still TRY, and because removing it changes nothing either way.
|
|
124
|
+
//
|
|
125
|
+
// THE OLD SENTENCE "there is no --permission-prompt-tool" IS STALE (checked
|
|
126
|
+
// 2026-10-14 against Claude Code 2.1.299). The flag exists, and the
|
|
127
|
+
// streaming path now passes `--permission-prompt-tool stdio` (see
|
|
128
|
+
// streamingArgs): a tool call outside this list arrives on stdout as a
|
|
129
|
+
// `control_request` and child.mjs answers it, after the person has answered
|
|
130
|
+
// a question in the idea (bridge/lib/permissionAsk.mjs). The one-shot `-p`
|
|
131
|
+
// path has no stdin to answer on and is unchanged: outside the list, a tool
|
|
132
|
+
// call is refused.
|
|
133
|
+
"ExitPlanMode",
|
|
134
|
+
"Write",
|
|
135
|
+
"Edit",
|
|
136
|
+
"Glob",
|
|
137
|
+
"Grep",
|
|
138
|
+
// TodoWrite is GONE on Claude Code 2.1.270 - verified live 2026-09-21,
|
|
139
|
+
// `echo "list your tools" | claude -p ... --allowedTools "... TodoWrite
|
|
140
|
+
// Task"` lists TaskCreate/TaskUpdate/TaskList/TaskGet and no TodoWrite.
|
|
141
|
+
// Kept for an OLDER Claude Code that still ships it; the four Task* tools
|
|
142
|
+
// are the plan surface on every CLI actually in use now.
|
|
143
|
+
"TodoWrite",
|
|
144
|
+
"TaskCreate",
|
|
145
|
+
"TaskUpdate",
|
|
146
|
+
"TaskList",
|
|
147
|
+
"TaskGet",
|
|
148
|
+
"Task",
|
|
149
|
+
// RENAMED 2026-09-12 (brief 31, Move 3b): the MCP namespace the injected
|
|
150
|
+
// per-session server answers to is `SERVER_KEY` ("glytch") now, not
|
|
151
|
+
// "board" - acpServersFor and commandFor's --mcp-config both name the
|
|
152
|
+
// server that below, so an allowlist still saying "mcp__board" would
|
|
153
|
+
// match nothing a spawned session actually calls and silently cut off
|
|
154
|
+
// get_board, propose, ask_user and report.
|
|
155
|
+
`mcp__${SERVER_KEY}`,
|
|
156
|
+
"Bash(git:*)",
|
|
157
|
+
"Bash(npm:*)",
|
|
158
|
+
"Bash(npx:*)",
|
|
159
|
+
"Bash(node:*)",
|
|
160
|
+
"Bash(ls:*)",
|
|
161
|
+
"Bash(cat:*)",
|
|
162
|
+
"Bash(rg:*)",
|
|
163
|
+
"Bash(find:*)",
|
|
164
|
+
"Bash(sed:*)",
|
|
165
|
+
"Bash(grep:*)",
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
/** Tools a headless session must never be OFFERED, because they open an
|
|
169
|
+
* interactive prompt no one is there to answer.
|
|
170
|
+
*
|
|
171
|
+
* EnterPlanMode (2026-08-30): spawn-mtf3xbm7 called it unprompted, wrote a
|
|
172
|
+
* plan, and then could not leave - plan approval is an interactive gate that
|
|
173
|
+
* neither --permission-mode acceptEdits, nor an allowlisted ExitPlanMode, nor
|
|
174
|
+
* a typed "I approve" on stdin can pass on CLI 2.1.34. The session parked in
|
|
175
|
+
* plan mode across three resumes ($2.63 total) with its frame reading
|
|
176
|
+
* RUNNING. The brief IS the plan for a spawned session; the board's ask_user
|
|
177
|
+
* is the channel for real forks.
|
|
178
|
+
*
|
|
179
|
+
* AskUserQuestion: the same shape one step earlier - a question rendered in a
|
|
180
|
+
* terminal nobody is watching. The preamble already points every session at
|
|
181
|
+
* the board's ask_user instead; taking the dead tool away means the model
|
|
182
|
+
* reaches for the live one first rather than after a failed call. */
|
|
183
|
+
export const CLAUDE_DISALLOWED_TOOLS = ["EnterPlanMode", "AskUserQuestion"];
|
|
184
|
+
|
|
185
|
+
/** The two tools that reach the web. Allowed up front only when the person
|
|
186
|
+
* turned web search on for this run (`webSearch: true` on the spawn request);
|
|
187
|
+
* otherwise they are outside the list, and on the streaming path a call to
|
|
188
|
+
* one is a question in the idea ("Can I search the web?"). */
|
|
189
|
+
export const CLAUDE_WEB_TOOLS = ["WebSearch", "WebFetch"];
|
|
190
|
+
|
|
191
|
+
/** The allowlist for one run: the standing list, plus the web tools when the
|
|
192
|
+
* request switched web search on. A plain function of the request, so the
|
|
193
|
+
* one-shot and streaming command lines cannot disagree. */
|
|
194
|
+
export const claudeToolsFor = (wants = {}) =>
|
|
195
|
+
wants.webSearch === true ? [...CLAUDE_TOOLS, ...CLAUDE_WEB_TOOLS] : CLAUDE_TOOLS;
|
|
196
|
+
|
|
197
|
+
export const AGENTS = {
|
|
198
|
+
codex: {
|
|
199
|
+
label: "Codex", bin: "codex", binEnv: "PM_CODEX_BIN", argsEnv: "PM_CODEX_ARGS",
|
|
200
|
+
protocol: "codex-app-server", fenced: true, needsBoardSetup: false,
|
|
201
|
+
modelFlag: null, effortFlag: null, protocolEffort: true,
|
|
202
|
+
// RENAMED 2026-09-12 (brief 31, Move 3b): the server codexBoardArgs
|
|
203
|
+
// registers is `mcp_servers.glytch` now (lib/codex.mjs), so this is the
|
|
204
|
+
// namespace codex's own tool list actually answers to.
|
|
205
|
+
boardTool: (name) => `mcp__${SERVER_KEY}__${name}`, todoTool: "update_plan",
|
|
206
|
+
args: () => ["app-server"], sessionArgs: () => [], resumeArgs: () => [],
|
|
207
|
+
},
|
|
208
|
+
claude: {
|
|
209
|
+
label: "Claude Code",
|
|
210
|
+
bin: "claude",
|
|
211
|
+
binEnv: "PM_CLAUDE_BIN",
|
|
212
|
+
argsEnv: "PM_CLAUDE_ARGS",
|
|
213
|
+
// Fenced by an allowlist: the session is offered a named set of tools
|
|
214
|
+
// rather than all of them. Read what that does and does not stop at the
|
|
215
|
+
// top of this file before repeating it as "it cannot open a shell" - it
|
|
216
|
+
// can, through Bash(node:*) and Bash(npx:*), both on the list.
|
|
217
|
+
fenced: true,
|
|
218
|
+
// Reads .mcp.json from the folder it runs in, so a linked project wires
|
|
219
|
+
// itself up with no extra step.
|
|
220
|
+
needsBoardSetup: false,
|
|
221
|
+
// --model takes an alias (opus, sonnet) or a full name. There is NO
|
|
222
|
+
// unconditional effort flag: commandFor requires a fresh CLI capability probe.
|
|
223
|
+
modelFlag: "--model",
|
|
224
|
+
effortFlag: null,
|
|
225
|
+
// Claude Code exposes an MCP server's tools under mcp__<server>__<tool>,
|
|
226
|
+
// and answers to the bare name too.
|
|
227
|
+
boardTool: (name) => name,
|
|
228
|
+
// brief 23 ruling 4a (2026-09-07): the plan contract in preambleFor
|
|
229
|
+
// names this tool, so a session can be told the true name of the thing
|
|
230
|
+
// that carries its plan. UPDATED 2026-09-21: Claude Code 2.1.270 has no
|
|
231
|
+
// TodoWrite at all (verified live - see the CLAUDE_TOOLS comment above);
|
|
232
|
+
// it ships TaskCreate/TaskUpdate/TaskList/TaskGet instead. `write` and
|
|
233
|
+
// `update` are the tools preambleFor tells a session to plan with;
|
|
234
|
+
// `fallback` is TodoWrite for an older Claude Code that still has it -
|
|
235
|
+
// both are already allowlisted above.
|
|
236
|
+
todoTool: { write: "TaskCreate", update: "TaskUpdate", fallback: "TodoWrite" },
|
|
237
|
+
// ORDER MATTERS AND IT COST A RUN. `--allowedTools <tools...>` is
|
|
238
|
+
// VARIADIC: it eats every following argument that does not begin with a
|
|
239
|
+
// dash. With the prompt last it was swallowed as one more tool name and
|
|
240
|
+
// the session spawned, billed, and never saw its brief. The prompt goes
|
|
241
|
+
// immediately after -p, before any variadic flag.
|
|
242
|
+
//
|
|
243
|
+
// stream-json because in text mode `-p` prints only its closing message,
|
|
244
|
+
// so a session's log stayed empty for its whole run and the live terminal
|
|
245
|
+
// had nothing to draw. --verbose is required alongside it for -p.
|
|
246
|
+
args: (wants = {}) => [
|
|
247
|
+
"-p",
|
|
248
|
+
"{prompt}",
|
|
249
|
+
"--output-format",
|
|
250
|
+
"stream-json",
|
|
251
|
+
"--verbose",
|
|
252
|
+
"--permission-mode",
|
|
253
|
+
"acceptEdits",
|
|
254
|
+
"--allowedTools",
|
|
255
|
+
claudeToolsFor(wants).join(" "),
|
|
256
|
+
// Variadic like --allowedTools, and for the same reason it sits after
|
|
257
|
+
// the prompt: one joined string, so it cannot eat what follows.
|
|
258
|
+
"--disallowedTools",
|
|
259
|
+
CLAUDE_DISALLOWED_TOOLS.join(" "),
|
|
260
|
+
],
|
|
261
|
+
// SESSION PERSISTENCE: --session-id mints a new session, --resume
|
|
262
|
+
// continues one. Both are UUIDs, both verified on CLI 2.1.34.
|
|
263
|
+
// The runner appends exactly one of these to the args array.
|
|
264
|
+
sessionArgs: (sessionId) => ["--session-id", sessionId],
|
|
265
|
+
resumeArgs: (resumeId) => ["--resume", resumeId],
|
|
266
|
+
// STREAMING INPUT (part 3): the child stays alive and takes further user
|
|
267
|
+
// messages on stdin as JSON lines. The prompt is NOT in the args - it goes
|
|
268
|
+
// as the first message on stdin. --replay-user-messages echoes the typed
|
|
269
|
+
// reply back on stdout, which is what the terminal needs to draw it.
|
|
270
|
+
// Verified on CLI 2.1.34.
|
|
271
|
+
//
|
|
272
|
+
// ASK IN THE IDEA: with `askPermissions` the CLI is told to send every tool
|
|
273
|
+
// call outside the allowlist to us as a `control_request` on stdout
|
|
274
|
+
// (`--permission-prompt-tool stdio`, the SDK's own door; it is a hidden
|
|
275
|
+
// flag, present on 2.1.299). child.mjs answers it on stdin once the person
|
|
276
|
+
// has answered the question filed in the idea. Without the flag such a
|
|
277
|
+
// call is refused silently, which is what every run did before. A flag
|
|
278
|
+
// with a value, so it cannot swallow what follows it.
|
|
279
|
+
streamingArgs: (wants = {}) => [
|
|
280
|
+
"-p",
|
|
281
|
+
"--input-format", "stream-json",
|
|
282
|
+
"--output-format", "stream-json",
|
|
283
|
+
"--verbose",
|
|
284
|
+
"--replay-user-messages",
|
|
285
|
+
"--permission-mode", "acceptEdits",
|
|
286
|
+
...(wants.askPermissions === true ? ["--permission-prompt-tool", "stdio"] : []),
|
|
287
|
+
"--allowedTools", claudeToolsFor(wants).join(" "),
|
|
288
|
+
// The streaming path is the one long sessions take, and it is exactly
|
|
289
|
+
// where spawn-mtf3xbm7 parked - so the same tools are barred here.
|
|
290
|
+
"--disallowedTools", CLAUDE_DISALLOWED_TOOLS.join(" "),
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// CLAUDE CODE, THE OTHER WAY ROUND (2026-09-02, brief 15 decision 8).
|
|
295
|
+
//
|
|
296
|
+
// Same model, same machine, same allowlist - a different pipe. The adapter
|
|
297
|
+
// is a node program, so `bin` is node itself and the single argument is the
|
|
298
|
+
// adapter's entrypoint, resolved from bridge/node_modules at command time.
|
|
299
|
+
// `PM_ACP_ADAPTER` overrides that, which is both the escape hatch for an
|
|
300
|
+
// odd install and the seam the tests drive a fake adapter through.
|
|
301
|
+
//
|
|
302
|
+
// WHAT THIS ENGINE IS FOR, and it is not "replacing claude". It is the one
|
|
303
|
+
// engine whose events arrive in a shape the live view of brief 15 wants
|
|
304
|
+
// without inference: streamed text, tool open and close, an
|
|
305
|
+
// auto-answerable permission door, a session that takes a second prompt,
|
|
306
|
+
// and a `session/cancel` for Stop. What it costs is the two things this
|
|
307
|
+
// bridge learned the hard way and acp.mjs re-states: no ceiling to set, and
|
|
308
|
+
// no dollar figure to report. Every receipt from here says "estimate".
|
|
309
|
+
"acp-claude": {
|
|
310
|
+
label: "Claude Code via ACP",
|
|
311
|
+
bin: process.execPath,
|
|
312
|
+
binEnv: "PM_ACP_NODE_BIN",
|
|
313
|
+
argsEnv: "PM_ACP_CLAUDE_ARGS",
|
|
314
|
+
// The SAME allowlist as `claude`, enforced at a different door: there it
|
|
315
|
+
// is `--allowedTools`, here it is the client answering
|
|
316
|
+
// session/request_permission from CLAUDE_TOOLS. One list, two doors.
|
|
317
|
+
fenced: true,
|
|
318
|
+
// The board server is passed inline at session/new (acpServersFor), so
|
|
319
|
+
// there is nothing to write into the worktree.
|
|
320
|
+
needsBoardSetup: false,
|
|
321
|
+
// NEITHER IS A FLAG on this engine: the model is a per-session config
|
|
322
|
+
// option (`session/set_config_option`, verified) and effort has no option
|
|
323
|
+
// at all on claude-agent-acp 0.73.0. modelFlag stays null so commandFor
|
|
324
|
+
// does not append a flag the adapter would choke on, and acp.model
|
|
325
|
+
// carries the request's choice instead. effortFlag stays null so an
|
|
326
|
+
// effort asked for is RECORDED as ignored rather than silently dropped.
|
|
327
|
+
modelFlag: null,
|
|
328
|
+
effortFlag: null,
|
|
329
|
+
boardTool: (name) => name,
|
|
330
|
+
// Same tools, same reason as the `claude` spec above: this is the same
|
|
331
|
+
// engine through a different pipe, so it carries the same Task* plan
|
|
332
|
+
// tools (updated 2026-09-21 alongside the `claude` spec).
|
|
333
|
+
todoTool: { write: "TaskCreate", update: "TaskUpdate", fallback: "TodoWrite" },
|
|
334
|
+
protocol: "acp",
|
|
335
|
+
adapter: "@agentclientprotocol/claude-agent-acp",
|
|
336
|
+
// dist/index.js, NOT the package name. The package's `main` is
|
|
337
|
+
// `dist/lib.js`, the adapter as a LIBRARY; the program that speaks
|
|
338
|
+
// JSON-RPC on stdio is its `bin`. Resolving the package name here got
|
|
339
|
+
// ERR_PACKAGE_PATH_NOT_EXPORTED on the first real run, because the "."
|
|
340
|
+
// export offers only an `import` condition. The spike used this same
|
|
341
|
+
// path.
|
|
342
|
+
adapterModule: "@agentclientprotocol/claude-agent-acp/dist/index.js",
|
|
343
|
+
// No `{prompt}` placeholder anywhere: the prompt is not an argument on
|
|
344
|
+
// this engine, it is the body of a session/prompt request. The runner
|
|
345
|
+
// passes it in the options object, which the other two engines ignore.
|
|
346
|
+
args: (wants = {}) => {
|
|
347
|
+
const entry = adapterEntry(
|
|
348
|
+
"@agentclientprotocol/claude-agent-acp/dist/index.js",
|
|
349
|
+
wants.env ?? process.env,
|
|
350
|
+
);
|
|
351
|
+
return entry ? [entry] : [];
|
|
352
|
+
},
|
|
353
|
+
// SESSION PERSISTENCE, and the honest state of it: a new session is minted
|
|
354
|
+
// by session/new every run, and a `resumeSessionId` is TRIED through
|
|
355
|
+
// session/load and falls back to a fresh session when it fails. The spike
|
|
356
|
+
// saw `loadSession: true` advertised but never loaded one, so this path
|
|
357
|
+
// is designed-for rather than proven - acp.mjs says so where it lives and
|
|
358
|
+
// bridge/SPAWN.md lists it as untested.
|
|
359
|
+
sessionArgs: () => [],
|
|
360
|
+
resumeArgs: () => [],
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
opencode: {
|
|
364
|
+
label: "opencode",
|
|
365
|
+
bin: "opencode",
|
|
366
|
+
binEnv: "PM_OPENCODE_BIN",
|
|
367
|
+
argsEnv: "PM_OPENCODE_ARGS",
|
|
368
|
+
// NOT fenced, and this is the honest difference. `--auto` auto-approves
|
|
369
|
+
// anything not explicitly denied. The worktree still bounds what it can
|
|
370
|
+
// WRITE; nothing bounds what it can RUN.
|
|
371
|
+
fenced: false,
|
|
372
|
+
// Keeps its own MCP config: `glytch init` writes the hosted `glytch`
|
|
373
|
+
// entry into opencode.json and `opencode mcp auth glytch` signs it in
|
|
374
|
+
// (brief 31, 2026-09-12; the spawned `glytch mcp` command this comment
|
|
375
|
+
// used to name is retired). Until that is done an opencode session runs
|
|
376
|
+
// blind - no get_board, no ask_user, no report - and its frame would open
|
|
377
|
+
// and never be written in.
|
|
378
|
+
needsBoardSetup: true,
|
|
379
|
+
// -m takes provider/model, which happens to be the same shape as an
|
|
380
|
+
// OpenRouter id. --variant IS reasoning effort, in its own words:
|
|
381
|
+
// "provider-specific reasoning effort, e.g., high, max, minimal".
|
|
382
|
+
modelFlag: "-m",
|
|
383
|
+
effortFlag: "--variant",
|
|
384
|
+
// opencode NAMESPACES them by the server key: <key>_get_board,
|
|
385
|
+
// <key>_report, <key>_ask_user. Telling a session to call `report` there
|
|
386
|
+
// is telling it to call something that does not exist - and it did
|
|
387
|
+
// exactly that, said "my tool list doesn't include get_board", and gave
|
|
388
|
+
// up. It was right. RENAMED 2026-09-12 (brief 31, Move 3b): the key
|
|
389
|
+
// prepareWorkspace registers under OPENCODE_CONFIG_CONTENT is
|
|
390
|
+
// SERVER_KEY ("glytch") now, not "board", so this must namespace the
|
|
391
|
+
// same way or a session is told to call a tool that no longer exists.
|
|
392
|
+
boardTool: (name) => `${SERVER_KEY}_${name}`,
|
|
393
|
+
todoTool: "todowrite",
|
|
394
|
+
// `run <message>` is the headless form; --format json is its event stream,
|
|
395
|
+
// the counterpart of Claude's stream-json. The message is a positional and
|
|
396
|
+
// goes first, for the same variadic reason.
|
|
397
|
+
args: () => ["run", "{prompt}", "--format", "json", "--auto"],
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
export const DEFAULT_AGENT = "claude";
|
|
402
|
+
|
|
403
|
+
/** The agent a request asked for, or the default. Unknown names fall back
|
|
404
|
+
* rather than throwing: a request naming an engine this runner has never
|
|
405
|
+
* heard of should run on the default and say so, not die holding a claim. */
|
|
406
|
+
export function agentFor(name) {
|
|
407
|
+
const key = typeof name === "string" ? name.trim().toLowerCase() : "";
|
|
408
|
+
return Object.prototype.hasOwnProperty.call(AGENTS, key) ? key : DEFAULT_AGENT;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// `isKnownAgent` was here and nothing called it. Removed 2026-09-02, and the
|
|
412
|
+
// reason is a rule rather than tidiness: NOTHING in this runner refuses an
|
|
413
|
+
// unknown engine name. agentFor falls back to the default and the receipt
|
|
414
|
+
// says which ran, because dying while holding a claim on a request nobody
|
|
415
|
+
// will retry is the expensive failure (checkRequest in spawn.mjs says so in
|
|
416
|
+
// as many words). A predicate that answers "is this a known agent?" exists
|
|
417
|
+
// only to be branched on, so leaving it here was an invitation to write the
|
|
418
|
+
// branch that ruling forbids.
|
|
419
|
+
|
|
420
|
+
/** Does this name belong to a coding CLI this bridge can run?
|
|
421
|
+
*
|
|
422
|
+
* THIS IS NOT `isKnownAgent`, the predicate retired above, and the difference
|
|
423
|
+
* is which decision it feeds. That one was asked "which engine runs this
|
|
424
|
+
* session?", and the ruling there stands: agentFor falls back to the default
|
|
425
|
+
* and the receipt says which ran, because dying while holding a claim on a
|
|
426
|
+
* request nobody will retry is the expensive failure.
|
|
427
|
+
*
|
|
428
|
+
* This one is asked "may this name reach a CHAT TURN at all?" (brief 25,
|
|
429
|
+
* 2026-09-07), and a fallback there is the exact confusion the brief exists to
|
|
430
|
+
* end. The whole ruling 1A is that a DM with a coding agent runs on Claude
|
|
431
|
+
* Code on his subscription rather than on an API key; a chat-turn file naming
|
|
432
|
+
* an engine this bridge does not have means the APP's routing broke, and
|
|
433
|
+
* quietly answering it as `claude` would tell him he is talking to the agent
|
|
434
|
+
* he named when he is not. So the chat door refuses, in words, and the
|
|
435
|
+
* session door still falls back. One predicate, one caller, one reason. */
|
|
436
|
+
export const isCodingCli = (name) =>
|
|
437
|
+
typeof name === "string" && Object.prototype.hasOwnProperty.call(AGENTS, name.trim().toLowerCase());
|
|
438
|
+
|
|
439
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
440
|
+
|
|
441
|
+
/** Does this request run a PERSISTENT session - one that mints or resumes a
|
|
442
|
+
* session id, so its worktree is kept and it can be resumed? */
|
|
443
|
+
export const isPersistentRequest = (request) =>
|
|
444
|
+
nonEmpty(request?.sessionId) || nonEmpty(request?.resumeSessionId);
|
|
445
|
+
|
|
446
|
+
/** Can a message be delivered INTO this session while it runs?
|
|
447
|
+
*
|
|
448
|
+
* A persistent session whose engine has a DOOR, and there are two of them
|
|
449
|
+
* now. On `claude` the door is stdin: the runner starts that child with
|
|
450
|
+
* `--input-format stream-json` and holds the pipe open. On `acp-claude` the
|
|
451
|
+
* door is a second `session/prompt` into the same sessionId - the spike
|
|
452
|
+
* proved a session stays alive between turns with no stdin trick, and
|
|
453
|
+
* runAcpChild delivers each reply file that way.
|
|
454
|
+
*
|
|
455
|
+
* opencode still has neither, and a one-shot request on either engine has
|
|
456
|
+
* none either: nothing reads reply files on that path, so a reply written
|
|
457
|
+
* beside a one-shot child sits on disk for ever.
|
|
458
|
+
*
|
|
459
|
+
* This is the same expression `runRequest` uses to choose the persistent
|
|
460
|
+
* path, and it lives here rather than in both places: the local stdio
|
|
461
|
+
* server's list_sessions asserted the opposite of it for every session
|
|
462
|
+
* ("no receipt, can receive messages"), and two spellings of one rule is how
|
|
463
|
+
* that happens. */
|
|
464
|
+
export const acceptsMessages = (request) =>
|
|
465
|
+
request?.planMode !== "draft" && isPersistentRequest(request) && runEngineFor(AGENTS[agentFor(request?.agent)]).acceptsMessages;
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
/** WHICH FUNCTION RUNS THE CHILD, off the engine row rather than out of a
|
|
469
|
+
* branch in runRequest.
|
|
470
|
+
*
|
|
471
|
+
* This existed as `useStreaming ? runStreamingChild : runChild` in the middle
|
|
472
|
+
* of runRequest, which was fine while every engine spoke stream-json on
|
|
473
|
+
* stdout. `acp-claude` does not, so the choice became a property of the
|
|
474
|
+
* engine - which is what this table has always been for. runRequest asks and
|
|
475
|
+
* stays ignorant, exactly as it does for the command line. */
|
|
476
|
+
export function runnerFor(agent, streaming) {
|
|
477
|
+
const spec = AGENTS[agentFor(agent)];
|
|
478
|
+
const engine = runEngineFor(spec);
|
|
479
|
+
return streaming ? engine.streaming : engine.run;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** The board, described the way an ACP `session/new` wants it.
|
|
483
|
+
*
|
|
484
|
+
* MOVED ONTO THE HOSTED ENDPOINT, 2026-09-11 (brief 30, W2a): the local
|
|
485
|
+
* stdio server this used to spawn as a subprocess of the adapter is
|
|
486
|
+
* retired - every engine reaches the board over HTTP now
|
|
487
|
+
* (supabase/functions/board-mcp), never through a locally-spawned process. A
|
|
488
|
+
* stdio entry became an HTTP one; nothing else about how ACP is handed its
|
|
489
|
+
* MCP servers changed - still inline at `session/new` rather than a file on
|
|
490
|
+
* disk, because ACP takes it as a request parameter (verified in the spike
|
|
491
|
+
* that this reaches the model as `mcp__<name>__<tool>`, so preambleFor's
|
|
492
|
+
* Claude naming needs no change here either).
|
|
493
|
+
*
|
|
494
|
+
* THE SHAPE IS THE PROTOCOL'S OWN, checked against
|
|
495
|
+
* @agentclientprotocol/sdk@1.4.0's schema (`McpServerHttp`, dist/schema/
|
|
496
|
+
* types.gen.d.ts): `{type: "http", name, url, headers: Array<{name, value}>}`
|
|
497
|
+
* - an ARRAY of {name, value} pairs for headers, the same shape env already
|
|
498
|
+
* used, not a plain object. This is a wire-format fact checked against the
|
|
499
|
+
* installed SDK's TypeScript types, not run against a live adapter: whether
|
|
500
|
+
* claude-agent-acp 0.73.0 actually implements an HTTP-type server (as
|
|
501
|
+
* opposed to merely accepting the shape and failing later) is UNVERIFIED - it
|
|
502
|
+
* advertises `McpCapabilities.http` at `initialize`, and nothing in this
|
|
503
|
+
* bridge has checked that flag or run a real session against it. */
|
|
504
|
+
export function acpServersFor(agent, { mcpUrl, token, sessionKey, boardId }) {
|
|
505
|
+
const spec = AGENTS[agentFor(agent)];
|
|
506
|
+
if (spec.protocol !== "acp" || !mcpUrl || !token) return [];
|
|
507
|
+
const headers = [{ name: "Authorization", value: `Bearer ${token}` }];
|
|
508
|
+
if (sessionKey) headers.push({ name: "X-Glytch-Session", value: sessionKey });
|
|
509
|
+
// THE RUN'S OWN BOARD (2026-09-21, board reporting fix), same rule as
|
|
510
|
+
// X-Glytch-Session above: board-mcp/index.ts seeds `boardFor`'s fallback
|
|
511
|
+
// from this header, so `report` and friends land on the board this run
|
|
512
|
+
// actually belongs to rather than guessing the account's most recent one.
|
|
513
|
+
if (boardId) headers.push({ name: "X-Glytch-Board", value: boardId });
|
|
514
|
+
return [{ type: "http", name: SERVER_KEY, url: mcpUrl, headers }];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Binary and arguments for one engine, with env overrides applied.
|
|
518
|
+
*
|
|
519
|
+
* `<AGENT>_ARGS` still overrides the lot for anyone who wants something else,
|
|
520
|
+
* and junk falls back to the engine's own default rather than to nothing -
|
|
521
|
+
* a malformed override must not quietly strip the permission flags. */
|
|
522
|
+
// ASK RULES FOR EVERY APP TOOL (#481). Claude Code checks deny, then ask,
|
|
523
|
+
// then allow, across every settings file, so an ask rule beats an allow the
|
|
524
|
+
// person saved earlier ("Yes, and don't ask again" on a Gmail tool). One
|
|
525
|
+
// `mcp__*` would also stop glytch's own tools, and a rule's only wildcard is
|
|
526
|
+
// `*`, so "every MCP tool but glytch's" is spelled out a character at a
|
|
527
|
+
// time: any name that leaves `mcp__glytch__` at some position. Claude Code
|
|
528
|
+
// turns every other character of a server name into `_`, so these
|
|
529
|
+
// characters are all a tool name can hold there. The last two rules catch a
|
|
530
|
+
// server of the person's named `glytch_` or `glytch__x`, whose tools would
|
|
531
|
+
// otherwise read as glytch's; no glytch tool starts with `_` or holds `__`.
|
|
532
|
+
const TOOL_NAME_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
|
|
533
|
+
export function appAskRules(keep = `mcp__${SERVER_KEY}__`) {
|
|
534
|
+
const rules = [];
|
|
535
|
+
for (let i = "mcp__".length; i < keep.length; i += 1) {
|
|
536
|
+
for (const c of TOOL_NAME_CHARS) if (c !== keep[i]) rules.push(`${keep.slice(0, i)}${c}*`);
|
|
537
|
+
}
|
|
538
|
+
return [...rules, `${keep}_*`, `${keep}*__*`];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function commandFor(agent, env = process.env, wants = {}) {
|
|
542
|
+
const spec = AGENTS[agentFor(agent)];
|
|
543
|
+
const bin = env[spec.binEnv]?.trim() || spec.bin;
|
|
544
|
+
// A draft never inherits a wider command line, MCP server, hook, skill,
|
|
545
|
+
// persisted session or shell tool. --tools removes capabilities entirely;
|
|
546
|
+
// --allowedTools alone only skips interactive permission prompts.
|
|
547
|
+
//
|
|
548
|
+
// WHICH ENGINES REACH THIS BRANCH IS NOT A LOCAL DECISION - see
|
|
549
|
+
// PLAN_MODE_AGENTS in bridge/lib/spawn.mjs, the checked validator, and
|
|
550
|
+
// briefs/plan-mode-beyond-claude.md for what was verified before each name
|
|
551
|
+
// was added. Codex joined Claude here 2026-09-11: the native app-server's
|
|
552
|
+
// `sandbox: "read-only"` thread policy is an OS-level seatbelt/landlock
|
|
553
|
+
// restriction the child process cannot argue its way out of (verified
|
|
554
|
+
// against the installed codex-cli 0.153.4's own generated protocol schema,
|
|
555
|
+
// `codex app-server generate-json-schema` - `ReadOnlySandboxPolicy`
|
|
556
|
+
// defaults `networkAccess: false` on top of blocking writes), combined
|
|
557
|
+
// with `approvalPolicy: "never"` (a blocked write is returned to the model
|
|
558
|
+
// as a failure, never escalated) and never configuring the board MCP
|
|
559
|
+
// server for that thread's process at all (runCodexChild, codex.mjs, skips
|
|
560
|
+
// `codexBoardArgs` outright when `config.planMode === "draft"` - the actual
|
|
561
|
+
// enforcement lives there, this branch only refuses to hand it anything to
|
|
562
|
+
// enforce against). opencode is deliberately absent: see the note beside
|
|
563
|
+
// `PLAN_MODE_AGENTS` in spawn.mjs for why its shipped `plan` agent does not
|
|
564
|
+
// meet this bar.
|
|
565
|
+
if (wants.planMode === "draft") {
|
|
566
|
+
const engine = agentFor(agent);
|
|
567
|
+
if (!PLAN_MODE_AGENTS.includes(engine)) throw new Error("Read-only planning currently requires Claude Code or Codex.");
|
|
568
|
+
if (engine === "codex") {
|
|
569
|
+
return {
|
|
570
|
+
bin, args: ["app-server"], spec, ignored: [], acp: null,
|
|
571
|
+
codex: {
|
|
572
|
+
model: typeof wants.model === "string" ? wants.model.trim() : "",
|
|
573
|
+
effort: typeof wants.effort === "string" ? wants.effort.trim() : "",
|
|
574
|
+
// NO RESUME, EVER, on a draft: a fresh thread every time, so a
|
|
575
|
+
// draft can never inherit a prior turn's permissions or history.
|
|
576
|
+
resumeSessionId: "",
|
|
577
|
+
// NO BOARD ACCESS: omitted rather than passed and ignored, so a
|
|
578
|
+
// future change to runCodexChild cannot accidentally wire a board
|
|
579
|
+
// server back in for a draft by forgetting to check planMode a
|
|
580
|
+
// second time.
|
|
581
|
+
boardUrl: undefined, sessionKey: undefined, boardId: undefined, argsOverridden: false,
|
|
582
|
+
// NO APPS EITHER (#481): runCodexChild switches off every MCP
|
|
583
|
+
// server the person set up in Codex for a draft, whatever the
|
|
584
|
+
// agent's own setting says.
|
|
585
|
+
computerApps: false,
|
|
586
|
+
planMode: "draft",
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const args = ["-p", "{prompt}", "--output-format", "stream-json", "--verbose",
|
|
591
|
+
"--permission-mode", "dontAsk", "--tools", "Read,Glob,Grep,WebSearch,WebFetch",
|
|
592
|
+
"--allowedTools", "Read,Glob,Grep,WebSearch,WebFetch",
|
|
593
|
+
"--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
594
|
+
"--setting-sources", "", "--settings", '{"disableAllHooks":true}',
|
|
595
|
+
"--disable-slash-commands", "--no-chrome", "--no-session-persistence"];
|
|
596
|
+
if (typeof wants.model === "string" && wants.model.trim()) args.push("--model", wants.model.trim());
|
|
597
|
+
const effort = typeof wants.effort === "string" ? wants.effort.trim() : "";
|
|
598
|
+
if (effort && wants.claudeEfforts?.includes(effort)) args.push("--effort", effort);
|
|
599
|
+
return { bin, args, spec, ignored: effort && !wants.claudeEfforts?.includes(effort) ? ["effort (not supported by this model on this computer)"] : [], acp: null };
|
|
600
|
+
}
|
|
601
|
+
const raw = env[spec.argsEnv];
|
|
602
|
+
// STREAMING MODE: when the caller asks for streaming and the engine supports
|
|
603
|
+
// it, use the streaming args (prompt on stdin, child stays alive for replies).
|
|
604
|
+
// `env` rides along in `wants` so an engine whose command line depends on
|
|
605
|
+
// the environment can build it - `acp-claude` resolves its adapter's
|
|
606
|
+
// entrypoint, and PM_ACP_ADAPTER overrides it. Every other engine ignores
|
|
607
|
+
// the field.
|
|
608
|
+
const asked = { ...wants, env };
|
|
609
|
+
let args = asked.streaming && spec.streamingArgs ? spec.streamingArgs(asked) : spec.args(asked);
|
|
610
|
+
let overridden = false;
|
|
611
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
612
|
+
try {
|
|
613
|
+
const parsed = JSON.parse(raw);
|
|
614
|
+
if (Array.isArray(parsed) && parsed.every((a) => typeof a === "string")) {
|
|
615
|
+
args = parsed;
|
|
616
|
+
overridden = true;
|
|
617
|
+
}
|
|
618
|
+
} catch {
|
|
619
|
+
/* fall through to the engine's default rather than failing to start */
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// WHAT THE CIRCLE ASKED FOR. Appended rather than woven in, so it cannot
|
|
624
|
+
// disturb the argument ORDER that a variadic flag depends on - the prompt
|
|
625
|
+
// has to stay ahead of --allowedTools, and a model flag inserted in the
|
|
626
|
+
// middle is how that breaks again.
|
|
627
|
+
//
|
|
628
|
+
// Skipped entirely when PM_*_ARGS replaced the command line: someone who
|
|
629
|
+
// wrote their own arguments meant them, and appending to them is guessing.
|
|
630
|
+
//
|
|
631
|
+
// An engine with no effort flag DROPS the request rather than approximating
|
|
632
|
+
// it. Claude requires local capability evidence before adding --effort;
|
|
633
|
+
// an older CLI must not fail the whole run over a preference.
|
|
634
|
+
const ignored = [];
|
|
635
|
+
if (!overridden) {
|
|
636
|
+
const model = typeof wants.model === "string" ? wants.model.trim() : "";
|
|
637
|
+
if (model !== "" && spec.modelFlag) args = [...args, spec.modelFlag, model];
|
|
638
|
+
const effort = typeof wants.effort === "string" ? wants.effort.trim() : "";
|
|
639
|
+
if (effort !== "") {
|
|
640
|
+
if (agentFor(agent) === "claude" && wants.claudeEfforts?.includes(effort)) args = [...args, "--effort", effort];
|
|
641
|
+
else if (spec.effortFlag) args = [...args, spec.effortFlag, effort];
|
|
642
|
+
else if (!spec.protocolEffort) ignored.push(agentFor(agent) === "claude" ? "effort (not supported by this model on this computer)" : `effort (${spec.label} has no such setting)`);
|
|
643
|
+
}
|
|
644
|
+
// SESSION PERSISTENCE: resume takes priority over sessionId. Both are
|
|
645
|
+
// appended AFTER --allowedTools because they are not variadic, and they
|
|
646
|
+
// must come after the prompt for the same reason model and effort do.
|
|
647
|
+
//
|
|
648
|
+
// Only Claude Code supports this on 2.1.34. opencode has no equivalent.
|
|
649
|
+
const resume = typeof wants.resumeSessionId === "string" ? wants.resumeSessionId.trim() : "";
|
|
650
|
+
const session = typeof wants.sessionId === "string" ? wants.sessionId.trim() : "";
|
|
651
|
+
if (resume !== "" && spec.resumeArgs) {
|
|
652
|
+
args = [...args, ...spec.resumeArgs(resume)];
|
|
653
|
+
} else if (session !== "" && spec.sessionArgs) {
|
|
654
|
+
args = [...args, ...spec.sessionArgs(session)];
|
|
655
|
+
}
|
|
656
|
+
// THE BOARD, OVER THE HOSTED ENDPOINT (2026-09-11, brief 30 W2a).
|
|
657
|
+
// The local stdio server Claude Code used to reach through a checked-in
|
|
658
|
+
// .mcp.json is retired - so this engine gets its
|
|
659
|
+
// board access the same way session/new and codexBoardArgs give it to
|
|
660
|
+
// the other two: built here, from what the runner resolved once through
|
|
661
|
+
// lib/auth.mjs (bridge/lib/runner.mjs), never from a file in the
|
|
662
|
+
// worktree. opencode and codex have their own wiring (an env override, a
|
|
663
|
+
// `-c` override, respectively) because a bare bearer token in THEIR
|
|
664
|
+
// command line would sit in `ps`; ACP gets it as protocol payload over
|
|
665
|
+
// stdio (the `acp` object below), never argv either.
|
|
666
|
+
//
|
|
667
|
+
// VERIFIED against the installed claude 2.1.34: `claude mcp add
|
|
668
|
+
// --transport http board <url> --header "Authorization: Bearer x"
|
|
669
|
+
// --header "X-Glytch-Session: y" -s project` writes exactly this shape
|
|
670
|
+
// into .mcp.json - `{"mcpServers":{"board":{"type":"http","url":...,
|
|
671
|
+
// "headers":{...}}}}` - so this is the JSON `--mcp-config` is documented
|
|
672
|
+
// to accept as a string, run against the real binary rather than assumed.
|
|
673
|
+
// RENAMED 2026-09-12 (brief 31, Move 3b): the key WRITTEN below is
|
|
674
|
+
// `glytch` (SERVER_KEY), not `board` - the verification above predates
|
|
675
|
+
// the rename and is kept dated rather than rerun, since it was checking
|
|
676
|
+
// the shape `--mcp-config` accepts, not the key name.
|
|
677
|
+
//
|
|
678
|
+
// --strict-mcp-config makes this the ONLY MCP config the session sees:
|
|
679
|
+
// any committed .mcp.json (which may still name the retired local
|
|
680
|
+
// server), any user- or project-level config, all of it ignored. Skipped
|
|
681
|
+
// when PM_CLAUDE_ARGS replaced the command line, for the same reason
|
|
682
|
+
// model/effort/session are: someone who wrote their own arguments meant
|
|
683
|
+
// them, and this bridge appending to a fully custom command line is
|
|
684
|
+
// guessing at what would not clash. A run with no boardUrl/boardToken
|
|
685
|
+
// (not signed in, no VIEWABLE_BOARD_TOKEN) still gets `--strict-mcp-config`
|
|
686
|
+
// with an EMPTY server list - no board tools, rather than a session that
|
|
687
|
+
// silently tries a stale local one.
|
|
688
|
+
if (agentFor(agent) === "claude") {
|
|
689
|
+
const boardUrl = typeof wants.boardUrl === "string" ? wants.boardUrl.trim() : "";
|
|
690
|
+
const boardToken = typeof wants.boardToken === "string" ? wants.boardToken.trim() : "";
|
|
691
|
+
const sessionKey = typeof wants.sessionKey === "string" ? wants.sessionKey.trim() : "";
|
|
692
|
+
const boardId = typeof wants.boardId === "string" ? wants.boardId.trim() : "";
|
|
693
|
+
const mcpServers =
|
|
694
|
+
boardUrl && boardToken
|
|
695
|
+
? {
|
|
696
|
+
[SERVER_KEY]: {
|
|
697
|
+
type: "http",
|
|
698
|
+
url: boardUrl,
|
|
699
|
+
headers: {
|
|
700
|
+
Authorization: `Bearer ${boardToken}`,
|
|
701
|
+
...(sessionKey ? { "X-Glytch-Session": sessionKey } : {}),
|
|
702
|
+
// THE RUN'S OWN BOARD (2026-09-21, board reporting fix) -
|
|
703
|
+
// same reasoning as acpServersFor's X-Glytch-Board above.
|
|
704
|
+
...(boardId ? { "X-Glytch-Board": boardId } : {}),
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
}
|
|
708
|
+
: {};
|
|
709
|
+
// USE MY COMPUTER'S APPS (#481). Off, the default: strict, glytch
|
|
710
|
+
// only, as above. On, for this one agent and this one person: the
|
|
711
|
+
// strict flag goes, so the person's own Claude Code MCP servers load
|
|
712
|
+
// beside glytch. Their tools are NOT added to --allowedTools, and an
|
|
713
|
+
// ask rule covers every one of them, so a tool the person allowed in
|
|
714
|
+
// their own settings before still asks each time; in -p mode that
|
|
715
|
+
// ask is refused until the permission ask (#478) reaches the person.
|
|
716
|
+
const apps = wants.computerApps === true
|
|
717
|
+
? ["--settings", JSON.stringify({ permissions: { ask: appAskRules() } })]
|
|
718
|
+
: ["--strict-mcp-config"];
|
|
719
|
+
args = [...args, ...apps, "--mcp-config", JSON.stringify({ mcpServers })];
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// WHAT AN ACP ENGINE NEEDS THAT AN ARGUMENT LIST CANNOT CARRY. Model,
|
|
723
|
+
// permission mode, the board's MCP server and the allowlist are all
|
|
724
|
+
// protocol payload rather than command line, so they travel beside the
|
|
725
|
+
// command instead of inside it. Null for every other engine, and runAcpChild
|
|
726
|
+
// is the only reader.
|
|
727
|
+
//
|
|
728
|
+
// The MODEL IS NOT DROPPED HERE even though modelFlag is null: the block
|
|
729
|
+
// above skips it because there is no flag to put it on, and it arrives at
|
|
730
|
+
// the session through `session/set_config_option` instead. Effort is a
|
|
731
|
+
// genuine drop and is on `ignored` above, because the adapter offers no
|
|
732
|
+
// reasoning option at all.
|
|
733
|
+
const acp =
|
|
734
|
+
spec.protocol === "acp"
|
|
735
|
+
? {
|
|
736
|
+
label: spec.label,
|
|
737
|
+
adapter: spec.adapter,
|
|
738
|
+
allowTools: CLAUDE_TOOLS,
|
|
739
|
+
mode: "acceptEdits",
|
|
740
|
+
model: typeof wants.model === "string" ? wants.model.trim() : "",
|
|
741
|
+
mcpServers: acpServersFor(agent, {
|
|
742
|
+
mcpUrl: wants.boardUrl,
|
|
743
|
+
token: wants.boardToken,
|
|
744
|
+
sessionKey: wants.sessionKey,
|
|
745
|
+
boardId: wants.boardId,
|
|
746
|
+
}),
|
|
747
|
+
resumeSessionId:
|
|
748
|
+
typeof wants.resumeSessionId === "string" ? wants.resumeSessionId.trim() : "",
|
|
749
|
+
}
|
|
750
|
+
: null;
|
|
751
|
+
|
|
752
|
+
const codex = spec.protocol === "codex-app-server" ? {
|
|
753
|
+
model: wants.model?.trim() ?? "", effort: wants.effort?.trim() ?? "",
|
|
754
|
+
resumeSessionId: wants.resumeSessionId?.trim() ?? "",
|
|
755
|
+
boardUrl: wants.boardUrl, sessionKey: wants.sessionKey, boardId: wants.boardId, argsOverridden: overridden,
|
|
756
|
+
// ASK IN THE IDEA: on-request approvals instead of never, so a step outside
|
|
757
|
+
// the sandbox reaches the runner (codex.mjs onRequest) instead of being
|
|
758
|
+
// refused. Only when the runner can actually ask (it passes
|
|
759
|
+
// `askPermissions` only with a board to ask on).
|
|
760
|
+
askPermissions: wants.askPermissions === true,
|
|
761
|
+
// The person turned web search on for this run.
|
|
762
|
+
webSearch: wants.webSearch === true,
|
|
763
|
+
computerApps: wants.computerApps === true,
|
|
764
|
+
} : null;
|
|
765
|
+
return { bin, args, spec, ignored, acp, codex };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Give an engine what it needs to reach the board, in the tree it will run in.
|
|
769
|
+
*
|
|
770
|
+
* Claude Code needs nothing here: its board access is on the command line
|
|
771
|
+
* commandFor already built (`--strict-mcp-config --mcp-config`).
|
|
772
|
+
*
|
|
773
|
+
* opencode keeps its own config and a fresh checkout reports "No MCP servers
|
|
774
|
+
* configured", so without this an opencode session runs blind - no get_board,
|
|
775
|
+
* no ask_user, no report - and its run would open on the idea and
|
|
776
|
+
* have nothing written into it (DATED REWRITE 2026-09-07, brief 26 ruling 4:
|
|
777
|
+
* was "its frame would open and never be written in").
|
|
778
|
+
*
|
|
779
|
+
* MOVED OFF A WRITTEN opencode.json ONTO OPENCODE_CONFIG_CONTENT, 2026-09-11
|
|
780
|
+
* (brief 30, W2a): the local server this used to name as a
|
|
781
|
+
* `type: "local"` entry is retired, and the replacement is a bearer
|
|
782
|
+
* token - writing THAT into a file inside the session's own worktree is a
|
|
783
|
+
* secret sitting on disk for as long as the worktree lives (kept for review,
|
|
784
|
+
* resumed, or simply not yet cleaned up), for no gain over an env var the
|
|
785
|
+
* child alone sees. `OPENCODE_CONFIG_CONTENT` is confirmed present in the
|
|
786
|
+
* installed opencode 1.18.23 binary (`strings` for the name) and its
|
|
787
|
+
* `type: "remote"` MCP shape is confirmed by running `opencode mcp add
|
|
788
|
+
* --url <url> --header "Name=value"` against a real project and reading back
|
|
789
|
+
* what it wrote - `{"mcp":{"board":{"type":"remote","url":...,
|
|
790
|
+
* "headers":{...}}}}` - checked against the real binary rather than assumed.
|
|
791
|
+
* Whether OPENCODE_CONFIG_CONTENT MERGES with a project's own opencode.json
|
|
792
|
+
* or replaces it outright is unverified either way; this bridge no longer
|
|
793
|
+
* writes a competing file for opencode, so there is nothing for either
|
|
794
|
+
* reading to conflict with.
|
|
795
|
+
*
|
|
796
|
+
* RENAMED 2026-09-12 (brief 31, Move 3b): the key WRITTEN below is `glytch`
|
|
797
|
+
* (SERVER_KEY), not `board` - the verification above predates the rename
|
|
798
|
+
* and is kept dated rather than rerun, since it was checking the shape
|
|
799
|
+
* `opencode mcp add` produces, not the key name. */
|
|
800
|
+
export async function prepareWorkspace(agent, { mcpUrl, token, sessionKey, boardId }) {
|
|
801
|
+
const spec = AGENTS[agentFor(agent)];
|
|
802
|
+
if (!spec.needsBoardSetup) return { wrote: null };
|
|
803
|
+
if (!mcpUrl || !token) return { wrote: null };
|
|
804
|
+
|
|
805
|
+
const config = {
|
|
806
|
+
$schema: "https://opencode.ai/config.json",
|
|
807
|
+
mcp: {
|
|
808
|
+
[SERVER_KEY]: {
|
|
809
|
+
type: "remote",
|
|
810
|
+
url: mcpUrl,
|
|
811
|
+
enabled: true,
|
|
812
|
+
headers: {
|
|
813
|
+
Authorization: `Bearer ${token}`,
|
|
814
|
+
...(sessionKey ? { "X-Glytch-Session": sessionKey } : {}),
|
|
815
|
+
// THE RUN'S OWN BOARD (2026-09-21, board reporting fix) - same
|
|
816
|
+
// reasoning as acpServersFor's X-Glytch-Board above.
|
|
817
|
+
...(boardId ? { "X-Glytch-Board": boardId } : {}),
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
return { wrote: null, env: { OPENCODE_CONFIG_CONTENT: JSON.stringify(config) } };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** What every brief is prefixed with, in the tool names THAT engine uses.
|
|
826
|
+
*
|
|
827
|
+
* It was one fixed string naming `ask_user` and `report`. Claude Code answers
|
|
828
|
+
* to those; opencode namespaces its MCP tools as `board_ask_user` and
|
|
829
|
+
* `board_report`, so a session was being told to call things that did not
|
|
830
|
+
* exist. It said so - "my tool list doesn't include get_board" - and stopped,
|
|
831
|
+
* which was the correct response to a wrong instruction.
|
|
832
|
+
*
|
|
833
|
+
* A session cannot be MADE to ask or report. It can only be told, accurately,
|
|
834
|
+
* in the one place it always reads. */
|
|
835
|
+
export function preambleFor(agent, { workplace, computerApps } = {}) {
|
|
836
|
+
const spec = AGENTS[agentFor(agent)];
|
|
837
|
+
const ask = spec.boardTool("ask_user");
|
|
838
|
+
const report = spec.boardTool("report");
|
|
839
|
+
return (
|
|
840
|
+
"You were spawned from a canvas by a person who is not watching a terminal. " +
|
|
841
|
+
(workplace ?? "You are in your own git worktree: commit your work on this branch, never " +
|
|
842
|
+
"merge to main, and never touch the checkout above you.\n\n") +
|
|
843
|
+
`If a permission or anything else blocks you, ask on the board with ${ask} ` +
|
|
844
|
+
`rather than stopping quietly - it lands as a question on your idea that the person answers ` +
|
|
845
|
+
"on the canvas, and that is the only channel you have. Then, whatever happens, " +
|
|
846
|
+
// DATED REWRITE 2026-09-07 (brief 26, ruling 4, found still standing in
|
|
847
|
+
// the QA sweep of the runner): "a session that ends without reporting
|
|
848
|
+
// leaves a frame reading DONE with nothing in it". There is no frame and
|
|
849
|
+
// no DONE badge - the run's record rides the idea and its card
|
|
850
|
+
// reads it - so the sentence named a surface the person cannot see any
|
|
851
|
+
// more. What has not changed is the consequence, which is the reason the
|
|
852
|
+
// sentence exists at all: silence looks exactly like success.
|
|
853
|
+
`call ${report} before you finish, including when you did nothing: the ` +
|
|
854
|
+
"card on the person's idea just reads finished, and they cannot " +
|
|
855
|
+
"tell that apart from success.\n\n" +
|
|
856
|
+
`Your board tools are named ${spec.boardTool("get_board")}, ` +
|
|
857
|
+
`${spec.boardTool("get_node")}, ${spec.boardTool("propose")}, ${ask} and ${report}.\n\n` +
|
|
858
|
+
// brief 23 ruling 4a (2026-09-07): the app derives the run's structure
|
|
859
|
+
// - its steps, decisions and waiting state - from this session's own
|
|
860
|
+
// log, and this preamble is the one place every session reads. So the
|
|
861
|
+
// plan contract has to live here: propose a plan, keep it current with
|
|
862
|
+
// whatever tool tracks it, say the word when a choice is made, and ask
|
|
863
|
+
// rather than guess.
|
|
864
|
+
//
|
|
865
|
+
// SHARPENED 2026-09-07 (QA sweep of the runner): no run in the sweep
|
|
866
|
+
// showed any run steps. The app reads the plan out of the todo
|
|
867
|
+
// tool's own calls (src/folder/sessionLog/plan.ts), so a session that
|
|
868
|
+
// writes the plan in prose, or writes it after the work, shows no steps.
|
|
869
|
+
// The two bounds below are what the design's plan spine needs and what a
|
|
870
|
+
// session cannot guess: WHEN to write it (before the first edit) and HOW
|
|
871
|
+
// MANY (3 to 8 - fewer is not a plan, more is a transcript).
|
|
872
|
+
"Work to a plan, and keep it where the board can read it. Before your " +
|
|
873
|
+
"first edit, write a plan of 3 to 8 steps in the order you mean to do " +
|
|
874
|
+
"them, one line each.\n\n" +
|
|
875
|
+
todoSentenceFor(spec.todoTool) +
|
|
876
|
+
"Whenever you make a choice the person would want to know about, write " +
|
|
877
|
+
'one line beginning "Decided: " saying what you chose and why. One ' +
|
|
878
|
+
"sentence, your own words. Those lines are what the person reads " +
|
|
879
|
+
"instead of the transcript.\n\n" +
|
|
880
|
+
`If you are blocked on something only the person can rule on, ask with ${ask} rather than guessing.\n\n` +
|
|
881
|
+
// The switch exists for Claude Code and Codex only (#481), so only they
|
|
882
|
+
// are told how to ask for it.
|
|
883
|
+
(["claude", "codex"].includes(agentFor(agent)) && computerApps !== true ? computerAppsOffRule() : "") +
|
|
884
|
+
conversationRules(spec, ask)
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** The plan-tool sentence of preambleFor, split out because `todoTool` now
|
|
889
|
+
* comes in two shapes: a plain tool name (codex's `update_plan`, opencode's
|
|
890
|
+
* `todowrite`) or, since 2026-09-21, `{write, update, fallback}` for the
|
|
891
|
+
* `claude` and `acp-claude` specs - Claude Code 2.1.270 dropped TodoWrite and
|
|
892
|
+
* ships TaskCreate/TaskUpdate/TaskList/TaskGet instead (verified live; see
|
|
893
|
+
* the CLAUDE_TOOLS comment above). `fallback` names TodoWrite for an older
|
|
894
|
+
* Claude Code that still has it. */
|
|
895
|
+
function todoSentenceFor(todoTool) {
|
|
896
|
+
if (!todoTool) return "";
|
|
897
|
+
if (typeof todoTool === "string") {
|
|
898
|
+
return (
|
|
899
|
+
`Write that plan with the ${todoTool} tool, one todo per step, and ` +
|
|
900
|
+
"keep the statuses current as you go: exactly one step in progress " +
|
|
901
|
+
"at a time, and a step marked completed the moment it is done. The " +
|
|
902
|
+
"run screen shows that list as the run's steps.\n\n"
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
const { write, update, fallback } = todoTool;
|
|
906
|
+
return (
|
|
907
|
+
`Write that plan with ${write}, one task per step, in the order you ` +
|
|
908
|
+
`mean to do them. Keep the statuses current as you go with ${update}: ` +
|
|
909
|
+
"exactly one step in_progress at a time, and a step marked completed " +
|
|
910
|
+
`the moment it is done (on an older Claude Code with no ${write}, use ` +
|
|
911
|
+
`${fallback} instead). The run screen shows that list as the run's ` +
|
|
912
|
+
"steps.\n\n"
|
|
913
|
+
);
|
|
914
|
+
}
|