viewable-one 0.0.0-stage → 0.12.1

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