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/codex.mjs ADDED
@@ -0,0 +1,747 @@
1
+ import { writeRisk, riskReason } from "./writePolicy.mjs";
2
+ // Codex's native app-server protocol, translated to the board's session log.
3
+ // No SDK or API key is needed: this uses the user's installed, signed-in CLI.
4
+ import { PassThrough } from "node:stream";
5
+ import { readFile, readdir, unlink, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { attachChild, DEFAULTS } from "./child.mjs";
8
+ import { imageAttachments } from "./imageAttachments.mjs";
9
+ import { CODEX_TOOL_TIMEOUT_SEC } from "./mcpjson.mjs";
10
+ import { codexRpc, startCodex } from "./codex-rpc.mjs";
11
+ import { planWindowInfoMessage, readPlanWindow } from "./codexPlanWindow.mjs";
12
+ import { prepareCodexApps, verifyCodexApps } from "./codexApps.mjs";
13
+ import { secretLike, toolInputsText } from "./codexToolInputs.mjs";
14
+
15
+ // MOVED ONTO THE HOSTED ENDPOINT, 2026-09-11 (brief 30, W2a): the local
16
+ // stdio server this used to spawn as `mcp_servers.board`'s
17
+ // `command`/`args`/`env` is retired - every engine reaches the board
18
+ // over HTTP now (supabase/functions/board-mcp). Codex's own config schema has
19
+ // a streamable-HTTP shape for this (`url`, `http_headers`, and either
20
+ // `bearer_token` or `bearer_token_env_var`), checked directly against the
21
+ // installed codex binary rather than assumed: `codex mcp add --help` offers
22
+ // `--bearer-token-env-var` and says why in its own words - `strings` on the
23
+ // binary turns up the literal message "uses unsupported `bearer_token`; set
24
+ // `bearer_token_env_var`", which is codex refusing a LITERAL secret on this
25
+ // exact field and asking for an environment variable's NAME instead.
26
+ //
27
+ // So the bearer token itself never appears in this string - only the NAME of
28
+ // the env var the child was given it under (`tokenEnvVar`, `PM_BOARD_TOKEN`
29
+ // by default, set on the child's own environment by lib/runner.mjs's
30
+ // childEnv). `-c mcp_servers.glytch={...}` is still one argv entry, so
31
+ // `http_headers` (the session key, not a secret) is fine to embed directly -
32
+ // it is exactly as visible in `ps` as the tool allowlist already is for
33
+ // every engine.
34
+ //
35
+ // RENAMED 2026-09-12 (brief 31, Move 3, ruling 3): the key is `glytch`,
36
+ // matching the same key `.codex/config.toml`'s persisted entry now uses
37
+ // (lib/project-mcp.mjs) - a session spawned by the runner and a session a
38
+ // person connects by hand (`codex mcp login glytch`) now name the same
39
+ // server.
40
+ export function codexBoardArgs({ boardUrl, tokenEnvVar = "PM_BOARD_TOKEN", sessionKey, boardId } = {}) {
41
+ if (!boardUrl) return ["app-server"];
42
+ const fields = [
43
+ `url = ${JSON.stringify(boardUrl)}`,
44
+ `bearer_token_env_var = ${JSON.stringify(tokenEnvVar)}`,
45
+ // THE BOARD TOOLS MUST NOT NEED AN APPROVAL NOBODY CAN GIVE (2026-09-26,
46
+ // live run spawn-muiptqah-414f7a). codex-cli 0.153 gates every MCP
47
+ // tool call behind a per-server approval mode, and the thread runs
48
+ // with `approvalPolicy: "never"` (runCodexChild below) - so the board
49
+ // read, the question and the final report were all auto-REJECTED, and
50
+ // Codex finished its work with nothing drawn on the canvas. The server
51
+ // is ours, over our own bearer token; its tools are the run's one
52
+ // channel to the person, never something to prompt about. The key name
53
+ // is the one in codex's own config schema (`McpServerConfig.
54
+ // default_tools_approval_mode`, values auto/prompt/writes/approve).
55
+ // NOT "auto": in codex-rs's requires_mcp_tool_approval_for_mode, auto
56
+ // still asks for every tool that is not annotated read-only (ours carry
57
+ // no annotations, so all of them), and the second live run
58
+ // (spawn-muirjn8n-710207) was rejected the same way with auto set.
59
+ // "approve" is the one value that never asks.
60
+ `default_tools_approval_mode = "approve"`,
61
+ // A check_answer that waits for the person holds its call up to 50s;
62
+ // Codex's own default cuts a tool call at 60s, too close.
63
+ `tool_timeout_sec = ${CODEX_TOOL_TIMEOUT_SEC}`,
64
+ ];
65
+ // THE RUN'S OWN BOARD (2026-09-21, board reporting fix), both headers in
66
+ // ONE inline table: a second `http_headers = {...}` field would silently
67
+ // overwrite the first rather than merge with it, so X-Glytch-Session and
68
+ // X-Glytch-Board have to be built together - same reasoning as
69
+ // agents.mjs's acpServersFor for the other two engines.
70
+ const headerFields = [];
71
+ if (sessionKey) headerFields.push(`"X-Glytch-Session" = ${JSON.stringify(sessionKey)}`);
72
+ if (boardId) headerFields.push(`"X-Glytch-Board" = ${JSON.stringify(boardId)}`);
73
+ if (headerFields.length > 0) fields.push(`http_headers = {${headerFields.join(", ")}}`);
74
+ return ["app-server", "-c", `mcp_servers.glytch={${fields.join(", ")}}`];
75
+ }
76
+
77
+ /** Codex streams token-sized deltas and then sends the authoritative complete
78
+ * agent message. Board log readers treat each assistant event as complete, so
79
+ * hold deltas and publish one event when the item completes. */
80
+ export function agentMessageAssembler() {
81
+ const partial = new Map();
82
+ const completed = new Set();
83
+ return {
84
+ delta(id, text) {
85
+ if (typeof id !== "string" || completed.has(id)) return;
86
+ partial.set(id, `${partial.get(id) ?? ""}${typeof text === "string" ? text : ""}`);
87
+ },
88
+ complete(item) {
89
+ const id = item?.id;
90
+ if (typeof id !== "string" || completed.has(id)) return null;
91
+ completed.add(id);
92
+ const streamedText = partial.get(id) ?? "";
93
+ partial.delete(id);
94
+ return typeof item.text === "string" && item.text !== "" ? item.text : streamedText;
95
+ },
96
+ };
97
+ }
98
+
99
+ const inputFor = (text, images) => [
100
+ { type: "text", text },
101
+ ...imageAttachments(images).map(({ url }) => ({ type: "image", url })),
102
+ ];
103
+
104
+ // THE APPROVALS CODEX CAN ASK (#478), turned into the same question Claude's
105
+ // can_use_tool gives. Shell commands read as Bash and edits as Edit, so the
106
+ // words and the remembered rule are the same for both engines.
107
+ const ASKABLE_APPROVALS = new Set([
108
+ "item/commandExecution/requestApproval",
109
+ "item/fileChange/requestApproval",
110
+ "item/permissions/requestApproval",
111
+ ]);
112
+ function approvalAsAsk(method, params = {}) {
113
+ if (method === "item/commandExecution/requestApproval") {
114
+ return { toolName: "Bash", input: { command: String(params.command ?? ""), ...(params.reason ? { description: String(params.reason) } : {}) } };
115
+ }
116
+ if (method === "item/fileChange/requestApproval") {
117
+ return { toolName: "Edit", input: { file_path: String(params.grantRoot ?? ""), ...(params.reason ? { description: String(params.reason) } : {}) } };
118
+ }
119
+ return { toolName: "Permissions", input: { scopes: grantScopesOf(params.permissions), ...(params.reason ? { description: String(params.reason) } : {}) } };
120
+ }
121
+ // The scopes a grant asks for, so "Always allow" remembers exactly those
122
+ // (permissionAsk.mjs ruleKeyFor): "network", and "<access> <path>" per
123
+ // filesystem entry, the legacy read and write lists included.
124
+ const pathText = (path) => {
125
+ if (typeof path === "string") return path;
126
+ if (path?.type === "path") return String(path.path ?? "");
127
+ if (path?.type === "glob_pattern") return String(path.pattern ?? "");
128
+ if (path?.type === "special") return `<${path.value?.kind ?? "special"}>${path.value?.subpath ? ` ${path.value.subpath}` : ""}`;
129
+ return "";
130
+ };
131
+ function grantScopesOf(permissions = {}) {
132
+ const scopes = [];
133
+ if (permissions?.network?.enabled) scopes.push("network");
134
+ const fs = permissions?.fileSystem ?? {};
135
+ for (const entry of Array.isArray(fs.entries) ? fs.entries : []) scopes.push(`${entry?.access ?? "access"} ${pathText(entry?.path)}`);
136
+ for (const access of ["read", "write"]) for (const path of Array.isArray(fs[access]) ? fs[access] : []) scopes.push(`${access} ${pathText(path)}`);
137
+ return [...new Set(scopes)];
138
+ }
139
+ function approvalAnswer(method, params = {}, allowed) {
140
+ if (method === "item/permissions/requestApproval") {
141
+ return allowed ? { permissions: params.permissions ?? {}, scope: "turn" } : { permissions: {}, scope: "turn" };
142
+ }
143
+ return { decision: allowed ? "accept" : "decline" };
144
+ }
145
+
146
+ // Installed 0.162.1 ServerRequest.json uses item/tool/requestUserInput;
147
+ // current app-server documentation also calls it tool/requestUserInput.
148
+ const INPUT_REQUESTS = new Set(["item/tool/requestUserInput", "tool/requestUserInput"]);
149
+ const ELICITATION = "mcpServer/elicitation/request";
150
+ // Chat turns, plan drafts, automatic starts and the switch set off all land here.
151
+ export const APPS_OFF_INFO = "The computer's apps are off for this run, so Codex has none of their tools.";
152
+ // McpServerElicitationRequestParams in codex 0.162.1's generated schema: the
153
+ // two OpenAI form modes carry a schema typed only as JSON, so they get the
154
+ // same checks as "form".
155
+ const FORM_MODES = new Set(["form", "openai/form", "openaiForm"]);
156
+ // Schema keys that only describe the form. They are never forwarded.
157
+ const IGNORED_SCHEMA_KEYS = new Set(["$schema", "title", "description", "additionalProperties"]);
158
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
159
+ // A field id, title or choice that asks for a credential. Names only, not
160
+ // prose: approval for a tool called list_api_keys is still asked (#599).
161
+ const sensitive = (value) => /password|passphrase|secret|token|credential|api[ _-]?key|authorization|cookie|one[ -]?time|verification[ -]?code/i.test(value);
162
+ const shortText = (value) => typeof value === "string" && value.trim() !== "" && value.length <= 2000;
163
+ // Forwarded prose must not carry a credential value (codexToolInputs.mjs).
164
+ const prose = (value) => shortText(value) && !secretLike(value);
165
+ // The schema allows an empty header or description; it forwards nothing.
166
+ const optionalProse = (value) => value === "" || prose(value);
167
+ // One cancel word: every hosted question gets it, so no request offers its
168
+ // own "Cancel" beside it.
169
+ const CANCEL = "Cancel";
170
+ // The hosted question path accepts at most four options. Reserve one for
171
+ // cancellation so no arbitrary field value has to be recommended.
172
+ const choices = (values) => Array.isArray(values) && values.length > 0 && values.length <= 3 && values.every((value) => shortText(value) && !sensitive(value) && value !== CANCEL) && new Set(values).size === values.length;
173
+ const refuse = (reason) => ({ refused: reason });
174
+ // Names only, never values: what a refused request looked like (#599).
175
+ const keysOf = (value) => (object(value) && Object.keys(value).length ? Object.keys(value).slice(0, 20).map((key) => key.slice(0, 40)).join(",") : "none");
176
+ function refusalLine(method, params, reason) {
177
+ const mode = typeof params?.mode === "string" && /^[\w/.-]{1,40}$/.test(params.mode) ? params.mode : params?.mode == null ? "none" : "other";
178
+ return `Codex ${method} refused: ${reason}. mode=${mode} keys=${keysOf(params)} _meta=${keysOf(params?._meta)}` +
179
+ ` requestedSchema=${keysOf(params?.requestedSchema)} fields=${keysOf(params?.requestedSchema?.properties)}`;
180
+ }
181
+
182
+ // Extend the incumbent permission asker with exact choices, never its memory
183
+ // or free-text path. No arbitrary schema, defaults, metadata or credentials
184
+ // are forwarded to the board. Unsupported shapes return { refused: reason }.
185
+ function protocolQuestions(method, params) {
186
+ if (INPUT_REQUESTS.has(method)) {
187
+ if (!shortText(params.itemId)) return refuse("no item id");
188
+ if (!Array.isArray(params.questions) || !params.questions.length || params.questions.length > 8) return refuse("no questions, or more than 8");
189
+ const ids = new Set();
190
+ const questions = [];
191
+ for (const [index, q] of params.questions.entries()) {
192
+ const which = `question ${index + 1}`;
193
+ if (!shortText(q.id) || ids.has(q.id)) return refuse(`${which} has no id, or a repeated one`);
194
+ if (!prose(q.question) || (q.header != null && !optionalProse(q.header))) return refuse(`${which} has no text, or text shaped like a credential`);
195
+ if (q.isSecret || q.isOther) return refuse(`${which} asks for free text`);
196
+ if (sensitive(`${q.id} ${q.header ?? ""}`)) return refuse(`${which} asks for a credential`);
197
+ ids.add(q.id);
198
+ // The legacy MCP approval offers persistence. This bridge only offers
199
+ // the literal one-call Allow, never a remembered grant. Not this and
200
+ // the hosted Cancel both refuse the call without supplying an answer.
201
+ let options = q.options?.map((option) => option?.label).filter((label) => label !== CANCEL);
202
+ if (q.id.startsWith("mcp_tool_call_approval")) options = options?.filter((label) => label === "Allow");
203
+ if (!choices(options)) return refuse(`${which} has no bounded choices`);
204
+ if (q.id.startsWith("mcp_tool_call_approval")) options.push("Not this");
205
+ if (q.options.some((option) => !optionalProse(option.description))) return refuse(`${which} has a choice without a plain description`);
206
+ questions.push({ id: q.id, question: q.question, options,
207
+ why: `Choose an answer for this request only.\n${q.options.filter((option) => options.includes(option.label)).map((option) => `${option.label}: ${option.description}`).join("\n")}${q.id.startsWith("mcp_tool_call_approval") ? "\nNot this: Decline this app request." : ""}` });
208
+ }
209
+ return { questions };
210
+ }
211
+ if (!shortText(params.serverName)) return refuse("no server name");
212
+ if (!prose(params.message)) return refuse("no message, or a message shaped like a credential");
213
+ if (params._meta != null && !object(params._meta)) return refuse("_meta is not an object");
214
+ if (params._meta?.codex_approval_kind != null && params._meta.codex_approval_kind !== "mcp_tool_call") return refuse("approval kind is not mcp_tool_call");
215
+ if (params.mode === "url") {
216
+ // Signed URLs and OAuth callback parameters cannot be copied into hosted
217
+ // questions. A plain HTTPS page can be completed by the person directly.
218
+ let url;
219
+ try { url = new URL(params.url); } catch { return refuse("url does not parse"); }
220
+ if (!shortText(params.elicitationId)) return refuse("no elicitation id");
221
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !["/", "/connect", "/login", "/authorize", "/auth"].includes(url.pathname)) return refuse("url is not a plain https sign-in page");
222
+ return { questions: [{ question: `${params.serverName}: ${params.message}`, options: ["Completed", "Not this"],
223
+ why: `Open ${url.href} yourself. Choose Completed only after finishing there. Do not put passwords or codes in this question.` }] };
224
+ }
225
+ if (!FORM_MODES.has(params.mode)) return refuse("unsupported mode");
226
+ const schema = params.requestedSchema;
227
+ if (!object(schema) || schema.type !== "object" || !object(schema.properties)) return refuse("requestedSchema is not an object schema");
228
+ const extra = Object.keys(schema).find((key) => !["type", "properties", "required"].includes(key) && !IGNORED_SCHEMA_KEYS.has(key));
229
+ if (extra) return refuse(`unsupported schema key ${JSON.stringify(extra.slice(0, 40))}`);
230
+ const entries = Object.entries(schema.properties);
231
+ if (entries.length > 8) return refuse("more than 8 fields");
232
+ if (schema.required != null && (!Array.isArray(schema.required) || schema.required.some((key) => !Object.hasOwn(schema.properties, key)))) return refuse("required names an unknown field");
233
+ const questions = [];
234
+ for (const [id, field] of entries) {
235
+ const which = `field ${JSON.stringify(id.slice(0, 40))}`;
236
+ if (!shortText(id) || !object(field)) return refuse(`${which} is not a field`);
237
+ if (sensitive(`${id} ${field.title ?? ""}`)) return refuse(`${which} asks for a credential`);
238
+ if ((field.title != null && !prose(field.title)) || (field.description != null && !optionalProse(field.description))) return refuse(`${which} has a title or description that is not plain text`);
239
+ const key = Object.keys(field).find((k) => !["type", "title", "description", "enum", "oneOf", "enumNames", "default"].includes(k));
240
+ if (key) return refuse(`${which} has unsupported key ${JSON.stringify(key.slice(0, 40))}`);
241
+ const values = field.type === "boolean" ? [true, false] : field.type === "string" ? field.enum ?? field.oneOf?.map((option) => option.const) : null;
242
+ if (field.type === "boolean" && (field.enum || field.oneOf || field.enumNames)) return refuse(`${which} is a boolean with choices`);
243
+ if (field.type === "string" && ((field.enum && field.oneOf) ||
244
+ (field.enum && !field.enum.every((value) => typeof value === "string")) ||
245
+ (field.oneOf && !field.oneOf.every((option) => object(option) && typeof option.const === "string" && shortText(option.title) && Object.keys(option).every((k) => k === "const" || k === "title"))))) return refuse(`${which} has malformed choices`);
246
+ const labels = values?.map(String);
247
+ if (!choices(labels)) return refuse(`${which} is free text, or has more than 3 choices`);
248
+ questions.push({ id, values, question: `${params.serverName}: ${field.title ?? id}`, why: `${params.message}\n${field.description ?? ""}\nChoose an answer for this request only.`, options: labels });
249
+ }
250
+ // Accept is a separate, explicit per-call choice, including an empty form
251
+ // (the actual Codex MCP tool approval shape). No persistence metadata back.
252
+ // The tool's inputs come with it, short and with secrets hidden (#608).
253
+ const details = toolInputsText(params);
254
+ questions.push({ question: `${params.serverName}: ${params.message}`, options: ["Allow", "Not this"],
255
+ why: `On your computer, ${params.serverName}\n${riskReason(writeRisk(params.serverName.toLowerCase(), params.message, params._meta?.tool_params) ?? "open-ended")}\n${details ? `${details}\n\n` : ""}This answer applies only to this request.` });
256
+ return { questions };
257
+ }
258
+
259
+ export async function runCodexChild(opts) {
260
+ const isDraft = opts.codex?.planMode === "draft";
261
+ let apps;
262
+ try {
263
+ imageAttachments(opts.images);
264
+ apps = await prepareCodexApps(opts, { on: opts.codex?.computerApps === true, isDraft });
265
+ } catch (error) { return { ok: false, log: "", spawnError: error.message }; }
266
+ const startedAt = Date.now();
267
+ const maxMs = opts.maxMs ?? 60_000;
268
+ for (;;) {
269
+ const remainingMs = maxMs - (Date.now() - startedAt);
270
+ if (remainingMs <= 0) return { ok: false, log: "", spawnError: "Codex ran out of time checking apps." };
271
+ const { appsRetry: retry, ...outcome } = await runCodexAttempt({ ...opts, maxMs: remainingMs }, apps);
272
+ if (!retry || outcome.timedOut || outcome.stoppedByUser || outcome.stalled) return outcome;
273
+ apps = { args: [...apps.args, ...retry.args], sources: retry.sources, notices: [...apps.notices, ...retry.notices] };
274
+ }
275
+ }
276
+
277
+ function runCodexAttempt(opts, apps) {
278
+ return new Promise((resolve) => {
279
+ const startedAt = Date.now();
280
+ const config = opts.codex ?? {};
281
+ const isDraft = config.planMode === "draft";
282
+ let child;
283
+ // Custom CLI arguments still need the linked board and this session's
284
+ // attribution. Append our configuration last so stale project settings
285
+ // cannot silently point a worktree session at another board.
286
+ //
287
+ // A DRAFT GETS NO BOARD MCP SERVER, AT ALL (briefs/plan-mode-beyond-claude.md).
288
+ // Read-only sandboxing stops a shell write or a network call FROM A CHILD
289
+ // PROCESS, but an MCP tool call is answered by codex's own process over
290
+ // its own network stack - a sandbox flag never sees it. So the only
291
+ // enforceable stop is not giving the thread's process a board server to
292
+ // call in the first place: codexBoardArgs is skipped outright here,
293
+ // never merely told not to mutate anything.
294
+ // USE MY COMPUTER'S APPS (#481): Codex loads the person's own MCP
295
+ // servers from their config whatever we pass, so the switch is applied
296
+ // here, server by server, before the app server starts.
297
+ const appSources = apps.sources;
298
+ let appsRetry;
299
+ const start = async () => [...(opts.args ?? ["app-server"]), ...(isDraft ? [] : codexBoardArgs(config).slice(1)), ...apps.args];
300
+ start().then((args) => {
301
+ try { child = startCodex({ ...opts, args }); } catch (error) { resolve({ ok: false, log: "", spawnError: error.message }); return; }
302
+ drive();
303
+ }, (error) => resolve({ ok: false, log: "", spawnError: error.message }));
304
+ const drive = () => {
305
+ if (opts.onSpawn) child.once("spawn", () => opts.onSpawn());
306
+ const translated = new PassThrough();
307
+ let threadId = null;
308
+ let turnId = null;
309
+ let modelCalls = 0;
310
+ let ending = false;
311
+ let replyBusy = false;
312
+ let answerBusy = false;
313
+ let ownWordsPending = 0;
314
+ let finishError;
315
+ let closedInput = false;
316
+ // Approval questions go to the person only on a normal run that was given
317
+ // an asker. `waitingOnPerson` counts questions still open.
318
+ const asking = !isDraft && config.askPermissions === true && typeof opts.askPermission === "function";
319
+ let waitingOnPerson = 0;
320
+ // Each open question's abort, so Stop or the run ending ends its wait
321
+ // (permissionAsk.mjs stops polling check_answer when aborted).
322
+ const openQuestions = new Set();
323
+ const protocolRequests = new Map();
324
+ // Notifications can drain before codexRpc's queued request handler runs.
325
+ // Retain resolution by connection-scoped RPC ID, including that window.
326
+ const resolvedRequests = new Set();
327
+ const abortQuestions = () => { for (const controller of openQuestions) controller.abort(); };
328
+ let stopping = false;
329
+ const approvalsClosed = () => stopping || ending || handle.settled() || handle.state.timedOut || handle.state.stalled;
330
+ let rpc;
331
+ let latestUsage;
332
+ let planWindow = { primary: null, secondary: null, readAt: null };
333
+ let accountingUsage;
334
+ let contextWindow;
335
+ let finishTimer;
336
+ let lastAssistantText = "";
337
+ const opened = new Map();
338
+ const closed = new Set();
339
+ const messages = agentMessageAssembler();
340
+ const emit = (event) => { if (!handle.settled()) translated.write(`${JSON.stringify({ ...event, session_id: threadId })}\n`); };
341
+ const assistant = (content, extra = {}) => emit({ type: "assistant", message: { content, ...extra } });
342
+ const facade = { pid: child.pid, stdout: translated, stderr: child.stderr,
343
+ kill: (signal) => child.kill(signal), on(event, fn) { child.on(event, fn); return facade; } };
344
+ const handle = attachChild(facade, {
345
+ ...opts,
346
+ resolve: (outcome) => { clearTimeout(finishTimer); abortQuestions(); rpc?.close(); resolve({ ...outcome, ...(appsRetry ? { appsRetry } : {}) }); },
347
+ // Latch admission closed BEFORE SIGTERM. A still-running child may
348
+ // issue entirely new requests during the independent kill grace.
349
+ onWallClock: (state) => { stopping = true; state.timedOut = true; abortQuestions(); },
350
+ maxMs: opts.maxMs ?? 60_000,
351
+ okOf: (state) => state.sawResult && !state.resultWasError && !state.stoppedByUser && !state.timedOut && !state.stalled,
352
+ extraFields: (state) => ({ finished: state.sawResult, finishedWithError: state.resultWasError, stalled: state.stalled,
353
+ codexThreadId: threadId, accountingUsage, ...(contextWindow ? { contextWindow } : {}) }),
354
+ stopCheck: opts.stopCheck ? async () => {
355
+ if (stopping || !(await opts.stopCheck())) return false;
356
+ stopping = true; handle.state.stoppedByUser = true;
357
+ abortQuestions();
358
+ if (threadId && turnId) {
359
+ // Native interruption gets a bounded opportunity to persist the
360
+ // turn; the shared SIGTERM/SIGKILL path remains the backstop.
361
+ void rpc.request("turn/interrupt", { threadId, turnId }).catch(() => {});
362
+ await new Promise((done) => setTimeout(done, 150));
363
+ }
364
+ return !handle.settled();
365
+ } : undefined,
366
+ });
367
+ // A completion can arrive in the same stdout chunk as a steer reply.
368
+ // Drain the delivery acknowledgement before closing, so a late reply is
369
+ // restored before the runner writes its sidecar and promotes late files.
370
+ const closeTurn = () => {
371
+ if (!ending || replyBusy || answerBusy || ownWordsPending !== 0 || closedInput || handle.settled()) return;
372
+ closedInput = true;
373
+ const error = finishError;
374
+ if (!appsRetry) emit({ type: "result", subtype: error ? "error_during_execution" : "success", is_error: !!error,
375
+ ...(error ? { error: String(error), errors: [String(error)] } : {}),
376
+ ...(!error && lastAssistantText ? { result: lastAssistantText } : {}),
377
+ num_turns: Math.max(modelCalls, turnId ? 1 : 0), duration_ms: Date.now() - startedAt,
378
+ cost_source: "unavailable", ...(latestUsage ? { usage: latestUsage } : {}) });
379
+ child.stdin.end();
380
+ finishTimer = setTimeout(() => { if (!handle.settled()) handle.kill(); }, 500);
381
+ };
382
+ const finish = (error) => {
383
+ if (ending || handle.settled()) return;
384
+ ending = true;
385
+ abortQuestions();
386
+ finishError = error;
387
+ closeTurn();
388
+ };
389
+ const toolStart = (item) => {
390
+ if (closed.has(item.id)) return;
391
+ let name; let input;
392
+ if (item.type === "mcpToolCall") { name = `mcp__${item.server}__${item.tool}`; input = item.arguments; }
393
+ else if (item.type === "commandExecution") { name = "Bash"; input = { command: item.command }; }
394
+ else if (item.type === "fileChange") { name = "Edit"; input = { changes: item.changes }; }
395
+ else return;
396
+ const previous = opened.get(item.id);
397
+ const snapshot = JSON.stringify(input);
398
+ if (opened.has(item.id)) {
399
+ // File inputs can change after item/started. Keep the snapshot in
400
+ // the raw transcript without inventing another tool call (#925).
401
+ if (item.type === "fileChange" && previous !== snapshot) {
402
+ opened.set(item.id, snapshot);
403
+ emit({ type: "system", subtype: "tool_input", tool_use_id: item.id, name, input });
404
+ }
405
+ return;
406
+ }
407
+ opened.set(item.id, snapshot);
408
+ assistant([{ type: "tool_use", id: item.id, name, input }]);
409
+ };
410
+ rpc = codexRpc(child, {
411
+ timeoutMs: opts.rpcTimeoutMs ?? 15_000,
412
+ async onRequest(method, params, requestId) {
413
+ if (method === ELICITATION || INPUT_REQUESTS.has(method)) {
414
+ const cancelled = method === ELICITATION ? { action: "cancel", content: null } : { answers: {} };
415
+ const refused = method === ELICITATION ? { action: "decline", content: null } : { answers: {} };
416
+ const identityMatches = () => params.threadId === threadId && threadId !== null &&
417
+ (params.turnId == null ? method === ELICITATION : params.turnId === turnId);
418
+ if (resolvedRequests.has(requestId)) return cancelled;
419
+ let parsed;
420
+ try { parsed = protocolQuestions(method, params); } catch { parsed = refuse("request could not be read"); }
421
+ const reason = !asking ? "this run does not ask the person"
422
+ : config.computerApps !== true ? "the computer's apps are off for this run"
423
+ : approvalsClosed() ? "the run is ending"
424
+ : !identityMatches() ? "thread or turn does not match this run"
425
+ : method === ELICITATION && !appSources?.some((source) => source.enabled && source.name === params.serverName) ? "server is not an enabled app"
426
+ : parsed.refused;
427
+ if (reason) {
428
+ opts.log?.(refusalLine(method, params, reason));
429
+ emit({ type: "system", subtype: "permission", decision: "denied", message: "Codex requested input this run cannot safely ask for. No answer or permission was supplied." });
430
+ return refused;
431
+ }
432
+ const { questions } = parsed;
433
+ const controller = new AbortController();
434
+ openQuestions.add(controller);
435
+ protocolRequests.set(requestId, controller);
436
+ waitingOnPerson += 1;
437
+ const answers = [];
438
+ try {
439
+ for (const question of questions) {
440
+ if (controller.signal.aborted || approvalsClosed() || resolvedRequests.has(requestId) || !identityMatches()) return cancelled;
441
+ const appApproval = question.id?.startsWith("mcp_tool_call_approval") ||
442
+ (method === ELICITATION && question.id === undefined && question.options.includes("Allow"));
443
+ const hostedQuestion = { ...question, options: [...question.options, CANCEL],
444
+ recommendation: appApproval
445
+ ? { answer: "Allow", why: "It lets this run carry on with what you asked." }
446
+ : { answer: CANCEL, why: "Cancel unless you have checked this request and want to answer it." } };
447
+ let abort;
448
+ const aborted = new Promise((resolve) => {
449
+ abort = () => resolve(null);
450
+ controller.signal.addEventListener("abort", abort, { once: true });
451
+ });
452
+ let verdict;
453
+ try {
454
+ verdict = await Promise.race([aborted, Promise.resolve().then(() => opts.askPermission({ question: hostedQuestion, signal: controller.signal }))]);
455
+ } finally { controller.signal.removeEventListener("abort", abort); }
456
+ if (controller.signal.aborted || approvalsClosed() || resolvedRequests.has(requestId) || !identityMatches() || verdict?.behavior === "cancel" || verdict == null) return cancelled;
457
+ if (verdict.behavior === "answer" && verdict.answer === CANCEL) return cancelled;
458
+ if (verdict.behavior === "answer" && typeof verdict.answer === "string" && !verdict.answer.trim()) return refused;
459
+ if (appApproval && verdict.behavior === "answer" && verdict.answer === "Not this") return refused;
460
+ if ((method === ELICITATION || appApproval) && verdict.behavior === "answer" && typeof verdict.answer === "string" && !question.options.includes(verdict.answer)) {
461
+ // Declines have no protocol content. Use the incumbent live
462
+ // reply path so own words reach the agent, never grant a call.
463
+ ownWordsPending += 1;
464
+ try {
465
+ if (!(await deliver(`The person declined this app request: ${question.question}\nTheir answer: ${verdict.answer}`))) throw new Error("the turn is no longer active");
466
+ } catch (error) {
467
+ // Completion may already have begun while steer was in
468
+ // flight. Preserve its delivery failure before closing.
469
+ finishError ??= `Codex could not deliver your app answer: ${error.message}`;
470
+ finish(finishError);
471
+ } finally { ownWordsPending -= 1; closeTurn(); }
472
+ return refused;
473
+ }
474
+ if (verdict.behavior !== "answer" || !question.options.includes(verdict.answer)) return refused;
475
+ answers.push(verdict.answer);
476
+ }
477
+ if (INPUT_REQUESTS.has(method)) return { answers: Object.fromEntries(questions.map((question, i) => [question.id, { answers: [answers[i]] }])) };
478
+ if (answers.at(-1) === "Not this") return refused;
479
+ return { action: "accept", content: params.mode === "url" ? null : Object.fromEntries(questions.filter((q) => q.id !== undefined).map((q, i) => [q.id, q.values[q.options.indexOf(answers[i])]])) };
480
+ } catch (error) {
481
+ opts.log?.(`Codex ${method} refused: the question failed (${error.message})`);
482
+ return refused;
483
+ } finally {
484
+ controller.abort();
485
+ openQuestions.delete(controller);
486
+ protocolRequests.delete(requestId);
487
+ waitingOnPerson -= 1;
488
+ handle.state.lastStdoutAt = Date.now();
489
+ }
490
+ }
491
+ // ASK IN THE IDEA (#478): a normal run with a board to ask on files the
492
+ // question and returns the person's answer. A draft, or a run with no
493
+ // asker, keeps declining. While the person decides, the stall watcher
494
+ // stands down (waitingOnPerson).
495
+ if (asking && ASKABLE_APPROVALS.has(method) && !approvalsClosed() && !resolvedRequests.has(requestId)) {
496
+ const call = approvalAsAsk(method, params);
497
+ const controller = new AbortController();
498
+ openQuestions.add(controller);
499
+ protocolRequests.set(requestId, controller);
500
+ waitingOnPerson += 1;
501
+ let verdict = null;
502
+ try { verdict = await opts.askPermission({ toolName: call.toolName, input: call.input, signal: controller.signal }); }
503
+ catch (error) { opts.log?.(`permission question failed: ${error.message}`); }
504
+ finally { openQuestions.delete(controller); protocolRequests.delete(requestId); waitingOnPerson -= 1; handle.state.lastStdoutAt = Date.now(); }
505
+ const allowed = !controller.signal.aborted && !approvalsClosed() && !resolvedRequests.has(requestId) && verdict?.behavior === "allow";
506
+ emit({ type: "system", subtype: "permission", decision: allowed ? "allowed" : "denied", message: allowed ? `${method}: the person allowed it.` : `${method}: ${verdict?.message ?? "the person said no, or did not answer."}` });
507
+ return approvalAnswer(method, params, allowed);
508
+ }
509
+ emit({ type: "system", subtype: "permission", decision: "denied", message: `${method}: use board ask_user for human decisions; this run cannot approve sandbox escalation.` });
510
+ if (ASKABLE_APPROVALS.has(method)) return approvalAnswer(method, params, false);
511
+ if (method.endsWith("/requestApproval")) return { decision: "decline" };
512
+ // RENAMED 2026-09-12 (brief 31, Move 3b): codexBoardArgs above
513
+ // registers the server as `mcp_servers.glytch`, so that is the tool
514
+ // name codex's own namespace actually answers to.
515
+ throw new Error(`Unsupported Codex request ${method}; ask with mcp__glytch__ask_user.`);
516
+ },
517
+ onNotification(method, params) {
518
+ if (handle.settled() || (threadId && params.threadId && params.threadId !== threadId)) return;
519
+ // Every protocol event proves liveness, including long-running tools.
520
+ handle.state.lastStdoutAt = Date.now();
521
+ if (method === "serverRequest/resolved" && params.threadId === threadId) {
522
+ resolvedRequests.add(params.requestId);
523
+ protocolRequests.get(params.requestId)?.abort();
524
+ }
525
+ if (method === "turn/started") {
526
+ if (turnId && params.turn?.id && turnId !== params.turn.id) abortQuestions();
527
+ turnId = params.turn?.id ?? turnId;
528
+ }
529
+ if (method === "item/agentMessage/delta") {
530
+ messages.delta(params.itemId, params.delta);
531
+ } else if (method === "item/reasoning/summaryTextDelta") {
532
+ assistant([{ type: "thinking", thinking: params.delta ?? "" }]);
533
+ } else if (method === "turn/plan/updated") {
534
+ const id = `codex-plan-${Date.now()}`;
535
+ assistant([{ type: "tool_use", id, name: "TodoWrite", input: { todos: (params.plan ?? []).map((p) => ({ content: p.step, status: p.status === "inProgress" ? "in_progress" : p.status })) } }]);
536
+ emit({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: id, content: "Plan updated" }] } });
537
+ } else if (method === "thread/tokenUsage/updated") {
538
+ // One usage update per model response: the count Claude Code calls
539
+ // turns (QA 2026-10-01, e4 - Codex always read "1 turn").
540
+ modelCalls += 1;
541
+ const usage = params.tokenUsage?.last;
542
+ if (params.tokenUsage?.total) accountingUsage = params.tokenUsage.total;
543
+ contextWindow = params.tokenUsage?.modelContextWindow;
544
+ if (usage) {
545
+ // inputTokens already INCLUDES cachedInputTokens. The shared
546
+ // context scanner adds caches, so do not count them twice.
547
+ latestUsage = { input_tokens: usage.inputTokens, output_tokens: usage.outputTokens };
548
+ assistant([], { usage: latestUsage, usage_is_snapshot: true,
549
+ total_usage: params.tokenUsage.total ? { input_tokens: params.tokenUsage.total.inputTokens, output_tokens: params.tokenUsage.total.outputTokens } : latestUsage });
550
+ }
551
+ } else if (method === "item/started") toolStart(params.item ?? {});
552
+ else if (method === "item/fileChange/patchUpdated") {
553
+ toolStart({ type: "fileChange", id: params.itemId, changes: params.changes });
554
+ } else if (method === "item/completed") {
555
+ const item = params.item ?? {};
556
+ if (item.type === "agentMessage") {
557
+ const text = messages.complete(item);
558
+ if (text !== null) {
559
+ lastAssistantText = text;
560
+ assistant([{ type: "text", text }]);
561
+ }
562
+ }
563
+ toolStart(item);
564
+ closed.add(item.id);
565
+ if (opened.delete(item.id)) emit({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: item.id,
566
+ is_error: item.status === "failed" || item.status === "declined" || item.error != null || (item.exitCode != null && item.exitCode !== 0),
567
+ content: item.result?.content ?? item.aggregatedOutput ?? item.error?.message ?? "Completed" }] } });
568
+ } else if (method === "turn/completed") {
569
+ turnId = params.turn?.id ?? turnId;
570
+ finish(params.turn?.status === "completed" ? null : params.turn?.error?.message ?? `Codex turn ${params.turn?.status ?? "failed"}`);
571
+ } else if (method === "error" && params.willRetry !== true) finish(params.error?.message ?? "Codex failed");
572
+ },
573
+ });
574
+ const deliver = async (text, images) => {
575
+ if (!threadId || !turnId || ending || stopping || handle.settled()) return false;
576
+ await rpc.request("turn/steer", { threadId, expectedTurnId: turnId, input: inputFor(text, images) });
577
+ emit({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
578
+ return true;
579
+ };
580
+ if (opts.replyDir && opts.replyPrefix) handle.every(async () => {
581
+ if (replyBusy || !turnId || ending || stopping || handle.settled()) return;
582
+ replyBusy = true;
583
+ try {
584
+ const files = (await readdir(opts.replyDir)).filter((f) => f.startsWith(opts.replyPrefix) && f.endsWith(".json")).sort();
585
+ for (const file of files) {
586
+ if (ending || stopping || handle.settled()) break;
587
+ let reply;
588
+ const path = join(opts.replyDir, file);
589
+ try { reply = JSON.parse(await readFile(path, "utf8")); } catch { continue; }
590
+ if (typeof reply.message !== "string") continue;
591
+ try { imageAttachments(reply.images); } catch (error) {
592
+ finish(`Reference image reply was not delivered: ${error.message}`);
593
+ return;
594
+ }
595
+ // Consume once before sending, matching the existing runner. A
596
+ // rejected steer becomes a resumable reply; never replay blindly.
597
+ try { await unlink(path); } catch { continue; }
598
+ try {
599
+ const text = reply.from ? `[message from ${reply.from}] ${reply.message}` : reply.message;
600
+ if (!(await deliver(text, reply.images))) await writeFile(path, JSON.stringify(reply), { flag: "wx" });
601
+ } catch (error) {
602
+ emit({ type: "system", subtype: "error", message: `Reply delivery failed: ${error.message}` });
603
+ // Only a definite active-turn rejection is safe to promote.
604
+ if (/turn.*(not found|not active|mismatch|completed)|no active turn/i.test(error.message)) {
605
+ await writeFile(path, JSON.stringify(reply), { flag: "wx" });
606
+ } else {
607
+ finish(`Codex could not confirm delivery of your reply: ${error.message}`);
608
+ }
609
+ }
610
+ }
611
+ } catch { /* directory may disappear while disconnecting */ }
612
+ finally { replyBusy = false; closeTurn(); }
613
+ }, opts.replyPollMs ?? 2000);
614
+ const collectAnswers = async () => {
615
+ if (!opts.answerPoll) return;
616
+ if (answerBusy || ending || !turnId || stopping || handle.settled()) return;
617
+ answerBusy = true;
618
+ try { for (const answer of await opts.answerPoll()) if (await deliver(answer.text)) await answer.onDelivered?.(); }
619
+ catch { /* a failed delivery is not an acknowledged answer */ }
620
+ finally { answerBusy = false; closeTurn(); }
621
+ };
622
+ if (opts.answerPoll) {
623
+ handle.every(collectAnswers, opts.answerPollMs ?? 4000);
624
+ opts.answerPoll.subscribe?.(collectAnswers);
625
+ }
626
+ const stallMs = opts.stallMs ?? DEFAULTS.stallMs;
627
+ handle.every(() => {
628
+ if (!ending && waitingOnPerson === 0 && handle.state.openToolUses.size === 0 && Date.now() - handle.state.lastStdoutAt >= stallMs) {
629
+ handle.state.stalled = true; handle.kill();
630
+ }
631
+ }, Math.min(10_000, stallMs / 4));
632
+ void (async () => {
633
+ await rpc.initialize();
634
+ // ONE EXTRA CALL, ON THE SAME CONNECTION, BEFORE ANY BILLABLE TURN
635
+ // (brief 46, Move 4). Codex reports no dollars; what it reports
636
+ // instead is how full its 5-hour and weekly windows are
637
+ // (`account/rateLimits/read`). Read once here for the info line a
638
+ // person reads instead of a cost figure. It stops nothing: a Codex
639
+ // run no longer stands down at a percentage (#170, #338).
640
+ planWindow = await readPlanWindow(rpc, { log: opts.log });
641
+ const account = await rpc.request("account/read", { refreshToken: false });
642
+ if (account.account == null && account.requiresOpenaiAuth !== false) throw new Error("Codex is not signed in. Run `codex login` and try again.");
643
+ if (!planWindow.primary && !planWindow.secondary) opts.log?.("Codex did not report its plan window.");
644
+ // Check the account's catalog before starting any billable turn. A
645
+ // stale saved model/effort must fail explicitly, never be substituted.
646
+ const catalog = [];
647
+ let cursor;
648
+ do {
649
+ const page = await rpc.request("model/list", { ...(cursor ? { cursor } : {}), limit: 100 });
650
+ catalog.push(...(page.data ?? []));
651
+ cursor = page.nextCursor;
652
+ } while (cursor && catalog.length < 200);
653
+ const selected = config.model ? catalog.find((m) => m.model === config.model) : catalog.find((m) => m.isDefault);
654
+ if (config.model && !selected) throw new Error(`Codex model "${config.model}" is not available in this account. Choose a model from the current Codex catalog.`);
655
+ if (config.effort && (!selected || !(selected.supportedReasoningEfforts ?? []).some((e) => e.reasoningEffort === config.effort))) {
656
+ throw new Error(`Codex effort "${config.effort}" is not supported by the selected model. Choose a supported effort or Default.`);
657
+ }
658
+ // DRAFT: read-only sandbox, no resume, no persisted session.
659
+ //
660
+ // `sandbox: "read-only"` is checked against the installed codex-cli
661
+ // 0.153.4's own generated protocol schema (`codex app-server
662
+ // generate-json-schema --out <dir>`, definitions.SandboxPolicy): the
663
+ // `ReadOnlySandboxPolicy` variant defaults `networkAccess: false`
664
+ // alongside blocking writes, so this is an OS-level seatbelt/landlock
665
+ // restriction on top of the CLI flag being named "read-only" - not an
666
+ // assumption that the label implies both. `approvalPolicy: "never"`
667
+ // means a blocked write or network attempt is returned to the model as
668
+ // a failure and never escalated into an approval prompt this runner
669
+ // would have to answer (the onRequest handler above declines any that
670
+ // somehow arrive anyway). `ephemeral: true` is codex's own "no session
671
+ // files persisted to disk" (matches `codex exec --ephemeral`'s
672
+ // documented meaning), which is this engine's version of "no
673
+ // persistent session" - and resume is never requested for a draft
674
+ // regardless of what a caller passed, so a draft can never inherit an
675
+ // implementation thread's history or permissions.
676
+ const thread = await rpc.request(!isDraft && config.resumeSessionId ? "thread/resume" : "thread/start", {
677
+ ...(!isDraft && config.resumeSessionId ? { threadId: config.resumeSessionId } : {}),
678
+ cwd: opts.cwd, approvalPolicy: asking ? "on-request" : "never", sandbox: isDraft ? "read-only" : "workspace-write",
679
+ ...(isDraft ? { ephemeral: true } : {}),
680
+ ...(config.model ? { model: config.model } : {}),
681
+ // The extra writable root for `<boardRoot>/.glytch` retired with it
682
+ // (2026-09-11, brief 30 W2a): it existed so the locally-spawned
683
+ // local stdio server - itself a child of this sandboxed process,
684
+ // back when the board was a `command`/`args` MCP entry - could
685
+ // write ops files onto disk. The board is an HTTPS call now, made by codex itself
686
+ // rather than by a filesystem-writing subprocess, so there is nothing
687
+ // under `.glytch` for this sandbox to need write access to any more.
688
+ config: {
689
+ ...(isDraft ? {} : { "sandbox_workspace_write.writable_roots": [opts.cwd].filter(Boolean) }),
690
+ ...(config.effort ? { model_reasoning_effort: config.effort } : {}),
691
+ // THE PLAN TOOL, SWITCHED ON (2026-09-26, the same live run): the
692
+ // preamble tells a Codex session to plan with `update_plan`, and
693
+ // the run screen shows steps from that tool's calls - but
694
+ // the app-server thread does not carry it unless asked, so the
695
+ // session wrote "update_plan is unavailable" and planned in prose,
696
+ // which shows no steps. `tools.update_plan` is codex's own config
697
+ // key for it (ToolsToml: web_search, experimental_request_user_input,
698
+ // update_plan). It is a STRUCT, not a flag: codex-rs's
699
+ // UpdatePlanToolConfig is `{ enabled: bool }` with unknown fields
700
+ // rejected, and a bare `true` made thread/start fail outright
701
+ // ("invalid type: boolean `true`, expected struct
702
+ // UpdatePlanToolConfig", live run spawn-muir3xjh-94fa5c).
703
+ "tools.update_plan": { enabled: true },
704
+ // WEB SEARCH FOR A RUN ON THIS COMPUTER (#478): `web_search` is
705
+ // codex's own top-level config key (WebSearchMode: disabled |
706
+ // cached | indexed | live, checked against codex-cli 0.162's
707
+ // generated protocol schema). Live only when asked for; otherwise
708
+ // set to disabled, so a ~/.codex/config.toml that turns live
709
+ // search on cannot search past the promised question (box review
710
+ // of PR 504). A plan draft never searches.
711
+ web_search: !isDraft && config.webSearch === true ? "live" : "disabled",
712
+ },
713
+ });
714
+ threadId = thread.thread?.id;
715
+ if (!threadId) throw new Error("Codex did not return a thread id");
716
+ await opts.onSession?.(threadId);
717
+ const checkedApps = await verifyCodexApps(rpc, { cwd: opts.cwd, threadId, on: config.computerApps === true, isDraft, sources: appSources });
718
+ if (checkedApps.notices.length) {
719
+ appsRetry = checkedApps;
720
+ // Reap the child through the existing bounded shutdown before retrying.
721
+ finish("Checking apps again with the unverified apps turned off.");
722
+ return;
723
+ }
724
+ for (const message of apps.notices) emit({ type: "system", subtype: "info", message });
725
+ emit({ type: "system", subtype: "init", model: thread.model ?? config.model, effort: thread.reasoningEffort ?? config.effort, engine: "codex" });
726
+ emit({ type: "system", subtype: "info", message: planWindowInfoMessage(planWindow) });
727
+ // An apps-off run looks like missing tools from the transcript alone:
728
+ // the model searches, finds none and says so (#2366, a chat turn).
729
+ if (config.computerApps !== true) emit({ type: "system", subtype: "info", message: APPS_OFF_INFO });
730
+ handle.markSent();
731
+ // THE ECHO IS THE PERSON'S OWN WORDS, NOT THE WHOLE BRIEF (2026-09-26):
732
+ // `opts.prompt` is the preamble plus the repo primer plus what they
733
+ // typed, and the expanded run drew all of it as the first transcript
734
+ // line - a wall of instructions above a two-line request. The runner
735
+ // hands the typed part separately as `opts.brief`; the child still
736
+ // receives the full prompt below. The role is what makes it the
737
+ // person's: the app reads a user event without one as the agent
738
+ // talking, and a chat streamed its instructions into the reply (#702).
739
+ emit({ type: "user", message: { role: "user", content: [{ type: "text", text: opts.brief ?? opts.prompt }] } });
740
+ const result = await rpc.request("turn/start", { threadId, input: inputFor(opts.prompt, opts.images),
741
+ ...(config.outputSchema ? { outputSchema: config.outputSchema } : {}),
742
+ ...(config.model ? { model: config.model } : {}), ...(config.effort ? { effort: config.effort } : {}) });
743
+ turnId = result.turn?.id ?? turnId;
744
+ })().catch((error) => finish(error.message));
745
+ };
746
+ });
747
+ }