viewable-one 0.0.0-stage → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1483 -0
- package/LICENSE +21 -0
- package/README.md +738 -2
- package/SPAWN.md +2108 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +1018 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +124 -0
- package/lib/agents.mjs +914 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1491 -0
- package/lib/authenticatedHttp.mjs +75 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1292 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +767 -0
- package/lib/codexApps.mjs +344 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +629 -0
- package/lib/devserver.mjs +670 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +173 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +70 -0
- package/lib/paths.mjs +10 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +130 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +323 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +388 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1640 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +145 -0
- package/lib/runnerWorkspace.mjs +177 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +589 -0
- package/lib/uninstall.mjs +464 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/viewableFolder.mjs +105 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1142 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- package/spawn-runner.mjs +167 -0
package/lib/codex.mjs
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
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
|
+
if (stopping || handle.settled()) return false;
|
|
578
|
+
emit({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
|
|
579
|
+
return true;
|
|
580
|
+
};
|
|
581
|
+
if (opts.replyDir && opts.replyPrefix) handle.every(async () => {
|
|
582
|
+
if (replyBusy || !turnId || ending || stopping || handle.settled()) return;
|
|
583
|
+
replyBusy = true;
|
|
584
|
+
try {
|
|
585
|
+
const files = (await readdir(opts.replyDir)).filter((f) => f.startsWith(opts.replyPrefix) && f.endsWith(".json")).sort();
|
|
586
|
+
for (const file of files) {
|
|
587
|
+
if (ending || stopping || handle.settled()) break;
|
|
588
|
+
let reply;
|
|
589
|
+
const path = join(opts.replyDir, file);
|
|
590
|
+
try { reply = JSON.parse(await readFile(path, "utf8")); } catch { continue; }
|
|
591
|
+
if (typeof reply.message !== "string") continue;
|
|
592
|
+
try { imageAttachments(reply.images); } catch (error) {
|
|
593
|
+
finish(`Reference image reply was not delivered: ${error.message}`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
// Consume once before sending, matching the existing runner. A
|
|
597
|
+
// rejected steer becomes a resumable reply; never replay blindly.
|
|
598
|
+
try { await unlink(path); } catch { continue; }
|
|
599
|
+
try {
|
|
600
|
+
const text = reply.from ? `[message from ${reply.from}] ${reply.message}` : reply.message;
|
|
601
|
+
if (!(await deliver(text, reply.images))) await writeFile(path, JSON.stringify(reply), { flag: "wx" });
|
|
602
|
+
} catch (error) {
|
|
603
|
+
emit({ type: "system", subtype: "error", message: `Reply delivery failed: ${error.message}` });
|
|
604
|
+
// Only a definite active-turn rejection is safe to promote.
|
|
605
|
+
if (/turn.*(not found|not active|mismatch|completed)|no active turn/i.test(error.message)) {
|
|
606
|
+
await writeFile(path, JSON.stringify(reply), { flag: "wx" });
|
|
607
|
+
} else {
|
|
608
|
+
finish(`Codex could not confirm delivery of your reply: ${error.message}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
} catch { /* directory may disappear while disconnecting */ }
|
|
613
|
+
finally { replyBusy = false; closeTurn(); }
|
|
614
|
+
}, opts.replyPollMs ?? 2000);
|
|
615
|
+
const collectAnswers = async () => {
|
|
616
|
+
if (!opts.answerPoll) return;
|
|
617
|
+
if (answerBusy || ending || !turnId || stopping || handle.settled()) return;
|
|
618
|
+
answerBusy = true;
|
|
619
|
+
try {
|
|
620
|
+
for (const answer of await opts.answerPoll()) {
|
|
621
|
+
if (ending || stopping || handle.settled()) break;
|
|
622
|
+
try {
|
|
623
|
+
if (await deliver(answer.text, answer.images)) await answer.onDelivered?.();
|
|
624
|
+
} catch {
|
|
625
|
+
// Keep the board row unacknowledged for the existing late sweep.
|
|
626
|
+
// Never echo adapter errors, which can contain credentials.
|
|
627
|
+
finishError ??= "Codex could not confirm delivery of your board reply.";
|
|
628
|
+
finish(finishError);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
catch { /* a failed poll is not an acknowledged answer */ }
|
|
634
|
+
finally { answerBusy = false; closeTurn(); }
|
|
635
|
+
};
|
|
636
|
+
if (opts.answerPoll) {
|
|
637
|
+
handle.every(collectAnswers, opts.answerPollMs ?? 4000);
|
|
638
|
+
opts.answerPoll.subscribe?.(collectAnswers);
|
|
639
|
+
}
|
|
640
|
+
const stallMs = opts.stallMs ?? DEFAULTS.stallMs;
|
|
641
|
+
handle.every(() => {
|
|
642
|
+
if (!ending && waitingOnPerson === 0 && handle.state.openToolUses.size === 0 && Date.now() - handle.state.lastStdoutAt >= stallMs) {
|
|
643
|
+
handle.state.stalled = true; handle.kill();
|
|
644
|
+
}
|
|
645
|
+
}, Math.min(10_000, stallMs / 4));
|
|
646
|
+
void (async () => {
|
|
647
|
+
await rpc.initialize();
|
|
648
|
+
// ONE EXTRA CALL, ON THE SAME CONNECTION, BEFORE ANY BILLABLE TURN
|
|
649
|
+
// (brief 46, Move 4). Codex reports no dollars; what it reports
|
|
650
|
+
// instead is how full its 5-hour and weekly windows are
|
|
651
|
+
// (`account/rateLimits/read`). Read once here for the info line a
|
|
652
|
+
// person reads instead of a cost figure. It stops nothing: a Codex
|
|
653
|
+
// run no longer stands down at a percentage (#170, #338).
|
|
654
|
+
planWindow = await readPlanWindow(rpc, { log: opts.log });
|
|
655
|
+
const account = await rpc.request("account/read", { refreshToken: false });
|
|
656
|
+
if (account.account == null && account.requiresOpenaiAuth !== false) throw new Error("Codex is not signed in. Run `codex login` and try again.");
|
|
657
|
+
if (!planWindow.primary && !planWindow.secondary) opts.log?.("Codex did not report its plan window.");
|
|
658
|
+
// Check the account's catalog before starting any billable turn. A
|
|
659
|
+
// stale saved model/effort must fail explicitly, never be substituted.
|
|
660
|
+
const catalog = [];
|
|
661
|
+
let cursor;
|
|
662
|
+
do {
|
|
663
|
+
const page = await rpc.request("model/list", { ...(cursor ? { cursor } : {}), limit: 100 });
|
|
664
|
+
catalog.push(...(page.data ?? []));
|
|
665
|
+
cursor = page.nextCursor;
|
|
666
|
+
} while (cursor && catalog.length < 200);
|
|
667
|
+
const selected = config.model ? catalog.find((m) => m.model === config.model) : catalog.find((m) => m.isDefault);
|
|
668
|
+
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.`);
|
|
669
|
+
if (config.effort && (!selected || !(selected.supportedReasoningEfforts ?? []).some((e) => e.reasoningEffort === config.effort))) {
|
|
670
|
+
throw new Error(`Codex effort "${config.effort}" is not supported by the selected model. Choose a supported effort or Default.`);
|
|
671
|
+
}
|
|
672
|
+
// DRAFT: read-only sandbox, no resume, no persisted session.
|
|
673
|
+
//
|
|
674
|
+
// `sandbox: "read-only"` is checked against the installed codex-cli
|
|
675
|
+
// 0.153.4's own generated protocol schema (`codex app-server
|
|
676
|
+
// generate-json-schema --out <dir>`, definitions.SandboxPolicy): the
|
|
677
|
+
// `ReadOnlySandboxPolicy` variant defaults `networkAccess: false`
|
|
678
|
+
// alongside blocking writes, so this is an OS-level seatbelt/landlock
|
|
679
|
+
// restriction on top of the CLI flag being named "read-only" - not an
|
|
680
|
+
// assumption that the label implies both. `approvalPolicy: "never"`
|
|
681
|
+
// means a blocked write or network attempt is returned to the model as
|
|
682
|
+
// a failure and never escalated into an approval prompt this runner
|
|
683
|
+
// would have to answer (the onRequest handler above declines any that
|
|
684
|
+
// somehow arrive anyway). `ephemeral: true` is codex's own "no session
|
|
685
|
+
// files persisted to disk" (matches `codex exec --ephemeral`'s
|
|
686
|
+
// documented meaning), which is this engine's version of "no
|
|
687
|
+
// persistent session" - and resume is never requested for a draft
|
|
688
|
+
// regardless of what a caller passed, so a draft can never inherit an
|
|
689
|
+
// implementation thread's history or permissions.
|
|
690
|
+
const thread = await rpc.request(!isDraft && config.resumeSessionId ? "thread/resume" : "thread/start", {
|
|
691
|
+
...(!isDraft && config.resumeSessionId ? { threadId: config.resumeSessionId } : {}),
|
|
692
|
+
cwd: opts.cwd, approvalPolicy: asking ? "on-request" : "never", sandbox: isDraft ? "read-only" : "workspace-write",
|
|
693
|
+
...(isDraft ? { ephemeral: true } : {}),
|
|
694
|
+
...(config.model ? { model: config.model } : {}),
|
|
695
|
+
// The extra writable root for `<boardRoot>/.glytch` retired with it
|
|
696
|
+
// (2026-09-11, brief 30 W2a): it existed so the locally-spawned
|
|
697
|
+
// local stdio server - itself a child of this sandboxed process,
|
|
698
|
+
// back when the board was a `command`/`args` MCP entry - could
|
|
699
|
+
// write ops files onto disk. The board is an HTTPS call now, made by codex itself
|
|
700
|
+
// rather than by a filesystem-writing subprocess, so there is nothing
|
|
701
|
+
// under `.glytch` for this sandbox to need write access to any more.
|
|
702
|
+
config: {
|
|
703
|
+
...(isDraft ? {} : { "sandbox_workspace_write.writable_roots": [opts.cwd].filter(Boolean) }),
|
|
704
|
+
...(config.effort ? { model_reasoning_effort: config.effort } : {}),
|
|
705
|
+
// THE PLAN TOOL, SWITCHED ON (2026-09-26, the same live run): the
|
|
706
|
+
// preamble tells a Codex session to plan with `update_plan`, and
|
|
707
|
+
// the run screen shows steps from that tool's calls - but
|
|
708
|
+
// the app-server thread does not carry it unless asked, so the
|
|
709
|
+
// session wrote "update_plan is unavailable" and planned in prose,
|
|
710
|
+
// which shows no steps. `tools.update_plan` is codex's own config
|
|
711
|
+
// key for it (ToolsToml: web_search, experimental_request_user_input,
|
|
712
|
+
// update_plan). It is a STRUCT, not a flag: codex-rs's
|
|
713
|
+
// UpdatePlanToolConfig is `{ enabled: bool }` with unknown fields
|
|
714
|
+
// rejected, and a bare `true` made thread/start fail outright
|
|
715
|
+
// ("invalid type: boolean `true`, expected struct
|
|
716
|
+
// UpdatePlanToolConfig", live run spawn-muir3xjh-94fa5c).
|
|
717
|
+
"tools.update_plan": { enabled: true },
|
|
718
|
+
// WEB SEARCH FOR A RUN ON THIS COMPUTER (#478): `web_search` is
|
|
719
|
+
// codex's own top-level config key (WebSearchMode: disabled |
|
|
720
|
+
// cached | indexed | live, checked against codex-cli 0.162's
|
|
721
|
+
// generated protocol schema). Live only when asked for; otherwise
|
|
722
|
+
// set to disabled, so a ~/.codex/config.toml that turns live
|
|
723
|
+
// search on cannot search past the promised question (box review
|
|
724
|
+
// of PR 504). A plan draft never searches.
|
|
725
|
+
web_search: !isDraft && config.webSearch === true ? "live" : "disabled",
|
|
726
|
+
},
|
|
727
|
+
}).catch((error) => {
|
|
728
|
+
if (!isDraft && config.resumeSessionId) {
|
|
729
|
+
opts.log?.(`Codex thread/resume failed: ${error.message}`);
|
|
730
|
+
throw new Error("This Codex session could not continue on this computer. Run again to start a new session.");
|
|
731
|
+
}
|
|
732
|
+
throw error;
|
|
733
|
+
});
|
|
734
|
+
threadId = thread.thread?.id;
|
|
735
|
+
if (!threadId) throw new Error("Codex did not return a thread id");
|
|
736
|
+
await opts.onSession?.(threadId);
|
|
737
|
+
const checkedApps = await verifyCodexApps(rpc, { cwd: opts.cwd, threadId, on: config.computerApps === true, isDraft, sources: appSources });
|
|
738
|
+
if (checkedApps.notices.length) {
|
|
739
|
+
appsRetry = checkedApps;
|
|
740
|
+
// Reap the child through the existing bounded shutdown before retrying.
|
|
741
|
+
finish("Checking apps again with the unverified apps turned off.");
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
for (const message of apps.notices) emit({ type: "system", subtype: "info", message });
|
|
745
|
+
emit({ type: "system", subtype: "init", model: thread.model ?? config.model, effort: thread.reasoningEffort ?? config.effort, engine: "codex" });
|
|
746
|
+
emit({ type: "system", subtype: "info", message: planWindowInfoMessage(planWindow) });
|
|
747
|
+
// An apps-off run looks like missing tools from the transcript alone:
|
|
748
|
+
// the model searches, finds none and says so (#2366, a chat turn).
|
|
749
|
+
if (config.computerApps !== true) emit({ type: "system", subtype: "info", message: APPS_OFF_INFO });
|
|
750
|
+
handle.markSent();
|
|
751
|
+
// THE ECHO IS THE PERSON'S OWN WORDS, NOT THE WHOLE BRIEF (2026-09-26):
|
|
752
|
+
// `opts.prompt` is the preamble plus the repo primer plus what they
|
|
753
|
+
// typed, and the expanded run drew all of it as the first transcript
|
|
754
|
+
// line - a wall of instructions above a two-line request. The runner
|
|
755
|
+
// hands the typed part separately as `opts.brief`; the child still
|
|
756
|
+
// receives the full prompt below. The role is what makes it the
|
|
757
|
+
// person's: the app reads a user event without one as the agent
|
|
758
|
+
// talking, and a chat streamed its instructions into the reply (#702).
|
|
759
|
+
emit({ type: "user", message: { role: "user", content: [{ type: "text", text: opts.brief ?? opts.prompt }] } });
|
|
760
|
+
const result = await rpc.request("turn/start", { threadId, input: inputFor(opts.prompt, opts.images),
|
|
761
|
+
...(config.outputSchema ? { outputSchema: config.outputSchema } : {}),
|
|
762
|
+
...(config.model ? { model: config.model } : {}), ...(config.effort ? { effort: config.effort } : {}) });
|
|
763
|
+
turnId = result.turn?.id ?? turnId;
|
|
764
|
+
})().catch((error) => finish(error.message));
|
|
765
|
+
};
|
|
766
|
+
});
|
|
767
|
+
}
|