@tarunspandit/codexflow 0.29.0

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 (92) hide show
  1. package/AGENTS.example.md +18 -0
  2. package/CHANGELOG.md +311 -0
  3. package/CHATGPT_PROMPT.md +12 -0
  4. package/CODEX_PROMPT.md +12 -0
  5. package/CONTRIBUTING.md +51 -0
  6. package/DOMAIN_SETUP.md +241 -0
  7. package/FAQ.md +327 -0
  8. package/FAQ_ZH.md +279 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +9 -0
  11. package/PUBLIC_LAUNCH_CHECKLIST.md +111 -0
  12. package/README.md +287 -0
  13. package/README_ZH.md +461 -0
  14. package/SECURITY.md +145 -0
  15. package/config.example.env +31 -0
  16. package/dist/analysis/cache.js +27 -0
  17. package/dist/analysis/cache.js.map +1 -0
  18. package/dist/analysis/classify.js +102 -0
  19. package/dist/analysis/classify.js.map +1 -0
  20. package/dist/analysis/extract.js +139 -0
  21. package/dist/analysis/extract.js.map +1 -0
  22. package/dist/analysis/graph.js +22 -0
  23. package/dist/analysis/graph.js.map +1 -0
  24. package/dist/analysis/impact.js +167 -0
  25. package/dist/analysis/impact.js.map +1 -0
  26. package/dist/analysis/index.js +215 -0
  27. package/dist/analysis/index.js.map +1 -0
  28. package/dist/analysis/inventory.js +51 -0
  29. package/dist/analysis/inventory.js.map +1 -0
  30. package/dist/analysis/providers.js +24 -0
  31. package/dist/analysis/providers.js.map +1 -0
  32. package/dist/analysis/rank.js +27 -0
  33. package/dist/analysis/rank.js.map +1 -0
  34. package/dist/analysis/types.js +8 -0
  35. package/dist/analysis/types.js.map +1 -0
  36. package/dist/bashOps.js +233 -0
  37. package/dist/bashOps.js.map +1 -0
  38. package/dist/capabilitiesOps.js +365 -0
  39. package/dist/capabilitiesOps.js.map +1 -0
  40. package/dist/codexSessions.js +379 -0
  41. package/dist/codexSessions.js.map +1 -0
  42. package/dist/config.js +289 -0
  43. package/dist/config.js.map +1 -0
  44. package/dist/fsOps.js +286 -0
  45. package/dist/fsOps.js.map +1 -0
  46. package/dist/gitOps.js +79 -0
  47. package/dist/gitOps.js.map +1 -0
  48. package/dist/guard.js +198 -0
  49. package/dist/guard.js.map +1 -0
  50. package/dist/http.js +1671 -0
  51. package/dist/http.js.map +1 -0
  52. package/dist/proContext.js +274 -0
  53. package/dist/proContext.js.map +1 -0
  54. package/dist/profileStore.js +89 -0
  55. package/dist/profileStore.js.map +1 -0
  56. package/dist/projectCatalog.js +134 -0
  57. package/dist/projectCatalog.js.map +1 -0
  58. package/dist/redact.js +73 -0
  59. package/dist/redact.js.map +1 -0
  60. package/dist/searchOps.js +186 -0
  61. package/dist/searchOps.js.map +1 -0
  62. package/dist/server.js +2502 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/stdio.js +36 -0
  65. package/dist/stdio.js.map +1 -0
  66. package/dist/toolCardWidget.js +1155 -0
  67. package/dist/toolCardWidget.js.map +1 -0
  68. package/dist/workspaceOps.js +229 -0
  69. package/dist/workspaceOps.js.map +1 -0
  70. package/docs/.nojekyll +1 -0
  71. package/docs/favicon.svg +5 -0
  72. package/docs/index.html +638 -0
  73. package/docs/og.png +0 -0
  74. package/docs/script.js +80 -0
  75. package/docs/star.svg +11 -0
  76. package/docs/styles.css +1229 -0
  77. package/docs/zh.html +436 -0
  78. package/package.json +94 -0
  79. package/scripts/analysis-cli-smoke.mjs +81 -0
  80. package/scripts/analysis-smoke.mjs +179 -0
  81. package/scripts/clean.mjs +6 -0
  82. package/scripts/cli-smoke.mjs +168 -0
  83. package/scripts/codexflow.mjs +4375 -0
  84. package/scripts/doctor-smoke.mjs +90 -0
  85. package/scripts/execute-handoff-smoke.mjs +1110 -0
  86. package/scripts/http-smoke.mjs +812 -0
  87. package/scripts/pro-apply.mjs +141 -0
  88. package/scripts/pro-bundle.mjs +121 -0
  89. package/scripts/pro-smoke.mjs +95 -0
  90. package/scripts/settings-smoke.mjs +756 -0
  91. package/scripts/smoke.mjs +1194 -0
  92. package/scripts/stress.mjs +835 -0
package/dist/server.js ADDED
@@ -0,0 +1,2502 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { z } from "zod";
7
+ import { WorkspaceManager, PathGuard, CodexFlowError } from "./guard.js";
8
+ import { repoTree, readTextFile, writeTextFile, editTextFile, ensureAiBridge } from "./fsOps.js";
9
+ import { searchWorkspace } from "./searchOps.js";
10
+ import { runBash } from "./bashOps.js";
11
+ import { gitDiff, gitDiffStatus, gitStatus } from "./gitOps.js";
12
+ import { readAiBridgeContext, readCodexContext, workspaceSummary } from "./workspaceOps.js";
13
+ import { buildProContext, exportProContext } from "./proContext.js";
14
+ import { codexflowInventory, loadSkill } from "./capabilitiesOps.js";
15
+ import { listCodexSessions, readCodexSession } from "./codexSessions.js";
16
+ import { discoverProjects } from "./projectCatalog.js";
17
+ import { TOOL_CARD_LEGACY_URIS, TOOL_CARD_MIME_TYPE, TOOL_CARD_URI, toolCardWidgetHtml } from "./toolCardWidget.js";
18
+ import { hasSecretValue, redactSensitiveText, redactStructured } from "./redact.js";
19
+ import { inspectWorkspace, invalidateWorkspaceAnalysis, reviewWorkspaceChanges } from "./analysis/index.js";
20
+ const STRUCTURED_STRING_MAX_CHARS = 30_000;
21
+ function errorText(error) {
22
+ if (error instanceof Error)
23
+ return redactSensitiveText(`${error.name}: ${error.message}`);
24
+ return redactSensitiveText(String(error));
25
+ }
26
+ function compactStructuredContent(value, depth = 0) {
27
+ if (depth > 8 || value === null || value === undefined)
28
+ return value;
29
+ if (typeof value === "string") {
30
+ if (value.length <= STRUCTURED_STRING_MAX_CHARS)
31
+ return value;
32
+ return `${value.slice(0, STRUCTURED_STRING_MAX_CHARS)}\n...[structured field truncated to ${STRUCTURED_STRING_MAX_CHARS} chars]`;
33
+ }
34
+ if (Array.isArray(value))
35
+ return value.map((item) => compactStructuredContent(item, depth + 1));
36
+ if (typeof value !== "object")
37
+ return value;
38
+ const out = {};
39
+ for (const [key, item] of Object.entries(value)) {
40
+ out[key] = compactStructuredContent(item, depth + 1);
41
+ }
42
+ return out;
43
+ }
44
+ function textResult(text, structuredContent = {}, meta = {}) {
45
+ return {
46
+ content: [{ type: "text", text: redactSensitiveText(text) }],
47
+ structuredContent: redactStructured(structuredContent),
48
+ _meta: meta
49
+ };
50
+ }
51
+ function countTextLines(value) {
52
+ if (!value)
53
+ return 0;
54
+ return value.split(/\r?\n/).filter((line) => line.length > 0).length;
55
+ }
56
+ function bashTextResult(config, result) {
57
+ if (config.bashTranscript === "full") {
58
+ return `# Bash\n\n\`\`\`bash\n$ ${result.command}\n\`\`\`\n\nCWD: ${result.cwd}\nExit: ${result.exitCode}${result.signal ? ` (${result.signal})` : ""}\nDuration: ${result.durationMs} ms\n\n## stdout\n\n\`\`\`text\n${result.stdout || ""}\n\`\`\`\n\n## stderr\n\n\`\`\`text\n${result.stderr || ""}\n\`\`\``;
59
+ }
60
+ const stdoutLines = countTextLines(result.stdout);
61
+ const stderrLines = countTextLines(result.stderr);
62
+ return [
63
+ "# Bash",
64
+ "",
65
+ `\`${result.command}\``,
66
+ "",
67
+ `CWD: ${result.cwd}`,
68
+ `Exit: ${result.exitCode}${result.signal ? ` (${result.signal})` : ""}`,
69
+ `Duration: ${result.durationMs} ms`,
70
+ `Output: stdout ${stdoutLines} line${stdoutLines === 1 ? "" : "s"}, stderr ${stderrLines} line${stderrLines === 1 ? "" : "s"}.`,
71
+ "",
72
+ "Raw stdout/stderr are in the structured CodexFlow card. Start with `--bash-transcript full` to print raw output in chat."
73
+ ].join("\n");
74
+ }
75
+ function errorResult(error) {
76
+ return {
77
+ isError: true,
78
+ content: [{ type: "text", text: errorText(error) }],
79
+ structuredContent: { error: errorText(error) }
80
+ };
81
+ }
82
+ function validateToolArgs(name, options, args) {
83
+ const inputSchema = options.inputSchema;
84
+ if (!inputSchema || typeof inputSchema !== "object" || Array.isArray(inputSchema))
85
+ return args ?? {};
86
+ const shape = {};
87
+ for (const [key, value] of Object.entries(inputSchema)) {
88
+ if (value && typeof value.safeParse === "function") {
89
+ shape[key] = value;
90
+ }
91
+ }
92
+ if (!Object.keys(shape).length)
93
+ return {};
94
+ const parsed = z.object(shape).safeParse(args ?? {});
95
+ if (parsed.success)
96
+ return parsed.data;
97
+ const details = parsed.error.issues
98
+ .map((issue) => `${issue.path.length ? issue.path.join(".") : "arguments"}: ${issue.message}`)
99
+ .join("; ");
100
+ throw new CodexFlowError(`Invalid arguments for ${name}: ${details}`);
101
+ }
102
+ function tagToolResult(result, name, options) {
103
+ if (!result || typeof result !== "object")
104
+ return result;
105
+ const structured = result.structuredContent;
106
+ const base = structured && typeof structured === "object" && !Array.isArray(structured)
107
+ ? structured
108
+ : {};
109
+ const tagged = {
110
+ codexflow_tool: name,
111
+ codexflow_title: options.title ?? name,
112
+ ...base
113
+ };
114
+ const meta = options._meta ?? {};
115
+ result.structuredContent = meta.ui || meta["openai/outputTemplate"] ? compactStructuredContent(tagged) : tagged;
116
+ return result;
117
+ }
118
+ function toolCardMeta() {
119
+ return {
120
+ ui: { resourceUri: TOOL_CARD_URI },
121
+ "openai/outputTemplate": TOOL_CARD_URI
122
+ };
123
+ }
124
+ const OPTIONAL_TOOL_CARD_META = [
125
+ "ui",
126
+ "openai/outputTemplate",
127
+ "openai/toolInvocation/invoking",
128
+ "openai/toolInvocation/invoked"
129
+ ];
130
+ function descriptorOptionsForConfig(config, options) {
131
+ const originalMeta = options._meta ?? {};
132
+ if (config.toolCards || originalMeta["codexflow/alwaysWidget"] === true)
133
+ return options;
134
+ const meta = { ...originalMeta };
135
+ for (const key of OPTIONAL_TOOL_CARD_META)
136
+ delete meta[key];
137
+ return { ...options, _meta: meta };
138
+ }
139
+ function toolCallLoggingEnabled() {
140
+ return process.env.CODEXFLOW_LOG_TOOL_CALLS === "1" || process.env.CODEXFLOW_LOG_REQUESTS === "1";
141
+ }
142
+ function logToolCall(name, status, started) {
143
+ if (!toolCallLoggingEnabled())
144
+ return;
145
+ console.error(`[CodexFlowTool] ${name} ${status} ${Date.now() - started}ms`);
146
+ }
147
+ function registerToolCardResource(server, config) {
148
+ if (config.connectionTest)
149
+ return;
150
+ const s = server;
151
+ if (typeof s.registerResource !== "function") {
152
+ throw new Error("Unsupported MCP SDK: CodexFlow widgets require registerResource.");
153
+ }
154
+ const registerUri = (uri, name) => {
155
+ s.registerResource(name, uri, {
156
+ title: "CodexFlow Tool Card",
157
+ description: "Compact visual renderer for CodexFlow workspace orientation, source changes, and handoffs.",
158
+ mimeType: TOOL_CARD_MIME_TYPE
159
+ }, async () => ({
160
+ contents: [
161
+ {
162
+ uri,
163
+ mimeType: TOOL_CARD_MIME_TYPE,
164
+ text: toolCardWidgetHtml,
165
+ _meta: {
166
+ ui: {
167
+ prefersBorder: true,
168
+ domain: config.widgetDomain,
169
+ csp: {
170
+ connectDomains: [],
171
+ resourceDomains: []
172
+ }
173
+ },
174
+ "openai/widgetDescription": "Renders CodexFlow workspace orientation, diagnostics, file diffs, change reviews, terminal checks, Pro context exports, and handoff plans as compact developer cards with bounded previews.",
175
+ "openai/widgetPrefersBorder": true,
176
+ "openai/widgetDomain": config.widgetDomain,
177
+ "openai/widgetCSP": {
178
+ connect_domains: [],
179
+ resource_domains: []
180
+ }
181
+ }
182
+ }
183
+ ]
184
+ }));
185
+ };
186
+ registerUri(TOOL_CARD_URI, "codexflow-tool-card");
187
+ for (const legacyUri of TOOL_CARD_LEGACY_URIS) {
188
+ registerUri(legacyUri, `codexflow-tool-card-${legacyUri.match(/v\d+/)?.[0] ?? "legacy"}`);
189
+ }
190
+ }
191
+ const SUPERTOOL_NAME = "codexflow";
192
+ const SUPERTOOL_ACTION_ALIASES = {
193
+ actions: "list_actions",
194
+ config: "server_config",
195
+ self_test: "codexflow_self_test",
196
+ inventory: "codexflow_inventory",
197
+ open: "open_current_workspace",
198
+ snapshot: "workspace_snapshot",
199
+ changes: "show_changes",
200
+ handoff_poll: "wait_for_handoff",
201
+ pro_export: "export_pro_context",
202
+ agent_handoff: "handoff_to_agent",
203
+ codex_handoff: "handoff_to_codex"
204
+ };
205
+ const registeredToolHandlersByServer = new WeakMap();
206
+ function rememberRegisteredToolHandler(server, name, handler) {
207
+ const key = server;
208
+ const handlers = registeredToolHandlersByServer.get(key) ?? new Map();
209
+ if (!registeredToolHandlersByServer.has(key))
210
+ registeredToolHandlersByServer.set(key, handlers);
211
+ handlers.set(name, handler);
212
+ }
213
+ function registeredToolHandler(server, name) {
214
+ return registeredToolHandlersByServer.get(server)?.get(name);
215
+ }
216
+ function normalizeSupertoolAction(value) {
217
+ const raw = String(value ?? "list_actions").trim();
218
+ const normalized = raw.toLowerCase().replace(/[\s-]+/g, "_");
219
+ return SUPERTOOL_ACTION_ALIASES[normalized] ?? normalized;
220
+ }
221
+ function isContextPath(config, relPath) {
222
+ const normalized = relPath.split(path.sep).join("/").replace(/^\.\//, "");
223
+ const contextDir = config.contextDir.replace(/^\.\//, "").replace(/\/$/, "");
224
+ return normalized === contextDir || normalized.startsWith(`${contextDir}/`);
225
+ }
226
+ function assertWriteToolAllowed(config, relPath) {
227
+ if (config.writeMode === "workspace")
228
+ return;
229
+ if (config.writeMode === "handoff" && isContextPath(config, relPath))
230
+ return;
231
+ if (config.writeMode === "handoff") {
232
+ throw new CodexFlowError(`Source writes are disabled because CODEXFLOW_WRITE_MODE=handoff. ` +
233
+ `Use handoff_to_agent or handoff_to_codex, or write/edit/apply_patch only inside ${config.contextDir}/.`);
234
+ }
235
+ throw new CodexFlowError("write/edit/apply_patch tools are disabled because CODEXFLOW_WRITE_MODE=off. handoff_to_agent and handoff_to_codex are still available for planning.");
236
+ }
237
+ function registerToolCompat(server, name, options, handler) {
238
+ const wrapped = async (args) => {
239
+ const started = Date.now();
240
+ try {
241
+ const result = tagToolResult(await handler(args ?? {}), name, options);
242
+ logToolCall(name, result?.isError ? "error" : "ok", started);
243
+ return result;
244
+ }
245
+ catch (error) {
246
+ const result = tagToolResult(errorResult(error), name, options);
247
+ logToolCall(name, "error", started);
248
+ return result;
249
+ }
250
+ };
251
+ const securitySchemes = [{ type: "noauth" }];
252
+ const fullOptions = {
253
+ securitySchemes,
254
+ ...options,
255
+ _meta: {
256
+ securitySchemes,
257
+ ...options._meta
258
+ }
259
+ };
260
+ const s = server;
261
+ if (typeof s.registerTool === "function") {
262
+ s.registerTool(name, fullOptions, wrapped);
263
+ return;
264
+ }
265
+ if (typeof s.tool === "function") {
266
+ s.tool(name, fullOptions.description ?? name, fullOptions.inputSchema ?? {}, wrapped);
267
+ return;
268
+ }
269
+ throw new Error("Unsupported MCP SDK: McpServer has neither registerTool nor tool.");
270
+ }
271
+ const MINIMAL_TOOL_NAMES = [
272
+ SUPERTOOL_NAME,
273
+ "server_config",
274
+ "codexflow_self_test",
275
+ "list_projects",
276
+ "select_project",
277
+ "load_skill",
278
+ "open_current_workspace",
279
+ "open_workspace",
280
+ "read",
281
+ "write",
282
+ "edit",
283
+ "apply_patch",
284
+ "bash",
285
+ "show_changes"
286
+ ];
287
+ const STANDARD_TOOL_NAMES = [
288
+ ...MINIMAL_TOOL_NAMES,
289
+ "inspect_workspace",
290
+ "tree",
291
+ "search",
292
+ "read_handoff",
293
+ "wait_for_handoff",
294
+ "export_pro_context",
295
+ "handoff_to_agent"
296
+ ];
297
+ const FULL_TOOL_NAMES = [
298
+ SUPERTOOL_NAME,
299
+ "server_config",
300
+ "codexflow_self_test",
301
+ "codexflow_inventory",
302
+ "list_projects",
303
+ "select_project",
304
+ "load_skill",
305
+ "list_workspaces",
306
+ "open_current_workspace",
307
+ "open_workspace",
308
+ "workspace_snapshot",
309
+ "inspect_workspace",
310
+ "tree",
311
+ "search",
312
+ "read",
313
+ "write",
314
+ "edit",
315
+ "apply_patch",
316
+ "bash",
317
+ "git_status",
318
+ "git_diff",
319
+ "show_changes",
320
+ "read_handoff",
321
+ "wait_for_handoff",
322
+ "codex_context",
323
+ "export_pro_context",
324
+ "handoff_to_agent",
325
+ "handoff_to_codex"
326
+ ];
327
+ const CONNECTION_TEST_HIDDEN_TOOLS = new Set([
328
+ SUPERTOOL_NAME,
329
+ "codexflow_self_test",
330
+ "write",
331
+ "edit",
332
+ "apply_patch",
333
+ "bash",
334
+ "export_pro_context",
335
+ "handoff_to_agent",
336
+ "handoff_to_codex"
337
+ ]);
338
+ function codexSessionToolNames(config) {
339
+ if (config.codexSessions === "off")
340
+ return [];
341
+ return config.codexSessions === "read"
342
+ ? ["codex_sessions", "read_codex_session"]
343
+ : ["codex_sessions"];
344
+ }
345
+ function toolNamesForMode(config) {
346
+ const names = config.toolMode === "full"
347
+ ? [...FULL_TOOL_NAMES]
348
+ : config.toolMode === "minimal"
349
+ ? [...MINIMAL_TOOL_NAMES]
350
+ : [...STANDARD_TOOL_NAMES];
351
+ if (config.bashMode === "off") {
352
+ const bashIndex = names.indexOf("bash");
353
+ if (bashIndex !== -1)
354
+ names.splice(bashIndex, 1);
355
+ }
356
+ if (config.writeMode !== "workspace") {
357
+ for (const writeTool of ["write", "edit", "apply_patch"]) {
358
+ const toolIndex = names.indexOf(writeTool);
359
+ if (toolIndex !== -1)
360
+ names.splice(toolIndex, 1);
361
+ }
362
+ }
363
+ if (config.writeMode === "handoff" && !names.includes("handoff_to_agent"))
364
+ names.push("handoff_to_agent");
365
+ if (!config.analysisEnabled) {
366
+ const analysisIndex = names.indexOf("inspect_workspace");
367
+ if (analysisIndex !== -1)
368
+ names.splice(analysisIndex, 1);
369
+ }
370
+ if (config.connectionTest) {
371
+ for (const hiddenTool of CONNECTION_TEST_HIDDEN_TOOLS) {
372
+ const toolIndex = names.indexOf(hiddenTool);
373
+ if (toolIndex !== -1)
374
+ names.splice(toolIndex, 1);
375
+ }
376
+ }
377
+ for (const name of codexSessionToolNames(config)) {
378
+ if (!names.includes(name))
379
+ names.push(name);
380
+ }
381
+ return names;
382
+ }
383
+ const MINIMAL_TOOLS = new Set(MINIMAL_TOOL_NAMES);
384
+ const STANDARD_TOOLS = new Set(STANDARD_TOOL_NAMES);
385
+ const registeredToolNamesByServer = new WeakMap();
386
+ function rememberRegisteredTool(server, name) {
387
+ const key = server;
388
+ const names = registeredToolNamesByServer.get(key) ?? [];
389
+ if (!registeredToolNamesByServer.has(key))
390
+ registeredToolNamesByServer.set(key, names);
391
+ if (!names.includes(name))
392
+ names.push(name);
393
+ }
394
+ function registeredToolNames(server) {
395
+ return [...(registeredToolNamesByServer.get(server) ?? [])];
396
+ }
397
+ function shouldRegisterTool(config, name) {
398
+ if (config.connectionTest && CONNECTION_TEST_HIDDEN_TOOLS.has(name))
399
+ return false;
400
+ if (name === "bash" && config.bashMode === "off")
401
+ return false;
402
+ if ((name === "write" || name === "edit" || name === "apply_patch") && config.writeMode !== "workspace")
403
+ return false;
404
+ if (name === "codex_sessions")
405
+ return config.codexSessions !== "off";
406
+ if (name === "read_codex_session")
407
+ return config.codexSessions === "read";
408
+ if (name === "inspect_workspace" && !config.analysisEnabled)
409
+ return false;
410
+ if (name === "handoff_to_agent" && config.writeMode === "handoff")
411
+ return true;
412
+ if (config.toolMode === "full")
413
+ return true;
414
+ if (config.toolMode === "minimal")
415
+ return MINIMAL_TOOLS.has(name);
416
+ return STANDARD_TOOLS.has(name);
417
+ }
418
+ function registerCodexTool(config, server, name, options, handler) {
419
+ if (!shouldRegisterTool(config, name))
420
+ return;
421
+ const validatedHandler = (args) => handler(validateToolArgs(name, options, args));
422
+ registerToolCompat(server, name, descriptorOptionsForConfig(config, options), validatedHandler);
423
+ rememberRegisteredTool(server, name);
424
+ rememberRegisteredToolHandler(server, name, validatedHandler);
425
+ }
426
+ function serverInstructions(config) {
427
+ const editInstruction = config.connectionTest
428
+ ? "5. Connection test mode is read-only. Write, patch, export, and handoff-writing tools are unavailable."
429
+ : config.writeMode === "workspace"
430
+ ? "5. Edit source files with write/edit/apply_patch. After edits, call show_changes once for git status, diff stats, and review diff."
431
+ : config.writeMode === "handoff"
432
+ ? "5. Source writes are disabled and generic write/edit/apply_patch tools are unavailable. Use handoff_to_agent/handoff_to_codex for plans."
433
+ : "5. Write/edit/apply_patch tools are disabled. Do not attempt direct file writes; use handoff or context export workflows instead.";
434
+ const bashInstruction = config.bashMode === "off"
435
+ ? "6. Bash is disabled and the bash tool is unavailable. Do not attempt shell commands."
436
+ : "6. Use bash only for meaningful verification commands such as npm test, npm run build, lint, typecheck, or an existing project script.";
437
+ return [
438
+ "CodexFlow gives this ChatGPT conversation Codex-style access to one selected local project while sharing a single local broker with other conversations.",
439
+ "",
440
+ "Preferred workflow:",
441
+ "1. At the start of a new coding conversation, call list_projects so the user can choose a project. Call select_project with their choice before doing project work. If the user already named an exact project, select it directly from the list.",
442
+ "2. The selected project is bound to this MCP conversation. Omit workspace_id on later calls; CodexFlow routes them to the bound project. Use select_project again only when the user explicitly switches projects.",
443
+ "3. Follow AGENTS.md and load relevant advertised skills returned by select_project before editing files.",
444
+ "4. Inspect with tree, search, and read. Do not use bash for git status, git diff, cat, sed, grep, rg, find, ls, or file reading.",
445
+ editInstruction,
446
+ bashInstruction,
447
+ "7. Keep tool calls minimal. Prefer one targeted search plus show_changes instead of repeated broad inspection calls.",
448
+ config.codexSessions !== "off"
449
+ ? `8. Codex session history access is enabled in ${config.codexSessions} mode. Use it only when the user asks for local Codex session history.`
450
+ : "",
451
+ config.requireBashSession && config.bashSessionId
452
+ ? `9. Bash session guard is enabled. Every bash call must include session_id="${config.bashSessionId}".`
453
+ : config.bashSessionId
454
+ ? `9. Bash session label for this server is "${config.bashSessionId}".`
455
+ : "",
456
+ "",
457
+ `Current modes: tool=${config.toolMode}, bash=${config.bashMode}, write=${config.writeMode}.`
458
+ ].filter(Boolean).join("\n");
459
+ }
460
+ function limitInt(value, fallback, min, max) {
461
+ const n = Number(value ?? fallback);
462
+ if (!Number.isFinite(n))
463
+ return fallback;
464
+ return Math.max(min, Math.min(max, Math.floor(n)));
465
+ }
466
+ function parseBool(value, fallback = false) {
467
+ if (typeof value === "boolean")
468
+ return value;
469
+ if (value === undefined || value === null)
470
+ return fallback;
471
+ return ["1", "true", "yes", "y"].includes(String(value).toLowerCase());
472
+ }
473
+ function diffBlock(diff) {
474
+ return `\n\n\`\`\`diff\n${diff}\n\`\`\``;
475
+ }
476
+ function diffStats(diff) {
477
+ let additions = 0;
478
+ let deletions = 0;
479
+ for (const line of diff.split("\n")) {
480
+ if (line.startsWith("+") && !line.startsWith("+++"))
481
+ additions += 1;
482
+ if (line.startsWith("-") && !line.startsWith("---"))
483
+ deletions += 1;
484
+ }
485
+ return { additions, deletions, changed: Boolean(diff.trim()) };
486
+ }
487
+ const reviewCheckpoints = new Map();
488
+ function reviewCheckpointKey(workspace, options) {
489
+ return `${workspace.id}\0${options.path ?? ""}\0${options.staged ? "staged" : "unstaged"}`;
490
+ }
491
+ function reviewFingerprint(status, diff) {
492
+ return createHash("sha256").update(status).update("\0").update(diff).digest("hex");
493
+ }
494
+ async function untrackedReviewFingerprint(config, guard, workspace, changedFiles) {
495
+ const hash = createHash("sha256");
496
+ for (const line of changedFiles) {
497
+ const match = line.match(/^\?\?\s+(.+)$/);
498
+ if (!match)
499
+ continue;
500
+ const relPath = match[1];
501
+ hash.update(relPath).update("\0");
502
+ try {
503
+ const resolved = guard.resolve(workspace, relPath);
504
+ const stat = await fsp.stat(resolved.absPath);
505
+ hash.update(String(stat.size)).update("\0").update(String(Math.floor(stat.mtimeMs))).update("\0");
506
+ if (stat.isFile() && stat.size <= config.maxReadBytes) {
507
+ hash.update(await fsp.readFile(resolved.absPath));
508
+ }
509
+ }
510
+ catch (error) {
511
+ hash.update(errorText(error));
512
+ }
513
+ hash.update("\0");
514
+ }
515
+ return hash.digest("hex");
516
+ }
517
+ function normalizeGitOutput(output) {
518
+ return output.trim() === "(no output)" ? "" : output;
519
+ }
520
+ function decodeGitQuotedPath(pathText) {
521
+ const input = pathText.startsWith('"') && pathText.endsWith('"') ? pathText.slice(1, -1) : pathText;
522
+ let decoded = "";
523
+ let escapedBytes = [];
524
+ const flushEscapedBytes = () => {
525
+ if (!escapedBytes.length)
526
+ return;
527
+ decoded += Buffer.from(escapedBytes).toString("utf8");
528
+ escapedBytes = [];
529
+ };
530
+ for (let i = 0; i < input.length; i += 1) {
531
+ const char = input[i];
532
+ if (char !== "\\") {
533
+ flushEscapedBytes();
534
+ decoded += char;
535
+ continue;
536
+ }
537
+ i += 1;
538
+ const escaped = input[i];
539
+ if (escaped === undefined)
540
+ throw new CodexFlowError(`Invalid quoted Git path: ${pathText}`);
541
+ if (/[0-7]/.test(escaped)) {
542
+ let octal = escaped;
543
+ for (let j = 0; j < 2 && i + 1 < input.length && /[0-7]/.test(input[i + 1]); j += 1) {
544
+ i += 1;
545
+ octal += input[i];
546
+ }
547
+ escapedBytes.push(Number.parseInt(octal, 8));
548
+ }
549
+ else {
550
+ flushEscapedBytes();
551
+ decoded += { a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" }[escaped] ?? escaped;
552
+ }
553
+ }
554
+ flushEscapedBytes();
555
+ return decoded;
556
+ }
557
+ function stripPatchPathComponents(filePath, stripComponents) {
558
+ if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath))
559
+ return filePath;
560
+ let stripped = filePath;
561
+ for (let i = 0; i < stripComponents; i += 1) {
562
+ const slash = stripped.indexOf("/");
563
+ if (slash < 0)
564
+ return stripped;
565
+ stripped = stripped.slice(slash + 1);
566
+ }
567
+ return stripped;
568
+ }
569
+ function normalizePatchPath(rawPath, stripComponents = 1) {
570
+ const raw = rawPath.trim().split("\t")[0]?.trim();
571
+ if (!raw || raw === "/dev/null")
572
+ return undefined;
573
+ const unquoted = raw.startsWith('"') && raw.endsWith('"') ? decodeGitQuotedPath(raw.slice(1, -1)) : raw;
574
+ return stripPatchPathComponents(unquoted, stripComponents);
575
+ }
576
+ function patchHasSymlinkMode(patch) {
577
+ return patch.split(/\r?\n/).some((line) => /^(?:new|old|deleted) file mode 120000\s*$/.test(line) || /^new mode 120000\s*$/.test(line) || /^old mode 120000\s*$/.test(line));
578
+ }
579
+ function patchTouchedPaths(patch) {
580
+ const paths = new Set();
581
+ for (const line of patch.split(/\r?\n/)) {
582
+ if (line.startsWith("--- ") || line.startsWith("+++ ")) {
583
+ const normalized = normalizePatchPath(line.slice(4));
584
+ if (normalized)
585
+ paths.add(normalized);
586
+ }
587
+ else if (line.startsWith("rename from ") || line.startsWith("rename to ") || line.startsWith("copy from ") || line.startsWith("copy to ")) {
588
+ const normalized = normalizePatchPath(line.replace(/^(?:rename|copy) (?:from|to) /, ""), 0);
589
+ if (normalized)
590
+ paths.add(normalized);
591
+ }
592
+ }
593
+ return [...paths];
594
+ }
595
+ function applyWorkspacePatch(config, guard, workspace, patch) {
596
+ if (!patch.trim())
597
+ throw new CodexFlowError("patch is required.");
598
+ if (Buffer.byteLength(patch, "utf8") > config.maxWriteBytes) {
599
+ throw new CodexFlowError(`Patch is too large. Limit: ${config.maxWriteBytes} bytes.`);
600
+ }
601
+ if (hasSecretValue(patch)) {
602
+ throw new CodexFlowError("Secret-looking content is blocked from apply_patch. Use placeholders such as [REDACTED_SECRET].");
603
+ }
604
+ if (patchHasSymlinkMode(patch)) {
605
+ throw new CodexFlowError("Symlink patches are blocked from apply_patch.");
606
+ }
607
+ const paths = patchTouchedPaths(patch);
608
+ if (!paths.length)
609
+ throw new CodexFlowError("Patch must include at least one file path.");
610
+ for (const touchedPath of paths) {
611
+ guard.resolve(workspace, touchedPath, { forWrite: true });
612
+ assertWriteToolAllowed(config, touchedPath);
613
+ }
614
+ const check = spawnSync("git", ["apply", "--check", "--whitespace=nowarn"], {
615
+ cwd: workspace.root,
616
+ input: patch,
617
+ encoding: "utf8",
618
+ maxBuffer: config.maxOutputBytes,
619
+ env: { ...process.env, NO_COLOR: "1" }
620
+ });
621
+ if (check.error || check.status !== 0) {
622
+ throw new CodexFlowError(redactSensitiveText(check.stderr?.trim() || check.stdout?.trim() || check.error?.message || "git apply --check failed"));
623
+ }
624
+ const applied = spawnSync("git", ["apply", "--whitespace=nowarn"], {
625
+ cwd: workspace.root,
626
+ input: patch,
627
+ encoding: "utf8",
628
+ maxBuffer: config.maxOutputBytes,
629
+ env: { ...process.env, NO_COLOR: "1" }
630
+ });
631
+ if (applied.error || applied.status !== 0) {
632
+ throw new CodexFlowError(redactSensitiveText(applied.stderr?.trim() || applied.stdout?.trim() || applied.error?.message || "git apply failed"));
633
+ }
634
+ const diff = redactSensitiveText(patch.trimEnd());
635
+ const stats = diffStats(diff);
636
+ return {
637
+ paths,
638
+ stdout: redactSensitiveText(applied.stdout?.trim() || ""),
639
+ stderr: redactSensitiveText(applied.stderr?.trim() || ""),
640
+ diff,
641
+ additions: stats.additions,
642
+ deletions: stats.deletions,
643
+ changed: true
644
+ };
645
+ }
646
+ function looksLikeGitError(output) {
647
+ const trimmed = output.trim();
648
+ const lower = trimmed.toLowerCase();
649
+ return (trimmed.startsWith("fatal:") ||
650
+ trimmed.startsWith("error:") ||
651
+ trimmed.startsWith("git unavailable or failed:") ||
652
+ trimmed.startsWith("git exited with status") ||
653
+ trimmed.startsWith("usage: git ") ||
654
+ lower.includes("not a git repository"));
655
+ }
656
+ function previewText(value, maxLines = 40, maxChars = 12_000) {
657
+ const lines = value.replace(/\r\n/g, "\n").split("\n").slice(0, maxLines).join("\n");
658
+ return lines.length > maxChars ? `${lines.slice(0, maxChars)}\n...[preview truncated]` : lines;
659
+ }
660
+ function changedStatusLines(status) {
661
+ return status
662
+ .split("\n")
663
+ .map((line) => line.trim())
664
+ .filter((line) => line && line !== "(no output)" && !line.startsWith("##"));
665
+ }
666
+ function changedPathsFromStatus(lines) {
667
+ const paths = [];
668
+ for (const line of lines) {
669
+ let raw;
670
+ if (line.startsWith("?? "))
671
+ raw = line.slice(3).trim();
672
+ else if (line.includes("\t"))
673
+ raw = line.split("\t").pop()?.trim() ?? "";
674
+ else if (/^.{2}\s/.test(line))
675
+ raw = line.slice(3).trim();
676
+ else
677
+ continue;
678
+ if (raw.includes(" -> "))
679
+ raw = raw.split(" -> ").pop() ?? raw;
680
+ const decoded = decodeGitQuotedPath(raw);
681
+ if (decoded && !paths.includes(decoded))
682
+ paths.push(decoded);
683
+ }
684
+ return paths;
685
+ }
686
+ function jsonlEvent(event, data) {
687
+ return JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + "\n";
688
+ }
689
+ function cleanOneLine(value, fallback, maxLength = 120) {
690
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
691
+ return (text || fallback).slice(0, maxLength);
692
+ }
693
+ function normalizeAgentId(value) {
694
+ const agent = cleanOneLine(value, "custom", 64).toLowerCase();
695
+ if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(agent)) {
696
+ throw new CodexFlowError("agent must use only lowercase letters, numbers, dots, underscores, or hyphens.");
697
+ }
698
+ return agent;
699
+ }
700
+ function displayAgentName(agent, agentName) {
701
+ const explicit = cleanOneLine(agentName, "", 80);
702
+ if (explicit)
703
+ return explicit;
704
+ if (agent === "codex")
705
+ return "Codex";
706
+ if (agent === "opencode")
707
+ return "OpenCode";
708
+ if (agent === "pi")
709
+ return "Pi";
710
+ return agent;
711
+ }
712
+ function shellQuote(value) {
713
+ return `'${value.replace(/'/g, "'\\''")}'`;
714
+ }
715
+ function agentCommandHint(agent, planPath, model) {
716
+ const modelArg = model ? ` --model ${shellQuote(model)}` : " --model '<provider/model>'";
717
+ const quotedPlanPath = shellQuote(planPath);
718
+ if (agent === "opencode")
719
+ return `opencode run${modelArg} "$(cat ${quotedPlanPath})"`;
720
+ if (agent === "pi")
721
+ return `pi run${modelArg} "$(cat ${quotedPlanPath})"`;
722
+ if (agent === "codex")
723
+ return `Read ${planPath} and execute it in small, reviewable steps.`;
724
+ return `Run your local implementation agent manually with ${planPath} as the task input.`;
725
+ }
726
+ async function readRawTextFileBounded(config, guard, workspace, filePath) {
727
+ const resolved = guard.resolve(workspace, filePath);
728
+ await guard.assertTextFile(resolved.absPath, config.maxReadBytes);
729
+ return fsp.readFile(resolved.absPath, "utf8");
730
+ }
731
+ function buildAgentPlanBody(options) {
732
+ const modelLine = options.model ? `Model: ${options.model}\n` : "";
733
+ return `# ${options.title}
734
+
735
+ Updated: ${new Date().toISOString()}
736
+ Workspace: ${options.workspace.root}
737
+ Target agent: ${options.agentName} (${options.agent})
738
+ ${modelLine}
739
+ ## Plan
740
+
741
+ ${options.plan.trim()}
742
+
743
+ ## Implementation contract
744
+
745
+ - Work from this plan in small, reviewable steps.
746
+ - Keep edits scoped to the requested task and existing project conventions.
747
+ - Run focused verification before handing work back.
748
+ - Update ${options.statusPath} with files touched, checks run, results, blockers, and review notes.
749
+ - Save the final review diff to ${options.diffPath} when practical.
750
+ - Append notable execution events to ${options.executionLogPath} when the implementation agent supports logging.
751
+ `;
752
+ }
753
+ async function writeAgentHandoff(config, guard, workspace, options) {
754
+ await ensureAiBridge(config, guard, workspace);
755
+ const agent = normalizeAgentId(options.agent);
756
+ const agentName = displayAgentName(agent, options.agentName);
757
+ const model = options.model ? cleanOneLine(options.model, "", 120) : undefined;
758
+ const plan = String(options.plan ?? "").trim();
759
+ if (!plan)
760
+ throw new CodexFlowError("plan must not be empty.");
761
+ const planPath = `${config.contextDir}/current-plan.md`;
762
+ const statusPath = `${config.contextDir}/agent-status.md`;
763
+ const legacyCodexStatusPath = `${config.contextDir}/codex-status.md`;
764
+ const diffPath = `${config.contextDir}/implementation-diff.patch`;
765
+ const logPath = `${config.contextDir}/session-log.jsonl`;
766
+ const executionLogPath = `${config.contextDir}/execution-log.jsonl`;
767
+ const body = buildAgentPlanBody({
768
+ title: options.title,
769
+ plan,
770
+ workspace,
771
+ agent,
772
+ agentName,
773
+ model,
774
+ statusPath,
775
+ diffPath,
776
+ executionLogPath
777
+ });
778
+ let content = body;
779
+ if (options.append) {
780
+ const raw = await readRawTextFileBounded(config, guard, workspace, planPath);
781
+ content = `${raw.trimEnd()}\n\n---\n\n${body}`;
782
+ }
783
+ const writeResult = await writeTextFile(config, guard, workspace, planPath, content, { createDirs: true, overwrite: true });
784
+ const event = {
785
+ agent,
786
+ agent_name: agentName,
787
+ model,
788
+ title: options.title,
789
+ plan_path: planPath,
790
+ status_path: statusPath,
791
+ diff_path: diffPath
792
+ };
793
+ const logResolved = guard.resolve(workspace, logPath, { forWrite: true });
794
+ const executionLogResolved = guard.resolve(workspace, executionLogPath, { forWrite: true });
795
+ await fsp.appendFile(logResolved.absPath, jsonlEvent(options.eventName, event), "utf8");
796
+ await fsp.appendFile(executionLogResolved.absPath, jsonlEvent(options.eventName, event), "utf8");
797
+ const promptLines = [
798
+ `Read ${planPath} and execute it in small, reviewable steps.`,
799
+ `After each meaningful change, update ${statusPath} with files touched, checks run, results, blockers, and the next review focus.`,
800
+ `Before review, write the final diff to ${diffPath} when practical.`,
801
+ agentCommandHint(agent, planPath, model)
802
+ ];
803
+ if (agent === "codex") {
804
+ promptLines.splice(2, 0, `For legacy Codex handoffs, mirror key status notes to ${legacyCodexStatusPath} if your workflow expects that file.`);
805
+ }
806
+ const prompt = promptLines.join("\n");
807
+ return {
808
+ agent,
809
+ agentName,
810
+ model,
811
+ title: options.title,
812
+ planPath,
813
+ statusPath,
814
+ diffPath,
815
+ logPath,
816
+ executionLogPath,
817
+ prompt,
818
+ writeResult
819
+ };
820
+ }
821
+ const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, openWorldHint: false, destructiveHint: false };
822
+ const SESSION_READ_ANNOTATIONS = { readOnlyHint: true, openWorldHint: false, destructiveHint: false, idempotentHint: false };
823
+ const LOCAL_WRITE_ANNOTATIONS = { readOnlyHint: false, openWorldHint: false, destructiveHint: true, idempotentHint: false };
824
+ const BASH_ANNOTATIONS = { readOnlyHint: false, openWorldHint: true, destructiveHint: true, idempotentHint: false };
825
+ const HANDOFF_WRITE_ANNOTATIONS = { readOnlyHint: false, openWorldHint: false, destructiveHint: false, idempotentHint: false };
826
+ const workspaceManagers = new Map();
827
+ function workspaceManagerKey(config) {
828
+ return JSON.stringify({
829
+ defaultRoot: config.defaultRoot,
830
+ allowedRoots: [...config.allowedRoots].sort(),
831
+ contextDir: config.contextDir
832
+ });
833
+ }
834
+ function getSharedWorkspaceManager(config) {
835
+ const key = workspaceManagerKey(config);
836
+ const existing = workspaceManagers.get(key);
837
+ if (existing)
838
+ return existing;
839
+ const manager = new WorkspaceManager(config);
840
+ workspaceManagers.set(key, manager);
841
+ return manager;
842
+ }
843
+ export function createCodexFlowServer(config) {
844
+ const workspaceManager = getSharedWorkspaceManager(config);
845
+ const chatSessionId = `codexflow-chat-${randomUUID()}`;
846
+ let activeWorkspaceId;
847
+ const workspaces = {
848
+ defaultWorkspace() {
849
+ const workspace = workspaceManager.defaultWorkspace();
850
+ activeWorkspaceId = workspace.id;
851
+ return workspace;
852
+ },
853
+ openWorkspace(root) {
854
+ const workspace = workspaceManager.openWorkspace(root);
855
+ activeWorkspaceId = workspace.id;
856
+ return workspace;
857
+ },
858
+ getWorkspace(id) {
859
+ if (id && activeWorkspaceId && id !== activeWorkspaceId) {
860
+ throw new CodexFlowError("This ChatGPT conversation is bound to a different project. Call select_project or open_workspace explicitly to switch it first.");
861
+ }
862
+ const workspace = workspaceManager.getWorkspace(id ?? activeWorkspaceId);
863
+ activeWorkspaceId = workspace.id;
864
+ return workspace;
865
+ },
866
+ listWorkspaces() {
867
+ return workspaceManager.listWorkspaces();
868
+ },
869
+ activeWorkspace() {
870
+ return activeWorkspaceId ? workspaceManager.getWorkspace(activeWorkspaceId) : undefined;
871
+ }
872
+ };
873
+ const guard = new PathGuard(config);
874
+ const server = new McpServer({ name: "CodexFlow", version: "0.29.0-beta.1" }, { instructions: serverInstructions(config) });
875
+ registeredToolNamesByServer.set(server, []);
876
+ registerToolCardResource(server, config);
877
+ registerCodexTool(config, server, SUPERTOOL_NAME, {
878
+ title: "CodexFlow Supertool",
879
+ description: "Stable wrapper for advanced ChatGPT connector setups. Pass action plus args to call an already-registered CodexFlow tool without changing the visible schema; it cannot call tools disabled by the current mode.",
880
+ inputSchema: {
881
+ action: z.string().optional().describe("Action or registered tool name. Use list_actions to see what this server mode allows."),
882
+ args: z.record(z.any()).optional().describe("Arguments for the selected action. Same shape as the wrapped CodexFlow tool.")
883
+ },
884
+ annotations: BASH_ANNOTATIONS,
885
+ _meta: {
886
+ ...toolCardMeta(),
887
+ "openai/toolInvocation/invoking": "Running CodexFlow supertool action...",
888
+ "openai/toolInvocation/invoked": "CodexFlow supertool action complete"
889
+ }
890
+ }, async (args) => {
891
+ const action = normalizeSupertoolAction(args.action);
892
+ const names = registeredToolNames(server).filter((name) => name !== SUPERTOOL_NAME);
893
+ if (action === "list_actions" || action === "help") {
894
+ const text = [
895
+ "# CodexFlow Supertool",
896
+ "",
897
+ "Use `codexflow` only when a stable wrapper is useful for ChatGPT connector caching or custom workflows. The explicit tools remain the preferred default because they give clearer descriptions and validation.",
898
+ "",
899
+ "## Available actions",
900
+ "",
901
+ names.length ? names.map((name) => `- ${name}`).join("\n") : "- none",
902
+ "",
903
+ "## Usage",
904
+ "",
905
+ "```json",
906
+ JSON.stringify({ action: "search", args: { workspace_id: "ws_...", query: "needle", path: "src" } }, null, 2),
907
+ "```"
908
+ ].join("\n");
909
+ return textResult(text, {
910
+ actions: names,
911
+ action_count: names.length,
912
+ aliases: SUPERTOOL_ACTION_ALIASES,
913
+ tool_mode: config.toolMode,
914
+ bash_mode: config.bashMode,
915
+ write_mode: config.writeMode
916
+ });
917
+ }
918
+ if (action === SUPERTOOL_NAME) {
919
+ throw new CodexFlowError("codexflow cannot call itself. Use action=list_actions to inspect available wrapped actions.");
920
+ }
921
+ const handler = registeredToolHandler(server, action);
922
+ if (!handler) {
923
+ throw new CodexFlowError(`CodexFlow action is not available in the current mode: ${action}. ` +
924
+ "Call codexflow with action=list_actions, or restart CodexFlow with a broader tool mode if that action should be exposed.");
925
+ }
926
+ const childArgs = args.args && typeof args.args === "object" && !Array.isArray(args.args)
927
+ ? args.args
928
+ : {};
929
+ let result;
930
+ try {
931
+ result = await handler(childArgs);
932
+ }
933
+ catch (error) {
934
+ result = errorResult(error);
935
+ }
936
+ if (result && typeof result === "object") {
937
+ const structured = result.structuredContent;
938
+ result.structuredContent = {
939
+ codexflow_tool: action,
940
+ codexflow_title: action,
941
+ codexflow_super_action: action,
942
+ wrapped_tool: action,
943
+ ...(structured && typeof structured === "object" && !Array.isArray(structured) ? structured : {})
944
+ };
945
+ }
946
+ return result;
947
+ });
948
+ registerCodexTool(config, server, "server_config", {
949
+ title: "Server Config",
950
+ description: "Show CodexFlow server configuration, safety modes, limits, and blocked paths. Does not reveal auth tokens.",
951
+ inputSchema: {},
952
+ annotations: READ_ONLY_ANNOTATIONS,
953
+ _meta: {
954
+ ...toolCardMeta(),
955
+ "openai/toolInvocation/invoking": "Reading CodexFlow server config...",
956
+ "openai/toolInvocation/invoked": "CodexFlow server config ready"
957
+ }
958
+ }, async () => {
959
+ const safeConfig = {
960
+ defaultRoot: config.defaultRoot,
961
+ allowedRoots: config.allowedRoots,
962
+ host: config.host,
963
+ port: config.port,
964
+ widgetDomain: config.widgetDomain,
965
+ authEnabled: Boolean(config.authToken),
966
+ bashMode: config.bashMode,
967
+ bashTranscript: config.bashTranscript,
968
+ bashSessionId: config.bashSessionId ?? null,
969
+ requireBashSession: config.requireBashSession,
970
+ codexSessions: config.codexSessions,
971
+ codexDir: config.codexDir,
972
+ writeMode: config.writeMode,
973
+ toolMode: config.toolMode,
974
+ toolCards: config.toolCards,
975
+ connectionTest: config.connectionTest,
976
+ analysisEnabled: config.analysisEnabled,
977
+ analysisLimits: config.analysisLimits,
978
+ inheritEnv: config.inheritEnv,
979
+ contextDir: config.contextDir,
980
+ maxReadBytes: config.maxReadBytes,
981
+ maxWriteBytes: config.maxWriteBytes,
982
+ maxOutputBytes: config.maxOutputBytes,
983
+ maxSearchResults: config.maxSearchResults,
984
+ blockedGlobs: config.blockedGlobs,
985
+ registeredTools: registeredToolNames(server),
986
+ registeredToolCount: registeredToolNames(server).length
987
+ };
988
+ return textResult(`# CodexFlow Server Config\n\n${JSON.stringify(safeConfig, null, 2)}`, safeConfig);
989
+ });
990
+ registerCodexTool(config, server, "codexflow_self_test", {
991
+ title: "CodexFlow Self Test",
992
+ description: "Run one controlled, local-only CodexFlow diagnostic. It checks modes, expected tools, workspace access, skills, git, safe bash policy, selected-only Pro context, and optional .ai-bridge write/edit probe without touching source files.",
993
+ inputSchema: {
994
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
995
+ write_probe: z.boolean().optional().describe("Create/edit only .ai-bridge/codexflow-self-test.md. Default: true."),
996
+ bash_probe: z.boolean().optional().describe("Check bash policy with safe local commands only. Default: true."),
997
+ pro_context_probe: z.boolean().optional().describe("Build a selected-only Pro context bundle in memory without writing pro-context.md. Default: true."),
998
+ include_global_skills: z.boolean().optional().describe("Include user/plugin skill discovery in the inventory check. Default: true."),
999
+ max_skills: z.number().int().min(1).max(120).optional().describe("Maximum skills to inspect during the inventory check. Default: 40.")
1000
+ },
1001
+ annotations: HANDOFF_WRITE_ANNOTATIONS,
1002
+ _meta: {
1003
+ ...toolCardMeta(),
1004
+ "openai/toolInvocation/invoking": "Running CodexFlow self-test...",
1005
+ "openai/toolInvocation/invoked": "CodexFlow self-test complete"
1006
+ }
1007
+ }, async (args) => {
1008
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1009
+ const started = Date.now();
1010
+ const checks = [];
1011
+ const filesTouched = [];
1012
+ const probePath = `${config.contextDir}/codexflow-self-test.md`;
1013
+ const check = (name, status, detail) => {
1014
+ checks.push({ name, status, detail: cleanOneLine(detail, detail, 260) });
1015
+ };
1016
+ check("workspace", "pass", workspace.root);
1017
+ check("tool mode", config.toolMode === "full" ? "pass" : "warn", `${config.toolMode}; expected tools: ${toolNamesForMode(config).length}`);
1018
+ check("write mode", config.writeMode === "off" ? "warn" : "pass", config.writeMode);
1019
+ check("bash mode", config.bashMode === "full" ? "warn" : "pass", config.bashMode);
1020
+ check("http auth", "pass", config.authToken
1021
+ ? "token configured"
1022
+ : config.requireHttpToken
1023
+ ? "token required when serving HTTP"
1024
+ : "token auth explicitly disabled");
1025
+ const expectedTools = toolNamesForMode(config).sort();
1026
+ const actualTools = registeredToolNames(server).sort();
1027
+ const missingTools = expectedTools.filter((name) => !actualTools.includes(name));
1028
+ const extraTools = actualTools.filter((name) => !expectedTools.includes(name));
1029
+ check("registered tool set", missingTools.length || extraTools.length ? "fail" : "pass", missingTools.length || extraTools.length
1030
+ ? `missing: ${missingTools.join(", ") || "none"}; extra: ${extraTools.join(", ") || "none"}`
1031
+ : `${actualTools.length} tools registered for ${config.toolMode} mode`);
1032
+ try {
1033
+ const inventory = await codexflowInventory(config, workspace, {
1034
+ includeGlobalSkills: parseBool(args.include_global_skills, true),
1035
+ includeMcpServers: true,
1036
+ maxSkills: limitInt(args.max_skills, 40, 1, 120)
1037
+ });
1038
+ check("inventory", "pass", `${inventory.skills.length} skills inspected, ${inventory.mcpServers.length} MCP server names visible`);
1039
+ }
1040
+ catch (error) {
1041
+ check("inventory", "fail", errorText(error));
1042
+ }
1043
+ try {
1044
+ const status = gitStatus(config, workspace);
1045
+ const gitFailed = looksLikeGitError(status);
1046
+ const changed = gitFailed ? 0 : changedStatusLines(status).length;
1047
+ check("git status", gitFailed ? "warn" : "pass", gitFailed ? status : `${changed} changed entries`);
1048
+ }
1049
+ catch (error) {
1050
+ check("git status", "fail", errorText(error));
1051
+ }
1052
+ if (parseBool(args.write_probe, true)) {
1053
+ if (config.writeMode === "off") {
1054
+ check("write/edit probe", "warn", "skipped because CODEXFLOW_WRITE_MODE=off");
1055
+ }
1056
+ else {
1057
+ try {
1058
+ assertWriteToolAllowed(config, probePath);
1059
+ const content = [
1060
+ "# CodexFlow Self Test",
1061
+ "",
1062
+ `Updated: ${new Date().toISOString()}`,
1063
+ `Workspace: ${workspace.root}`,
1064
+ "marker: before",
1065
+ ""
1066
+ ].join("\n");
1067
+ await writeTextFile(config, guard, workspace, probePath, content, { createDirs: true, overwrite: true });
1068
+ await editTextFile(config, guard, workspace, probePath, "marker: before", "marker: after", { expectedReplacements: 1 });
1069
+ const readBack = await readTextFile(config, guard, workspace, probePath, { maxBytes: 20_000 });
1070
+ if (!readBack.text.includes("marker: after"))
1071
+ throw new CodexFlowError("self-test edit marker was not found after edit.");
1072
+ const scopedStatus = gitStatus(config, workspace, guard, probePath);
1073
+ const scopedFiles = changedStatusLines(scopedStatus);
1074
+ filesTouched.push(probePath);
1075
+ check("write/edit probe", scopedFiles.length && scopedFiles.every((line) => line.includes(probePath)) ? "pass" : "warn", scopedFiles.length ? `path-scoped status: ${scopedFiles.join(", ")}` : "path-scoped status clean after write/edit");
1076
+ }
1077
+ catch (error) {
1078
+ check("write/edit probe", "fail", errorText(error));
1079
+ }
1080
+ }
1081
+ }
1082
+ else {
1083
+ check("write/edit probe", "warn", "skipped by request");
1084
+ }
1085
+ if (parseBool(args.pro_context_probe, true)) {
1086
+ try {
1087
+ if (!filesTouched.includes(probePath)) {
1088
+ check("selected-only pro context", "warn", "skipped because write probe did not create the selected file");
1089
+ }
1090
+ else {
1091
+ const context = await buildProContext(config, guard, workspace, {
1092
+ title: "CodexFlow Self Test Context",
1093
+ selectedPaths: [probePath],
1094
+ includeImportantFiles: false,
1095
+ includeChangedFiles: false,
1096
+ includeDiff: false,
1097
+ includeAiBridge: false,
1098
+ maxFiles: 4,
1099
+ maxTotalBytes: 80_000
1100
+ });
1101
+ const exactOnly = context.filesIncluded.length === 1 && context.filesIncluded[0] === probePath;
1102
+ check("selected-only pro context", exactOnly ? "pass" : "fail", exactOnly ? `included only ${probePath}` : `included ${context.filesIncluded.join(", ") || "no files"}`);
1103
+ }
1104
+ }
1105
+ catch (error) {
1106
+ check("selected-only pro context", "fail", errorText(error));
1107
+ }
1108
+ }
1109
+ else {
1110
+ check("selected-only pro context", "warn", "skipped by request");
1111
+ }
1112
+ if (parseBool(args.bash_probe, true)) {
1113
+ try {
1114
+ if (config.bashMode === "off") {
1115
+ check("bash policy", "warn", "bash disabled");
1116
+ }
1117
+ else {
1118
+ const bashProbeOptions = { timeoutMs: 10_000, sessionId: config.bashSessionId };
1119
+ const pwd = await runBash(config, guard, workspace, "pwd", bashProbeOptions);
1120
+ if (config.bashMode === "safe") {
1121
+ try {
1122
+ await runBash(config, guard, workspace, "ls $HOME", bashProbeOptions);
1123
+ check("bash policy", "fail", "safe bash allowed environment expansion unexpectedly");
1124
+ }
1125
+ catch {
1126
+ check("bash policy", pwd.exitCode === 0 ? "pass" : "warn", "safe bash allowed pwd and blocked environment expansion");
1127
+ }
1128
+ }
1129
+ else {
1130
+ check("bash policy", pwd.exitCode === 0 ? "warn" : "fail", "full bash is enabled; use only for trusted local repos");
1131
+ }
1132
+ }
1133
+ }
1134
+ catch (error) {
1135
+ check("bash policy", "fail", errorText(error));
1136
+ }
1137
+ }
1138
+ else {
1139
+ check("bash policy", "warn", "skipped by request");
1140
+ }
1141
+ check("terms boundary", "pass", "local workspace bridge only; does not provide models, proxy model access, bypass quotas, or execute remote/local agents from MCP");
1142
+ const failed = checks.filter((item) => item.status === "fail").length;
1143
+ const warned = checks.filter((item) => item.status === "warn").length;
1144
+ const passed = checks.filter((item) => item.status === "pass").length;
1145
+ const status = failed ? "fail" : warned ? "warn" : "pass";
1146
+ const text = [
1147
+ "# CodexFlow Self Test",
1148
+ "",
1149
+ `Status: ${status}`,
1150
+ `Workspace: ${workspace.root}`,
1151
+ `Mode: tools=${config.toolMode}, write=${config.writeMode}, bash=${config.bashMode}${config.bashSessionId ? `, bash_session=${config.bashSessionId}${config.requireBashSession ? " required" : ""}` : ""}`,
1152
+ `Expected tools: ${expectedTools.length}`,
1153
+ `Registered tools: ${actualTools.length}`,
1154
+ `Duration: ${Date.now() - started} ms`,
1155
+ "",
1156
+ "## Checks",
1157
+ "",
1158
+ ...checks.map((item) => `- ${item.status.toUpperCase()} ${item.name}: ${item.detail}`),
1159
+ "",
1160
+ "## Terms Boundary",
1161
+ "",
1162
+ "CodexFlow exposes local repo tools to the ChatGPT session the user controls. It does not provide models, proxy model access, resell access, modify quotas, bypass limits, or run local implementation agents through remote MCP tools."
1163
+ ].join("\n");
1164
+ return textResult(text, {
1165
+ workspace_id: workspace.id,
1166
+ root: workspace.root,
1167
+ status,
1168
+ passed,
1169
+ warned,
1170
+ failed,
1171
+ duration_ms: Date.now() - started,
1172
+ expected_tools: expectedTools,
1173
+ expected_tool_count: expectedTools.length,
1174
+ registered_tools: actualTools,
1175
+ registered_tool_count: actualTools.length,
1176
+ bash_mode: config.bashMode,
1177
+ bash_session_id: config.bashSessionId ?? null,
1178
+ require_bash_session: config.requireBashSession,
1179
+ write_mode: config.writeMode,
1180
+ tool_mode: config.toolMode,
1181
+ files_touched: filesTouched,
1182
+ checks,
1183
+ terms_boundary: {
1184
+ local_workspace_bridge: true,
1185
+ provides_models: false,
1186
+ proxies_model_access: false,
1187
+ bypasses_quotas: false,
1188
+ remote_agent_execution: false
1189
+ }
1190
+ });
1191
+ });
1192
+ registerCodexTool(config, server, "codexflow_inventory", {
1193
+ title: "CodexFlow Inventory",
1194
+ description: "List CodexFlow modes plus discovered skills, locally available Codex plugin manifests, and configured MCP server names. Use this early when planning needs local agent capabilities.",
1195
+ inputSchema: {
1196
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1197
+ include_global_skills: z.boolean().optional().describe("Include user and plugin skill folders. Default: true."),
1198
+ include_mcp_servers: z.boolean().optional().describe("Include configured MCP server names from safe config files. Default: true."),
1199
+ max_skills: z.number().int().min(1).max(500).optional().describe("Maximum skills to list. Default: 120.")
1200
+ },
1201
+ annotations: READ_ONLY_ANNOTATIONS,
1202
+ _meta: {
1203
+ ...toolCardMeta(),
1204
+ "openai/toolInvocation/invoking": "Reading CodexFlow inventory...",
1205
+ "openai/toolInvocation/invoked": "CodexFlow inventory ready"
1206
+ }
1207
+ }, async (args) => {
1208
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1209
+ const inventory = await codexflowInventory(config, workspace, {
1210
+ includeGlobalSkills: parseBool(args.include_global_skills, true),
1211
+ includeMcpServers: parseBool(args.include_mcp_servers, true),
1212
+ maxSkills: limitInt(args.max_skills, 120, 1, 500)
1213
+ });
1214
+ return textResult(inventory.text, {
1215
+ workspace_id: workspace.id,
1216
+ root: workspace.root,
1217
+ bash_mode: config.bashMode,
1218
+ write_mode: config.writeMode,
1219
+ tool_mode: config.toolMode,
1220
+ skills: inventory.skills,
1221
+ skill_count: inventory.skills.length,
1222
+ plugins: inventory.plugins,
1223
+ plugin_count: inventory.plugins.length,
1224
+ mcp_servers: inventory.mcpServers,
1225
+ mcp_server_count: inventory.mcpServers.length,
1226
+ widget_uri: TOOL_CARD_URI
1227
+ });
1228
+ });
1229
+ registerCodexTool(config, server, "load_skill", {
1230
+ title: "Load Skill",
1231
+ description: "Load the bounded SKILL.md body for a discovered workspace, user, or plugin skill by name. Does not accept arbitrary paths; use after open_current_workspace/open_workspace shows skill_inventory.",
1232
+ inputSchema: {
1233
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1234
+ name: z.string().describe("Exact skill name from skill_inventory or codexflow_inventory."),
1235
+ source: z.enum(["workspace", "user", "plugin", "other"]).optional().describe("Optional source when multiple skills share a name."),
1236
+ path: z.string().optional().describe("Exact sanitized path from skill_inventory when name/source are still ambiguous."),
1237
+ include_global_skills: z.boolean().optional().describe("Also scan installed user/plugin skills. Default: auto when source/path is not workspace."),
1238
+ max_skills: z.number().int().min(1).max(500).optional().describe("Maximum skills to scan while resolving the requested skill. Default: 500."),
1239
+ max_bytes: z.number().int().min(1000).max(100000).optional().describe("Maximum bytes to return from SKILL.md. Default: 40000.")
1240
+ },
1241
+ annotations: READ_ONLY_ANNOTATIONS,
1242
+ _meta: {
1243
+ ...toolCardMeta(),
1244
+ "openai/toolInvocation/invoking": "Loading skill instructions...",
1245
+ "openai/toolInvocation/invoked": "Skill instructions loaded"
1246
+ }
1247
+ }, async (args) => {
1248
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1249
+ const requestedPath = typeof args.path === "string" ? args.path : undefined;
1250
+ const includeGlobalDefault = args.source === undefined ||
1251
+ (args.source !== undefined && args.source !== "workspace") ||
1252
+ Boolean(requestedPath && !requestedPath.startsWith("$WORKSPACE/"));
1253
+ const loaded = await loadSkill(workspace, {
1254
+ name: String(args.name ?? ""),
1255
+ source: args.source,
1256
+ path: requestedPath,
1257
+ includeGlobal: parseBool(args.include_global_skills, includeGlobalDefault),
1258
+ maxSkills: limitInt(args.max_skills, 500, 1, 500),
1259
+ maxBytes: limitInt(args.max_bytes, 40_000, 1_000, 100_000)
1260
+ });
1261
+ const truncated = loaded.truncated ? "\n\n[truncated: increase max_bytes if more context is required]" : "";
1262
+ const text = `# Load Skill\n\nName: ${loaded.skill.name}\nSource: ${loaded.skill.source}\nPath: ${loaded.skill.path}\nBytes: ${loaded.bytes}/${loaded.totalBytes}\n\n\`\`\`markdown\n${loaded.text}${truncated}\n\`\`\``;
1263
+ return textResult(text, {
1264
+ workspace_id: workspace.id,
1265
+ root: workspace.root,
1266
+ skill: loaded.skill,
1267
+ bytes: loaded.bytes,
1268
+ total_bytes: loaded.totalBytes,
1269
+ truncated: loaded.truncated,
1270
+ text: loaded.text
1271
+ });
1272
+ });
1273
+ registerCodexTool(config, server, "list_projects", {
1274
+ title: "Choose A Project",
1275
+ description: "Call this first in a new CodexFlow coding chat. It synchronizes configured folders with recent local Codex project folders and shows a project picker; it does not run the Codex CLI.",
1276
+ inputSchema: {
1277
+ refresh: z.boolean().optional().describe("Rescan configured roots and local Codex project metadata. Default: false."),
1278
+ query: z.string().optional().describe("Optional case-insensitive filter over project name and path."),
1279
+ max_projects: z.number().int().min(1).max(250).optional().describe("Maximum projects to show. Default: 100.")
1280
+ },
1281
+ annotations: SESSION_READ_ANNOTATIONS,
1282
+ _meta: {
1283
+ ...toolCardMeta(),
1284
+ "codexflow/alwaysWidget": true,
1285
+ "openai/widgetAccessible": true,
1286
+ "openai/toolInvocation/invoking": "Synchronizing local projects...",
1287
+ "openai/toolInvocation/invoked": "Choose a local project"
1288
+ }
1289
+ }, async (args) => {
1290
+ const candidates = await discoverProjects(config, {
1291
+ refresh: parseBool(args.refresh, false),
1292
+ maxProjects: limitInt(args.max_projects, 100, 1, 250)
1293
+ });
1294
+ const query = String(args.query ?? "").trim().toLowerCase();
1295
+ const projects = candidates.flatMap((candidate) => {
1296
+ if (query && !`${candidate.name}\n${candidate.root}`.toLowerCase().includes(query))
1297
+ return [];
1298
+ const workspace = workspaceManager.openWorkspace(candidate.root);
1299
+ return [{
1300
+ project_id: workspace.id,
1301
+ name: candidate.name,
1302
+ root: candidate.root,
1303
+ sources: candidate.sources,
1304
+ last_active_at: candidate.lastActiveAt ?? null,
1305
+ selected: workspace.id === activeWorkspaceId
1306
+ }];
1307
+ });
1308
+ const rows = projects.length
1309
+ ? projects.map((project) => `- ${project.project_id} — ${project.name} — ${project.root}${project.selected ? " (selected)" : ""}`).join("\n")
1310
+ : "No projects found inside the configured allowed roots.";
1311
+ return textResult(`# Select a project\n\nChoose one project before doing repository work.\n\n${rows}`, {
1312
+ projects,
1313
+ count: projects.length,
1314
+ selected_project_id: activeWorkspaceId ?? null,
1315
+ picker: true,
1316
+ sync_sources: ["configured roots", "recent Codex project metadata"],
1317
+ allowed_roots: config.allowedRoots
1318
+ }, { "openai/widgetSessionId": chatSessionId });
1319
+ });
1320
+ registerCodexTool(config, server, "select_project", {
1321
+ title: "Select Project",
1322
+ description: "Bind this ChatGPT conversation to one synchronized local project. All later CodexFlow file, git, search, edit, and terminal tools route there when workspace_id is omitted.",
1323
+ inputSchema: {
1324
+ project_id: z.string().optional().describe("Project id returned by list_projects."),
1325
+ name: z.string().optional().describe("Exact project name from list_projects when project_id is unavailable."),
1326
+ include_tree: z.boolean().optional().describe("Include a compact initial tree. Default: true."),
1327
+ max_depth: z.number().int().min(1).max(8).optional().describe("Initial tree depth. Default: 2.")
1328
+ },
1329
+ annotations: SESSION_READ_ANNOTATIONS,
1330
+ _meta: {
1331
+ ...toolCardMeta(),
1332
+ "codexflow/alwaysWidget": true,
1333
+ "openai/widgetAccessible": true,
1334
+ "openai/toolInvocation/invoking": "Binding this chat to the project...",
1335
+ "openai/toolInvocation/invoked": "Project selected"
1336
+ }
1337
+ }, async (args) => {
1338
+ if (!args.project_id && !args.name)
1339
+ throw new CodexFlowError("project_id or name is required. Call list_projects first.");
1340
+ const candidates = await discoverProjects(config, { maxProjects: 250 });
1341
+ const choices = candidates.map((candidate) => ({ candidate, workspace: workspaceManager.openWorkspace(candidate.root) }));
1342
+ let matches = args.project_id
1343
+ ? choices.filter((choice) => choice.workspace.id === args.project_id)
1344
+ : choices.filter((choice) => choice.candidate.name.toLowerCase() === String(args.name).trim().toLowerCase());
1345
+ if (matches.length > 1)
1346
+ throw new CodexFlowError(`Multiple projects are named ${args.name}. Select one by project_id.`);
1347
+ const selected = matches[0];
1348
+ if (!selected)
1349
+ throw new CodexFlowError("Project not found in the synchronized catalog. Call list_projects with refresh=true.");
1350
+ activeWorkspaceId = selected.workspace.id;
1351
+ const [summary, inventory] = await Promise.all([
1352
+ workspaceSummary(config, guard, selected.workspace, {
1353
+ includeTree: parseBool(args.include_tree, true),
1354
+ maxDepth: limitInt(args.max_depth, 2, 1, 8),
1355
+ includeSkills: false,
1356
+ bootstrapContext: false
1357
+ }),
1358
+ codexflowInventory(config, selected.workspace, { includeGlobalSkills: true, includeMcpServers: true, maxSkills: 120 })
1359
+ ]);
1360
+ const pluginSkills = inventory.skills.filter((skill) => skill.source === "plugin");
1361
+ const text = [
1362
+ `# Project selected: ${selected.candidate.name}`,
1363
+ "",
1364
+ `Project ID: ${selected.workspace.id}`,
1365
+ `Root: ${selected.workspace.root}`,
1366
+ "This ChatGPT conversation is now routed to this project. Omit workspace_id on subsequent calls.",
1367
+ "",
1368
+ summary.agentsLoaded ? `Repository instructions: ${summary.agentsPath}` : "Repository instructions: none found",
1369
+ `Skills advertised: ${inventory.skills.length}`,
1370
+ `Plugins advertised: ${inventory.plugins.length}`,
1371
+ `Plugin skills advertised: ${pluginSkills.length}`,
1372
+ `Configured MCP servers advertised: ${inventory.mcpServers.length}`
1373
+ ].join("\n");
1374
+ return textResult(text, {
1375
+ selected: true,
1376
+ project_id: selected.workspace.id,
1377
+ workspace_id: selected.workspace.id,
1378
+ name: selected.candidate.name,
1379
+ root: selected.workspace.root,
1380
+ sources: selected.candidate.sources,
1381
+ agents_loaded: summary.agentsLoaded,
1382
+ agents_path: summary.agentsPath,
1383
+ tree: summary.tree,
1384
+ git_status: summary.gitStatus,
1385
+ skills: inventory.skills,
1386
+ skill_count: inventory.skills.length,
1387
+ plugins: inventory.plugins,
1388
+ plugin_count: inventory.plugins.length,
1389
+ plugin_skills: pluginSkills,
1390
+ mcp_servers: inventory.mcpServers,
1391
+ mcp_server_count: inventory.mcpServers.length,
1392
+ bash_mode: config.bashMode,
1393
+ write_mode: config.writeMode,
1394
+ tool_mode: config.toolMode
1395
+ }, { "openai/widgetSessionId": chatSessionId });
1396
+ });
1397
+ registerCodexTool(config, server, "list_workspaces", {
1398
+ title: "List Workspaces",
1399
+ description: "List currently opened CodexFlow workspaces for this server/config.",
1400
+ inputSchema: {},
1401
+ annotations: READ_ONLY_ANNOTATIONS,
1402
+ _meta: {
1403
+ ...toolCardMeta(),
1404
+ "openai/toolInvocation/invoking": "Listing CodexFlow workspaces...",
1405
+ "openai/toolInvocation/invoked": "CodexFlow workspaces listed"
1406
+ }
1407
+ }, async () => {
1408
+ const current = workspaces.listWorkspaces();
1409
+ const text = current.length
1410
+ ? current.map((workspace) => `- ${workspace.id} — ${workspace.root} (opened ${workspace.openedAt})`).join("\n")
1411
+ : "No workspaces opened on this CodexFlow server/config yet. Call open_workspace first.";
1412
+ return textResult(text, { workspaces: current, count: current.length });
1413
+ });
1414
+ registerCodexTool(config, server, "open_current_workspace", {
1415
+ title: "Open Current Workspace",
1416
+ description: "Open the project already selected for this ChatGPT conversation, or the configured default when no project has been selected yet.",
1417
+ inputSchema: {
1418
+ include_tree: z.boolean().optional().describe("Include a compact file tree. Default: false for speed."),
1419
+ max_depth: z.number().int().min(1).max(8).optional().describe("Tree depth when include_tree=true. Default: 2."),
1420
+ include_skills: z.boolean().optional().describe("Discover skills by name/description. Default: false for speed."),
1421
+ include_global_skills: z.boolean().optional().describe("Also scan installed user/plugin skills when include_skills=true. Default: false.")
1422
+ },
1423
+ annotations: SESSION_READ_ANNOTATIONS,
1424
+ _meta: {
1425
+ ...toolCardMeta(),
1426
+ "openai/toolInvocation/invoking": "Opening current CodexFlow workspace...",
1427
+ "openai/toolInvocation/invoked": "Current CodexFlow workspace opened"
1428
+ }
1429
+ }, async (args) => {
1430
+ const workspace = workspaces.activeWorkspace() ?? workspaces.defaultWorkspace();
1431
+ const summary = await workspaceSummary(config, guard, workspace, {
1432
+ includeTree: parseBool(args.include_tree, false),
1433
+ maxDepth: limitInt(args.max_depth, 2, 1, 8),
1434
+ includeSkills: parseBool(args.include_skills, false),
1435
+ includeGlobalSkills: parseBool(args.include_global_skills, false),
1436
+ bootstrapContext: false
1437
+ });
1438
+ return textResult(summary.text, {
1439
+ workspace_id: summary.workspaceId,
1440
+ root: summary.root,
1441
+ agents_loaded: summary.agentsLoaded,
1442
+ agents_path: summary.agentsPath,
1443
+ skills: summary.skills,
1444
+ skill_inventory: summary.skillInventory,
1445
+ skill_counts: summary.skillCounts,
1446
+ tree: summary.tree,
1447
+ git_status: summary.gitStatus,
1448
+ bash_mode: config.bashMode,
1449
+ write_mode: config.writeMode,
1450
+ tool_mode: config.toolMode
1451
+ });
1452
+ });
1453
+ registerCodexTool(config, server, "open_workspace", {
1454
+ title: "Open Workspace",
1455
+ description: "Open a local project directory as a CodexFlow workspace. Returns a workspace_id plus git status, AGENTS.md, and a compact file tree.",
1456
+ inputSchema: {
1457
+ root: z.string().optional().describe("Project directory to open. Omit to use CODEXFLOW_ROOT/current working directory. Supports ~/ paths."),
1458
+ path: z.string().optional().describe("Alias for root. Useful for clients that naturally send path instead of root."),
1459
+ include_tree: z.boolean().optional().describe("Include a compact file tree. Default: true."),
1460
+ max_depth: z.number().int().min(1).max(8).optional().describe("Tree depth. Default: 3."),
1461
+ max_files: z.number().int().min(1).max(3000).optional().describe("Alias for maximum tree entries. Default: 500."),
1462
+ include_skills: z.boolean().optional().describe("Discover skills by name/description. Default: false for speed."),
1463
+ include_global_skills: z.boolean().optional().describe("Also scan installed user/plugin skills when include_skills=true. Default: false."),
1464
+ bootstrap_context: z.boolean().optional().describe("Deprecated and ignored. Use handoff_to_agent to create .ai-bridge files.")
1465
+ },
1466
+ annotations: SESSION_READ_ANNOTATIONS,
1467
+ _meta: {
1468
+ ...toolCardMeta(),
1469
+ "openai/toolInvocation/invoking": "Opening CodexFlow workspace...",
1470
+ "openai/toolInvocation/invoked": "CodexFlow workspace opened"
1471
+ }
1472
+ }, async (args) => {
1473
+ if (args.root && args.path && args.root !== args.path) {
1474
+ throw new CodexFlowError("open_workspace accepts either root or path. If both are provided, they must match.");
1475
+ }
1476
+ const requestedRoot = args.root ?? args.path;
1477
+ const workspace = requestedRoot
1478
+ ? workspaces.openWorkspace(requestedRoot)
1479
+ : workspaces.activeWorkspace() ?? workspaces.defaultWorkspace();
1480
+ const summary = await workspaceSummary(config, guard, workspace, {
1481
+ includeTree: args.include_tree !== false,
1482
+ maxDepth: limitInt(args.max_depth, 3, 1, 8),
1483
+ maxEntries: limitInt(args.max_files, 500, 1, 3000),
1484
+ includeSkills: parseBool(args.include_skills, false),
1485
+ includeGlobalSkills: parseBool(args.include_global_skills, false),
1486
+ bootstrapContext: false
1487
+ });
1488
+ return textResult(summary.text, {
1489
+ workspace_id: summary.workspaceId,
1490
+ root: summary.root,
1491
+ agents_loaded: summary.agentsLoaded,
1492
+ agents_path: summary.agentsPath,
1493
+ skills: summary.skills,
1494
+ skill_inventory: summary.skillInventory,
1495
+ skill_counts: summary.skillCounts,
1496
+ tree: summary.tree,
1497
+ git_status: summary.gitStatus,
1498
+ bash_mode: config.bashMode,
1499
+ write_mode: config.writeMode,
1500
+ tool_mode: config.toolMode
1501
+ });
1502
+ });
1503
+ registerCodexTool(config, server, "workspace_snapshot", {
1504
+ title: "Workspace Snapshot",
1505
+ description: "Return git status, recent commits, .ai-bridge context, and a compact tree for an opened workspace.",
1506
+ inputSchema: {
1507
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1508
+ max_depth: z.number().int().min(1).max(8).optional().describe("Tree depth. Default: 3."),
1509
+ max_files: z.number().int().min(1).max(3000).optional().describe("Alias for maximum tree entries. Default: 500."),
1510
+ include_skills: z.boolean().optional().describe("Discover repo-local skills. Default: false for speed."),
1511
+ include_global_skills: z.boolean().optional().describe("Also scan home-level skill folders when include_skills=true. Default: false.")
1512
+ },
1513
+ annotations: READ_ONLY_ANNOTATIONS,
1514
+ _meta: {
1515
+ ...toolCardMeta(),
1516
+ "openai/toolInvocation/invoking": "Collecting workspace snapshot...",
1517
+ "openai/toolInvocation/invoked": "Workspace snapshot ready"
1518
+ }
1519
+ }, async (args) => {
1520
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1521
+ const summary = await workspaceSummary(config, guard, workspace, {
1522
+ includeTree: true,
1523
+ maxDepth: limitInt(args.max_depth, 3, 1, 8),
1524
+ maxEntries: limitInt(args.max_files, 500, 1, 3000),
1525
+ includeSkills: parseBool(args.include_skills, false),
1526
+ includeGlobalSkills: parseBool(args.include_global_skills, false)
1527
+ });
1528
+ const ai = await readAiBridgeContext(config, guard, workspace);
1529
+ const text = `${summary.text}\n\n## AI handoff context\n\n${ai.text}`;
1530
+ return textResult(text, {
1531
+ workspace_id: workspace.id,
1532
+ root: workspace.root,
1533
+ agents_loaded: summary.agentsLoaded,
1534
+ agents_path: summary.agentsPath,
1535
+ skills: summary.skills,
1536
+ skill_inventory: summary.skillInventory,
1537
+ skill_counts: summary.skillCounts,
1538
+ tree: summary.tree,
1539
+ git_status: summary.gitStatus,
1540
+ ai_context_files: ai.files,
1541
+ bash_mode: config.bashMode,
1542
+ write_mode: config.writeMode,
1543
+ tool_mode: config.toolMode
1544
+ });
1545
+ });
1546
+ registerCodexTool(config, server, "inspect_workspace", {
1547
+ title: "Inspect Workspace",
1548
+ description: "Build a bounded repository map with languages, project types, entrypoints, areas, symbols, relationships, and coverage warnings.",
1549
+ inputSchema: {
1550
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1551
+ path: z.string().optional().describe("Optional workspace-relative area to emphasize. Default: entire workspace."),
1552
+ max_files: z.number().int().min(1).max(100000).optional().describe("Maximum returned file records. Default: 300."),
1553
+ include_symbols: z.boolean().optional().describe("Include symbols in structured output. Default: true."),
1554
+ include_relationships: z.boolean().optional().describe("Include relationships in structured output. Default: true."),
1555
+ max_symbols: z.number().int().min(1).max(100000).optional().describe("Maximum returned symbols. Analysis remains bounded by server config."),
1556
+ max_relationships: z.number().int().min(1).max(250000).optional().describe("Maximum returned relationships. Analysis remains bounded by server config.")
1557
+ },
1558
+ annotations: READ_ONLY_ANNOTATIONS,
1559
+ _meta: {
1560
+ ...toolCardMeta(),
1561
+ "openai/toolInvocation/invoking": "Inspecting workspace analysis...",
1562
+ "openai/toolInvocation/invoked": "Workspace analysis ready"
1563
+ }
1564
+ }, async (args) => {
1565
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1566
+ if (args.path)
1567
+ guard.resolve(workspace, args.path);
1568
+ const result = await inspectWorkspace(config, guard, workspace);
1569
+ const prefix = typeof args.path === "string" && args.path.trim()
1570
+ ? guard.resolve(workspace, args.path).relPath.replace(/^\.\/?$/, "")
1571
+ : "";
1572
+ const inScope = (filePath) => !prefix || filePath === prefix || filePath.startsWith(`${prefix}/`);
1573
+ const areaInScope = (areaPath) => !prefix || areaPath === "." || inScope(areaPath) || prefix.startsWith(`${areaPath}/`);
1574
+ const fileLimit = config.toolCards ? 120 : limitInt(args.max_files, 300, 1, config.analysisLimits.maxInventoryFiles);
1575
+ const symbolLimit = config.toolCards ? 80 : limitInt(args.max_symbols, 500, 1, config.analysisLimits.maxSymbols);
1576
+ const relationshipLimit = config.toolCards ? 120 : limitInt(args.max_relationships, 800, 1, config.analysisLimits.maxRelationships);
1577
+ const scopedFiles = result.files.filter((file) => inScope(file.path));
1578
+ const scopedSymbols = result.symbols.filter((symbol) => inScope(symbol.path));
1579
+ const scopedRelationships = result.relationships.filter((relationship) => inScope(relationship.from) || inScope(relationship.to));
1580
+ const files = scopedFiles.slice(0, fileLimit);
1581
+ const symbols = args.include_symbols === false
1582
+ ? []
1583
+ : scopedSymbols.slice(0, symbolLimit);
1584
+ const relationships = args.include_relationships === false
1585
+ ? []
1586
+ : scopedRelationships.slice(0, relationshipLimit);
1587
+ const outputLimited = files.length < scopedFiles.length ||
1588
+ (args.include_symbols !== false && symbols.length < scopedSymbols.length) ||
1589
+ (args.include_relationships !== false && relationships.length < scopedRelationships.length);
1590
+ const outputWarnings = [
1591
+ ...result.warnings,
1592
+ ...(outputLimited ? ["Structured output was limited. Use path or max_* arguments to request a narrower or larger result."] : [])
1593
+ ];
1594
+ const text = [
1595
+ "# Workspace Analysis",
1596
+ "",
1597
+ `Workspace: ${workspace.root}`,
1598
+ `Projects: ${result.projectTypes.join(", ") || "unknown"}`,
1599
+ `Languages: ${result.languages.join(", ") || "unknown"}`,
1600
+ `Entrypoints: ${result.entrypoints.filter(inScope).join(", ") || "none detected"}`,
1601
+ `Coverage: ${result.coverage.analyzedFiles}/${result.coverage.inventoryFiles} files analyzed, ${result.coverage.symbolCount} symbols, ${result.coverage.relationshipCount} relationships${result.coverage.truncated ? " (partial)" : ""}`,
1602
+ `Returned: ${files.length} files, ${symbols.length} symbols, ${relationships.length} relationships`,
1603
+ ...(outputWarnings.length ? ["", "## Warnings", "", ...outputWarnings.map((warning) => `- ${warning}`)] : [])
1604
+ ].join("\n");
1605
+ return textResult(text, {
1606
+ schema_version: 1,
1607
+ workspace_id: workspace.id,
1608
+ root: workspace.root,
1609
+ path: args.path ?? ".",
1610
+ languages: result.languages,
1611
+ project_types: result.projectTypes,
1612
+ entrypoints: result.entrypoints.filter(inScope),
1613
+ important_files: result.importantFiles.filter(inScope),
1614
+ areas: result.areas.filter((area) => areaInScope(area.path)),
1615
+ files,
1616
+ symbols,
1617
+ relationships,
1618
+ coverage: result.coverage,
1619
+ warnings: outputWarnings,
1620
+ output_limited: outputLimited,
1621
+ returned: { files: files.length, symbols: symbols.length, relationships: relationships.length },
1622
+ cache: result.cache
1623
+ });
1624
+ });
1625
+ registerCodexTool(config, server, "tree", {
1626
+ title: "File Tree",
1627
+ description: "List files and directories inside the workspace, excluding blocked paths.",
1628
+ inputSchema: {
1629
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1630
+ path: z.string().optional().describe("Directory relative to workspace root. Default: ."),
1631
+ max_depth: z.number().int().min(1).max(12).optional().describe("Maximum depth. Default: 4."),
1632
+ include_hidden: z.boolean().optional().describe("Include dotfiles/dotfolders that are not blocked. Default: false."),
1633
+ max_entries: z.number().int().min(1).max(3000).optional().describe("Maximum entries. Default: 800.")
1634
+ },
1635
+ annotations: READ_ONLY_ANNOTATIONS,
1636
+ _meta: {
1637
+ ...toolCardMeta(),
1638
+ "openai/toolInvocation/invoking": "Listing workspace files...",
1639
+ "openai/toolInvocation/invoked": "Workspace files listed"
1640
+ }
1641
+ }, async (args) => {
1642
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1643
+ const result = await repoTree(config, guard, workspace, {
1644
+ path: args.path ?? ".",
1645
+ maxDepth: limitInt(args.max_depth, 4, 1, 12),
1646
+ includeHidden: parseBool(args.include_hidden, false),
1647
+ maxEntries: limitInt(args.max_entries, 800, 1, 3000)
1648
+ });
1649
+ return textResult(result.text, { workspace_id: workspace.id, root: workspace.root, ...result });
1650
+ });
1651
+ registerCodexTool(config, server, "search", {
1652
+ title: "Search Files",
1653
+ description: "Use this for targeted verification or code lookup. Prefer one specific final search instead of repeated broad verification searches.",
1654
+ inputSchema: {
1655
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1656
+ query: z.string().describe("Text or regex to search for."),
1657
+ regex: z.boolean().optional().describe("Treat query as a regular expression. Requires ripgrep. Default: false."),
1658
+ path: z.string().optional().describe("Directory or file relative to workspace root. Default: ."),
1659
+ glob: z.string().optional().describe("Optional glob, for example src/**/*.ts."),
1660
+ include_hidden: z.boolean().optional().describe("Include hidden files that are not blocked. Default: false."),
1661
+ max_results: z.number().int().min(1).max(2000).optional().describe("Maximum results. Default from config."),
1662
+ intent: z.enum(["auto", "text", "symbol", "references", "impact"]).optional().describe("Optional structured search intent. Omit for legacy lexical behavior."),
1663
+ symbol: z.string().optional().describe("Optional symbol query. Uses repository analysis and overrides query text."),
1664
+ include_tests: z.boolean().optional().describe("Include related tests in structured results. Default: false.")
1665
+ },
1666
+ annotations: READ_ONLY_ANNOTATIONS,
1667
+ _meta: {
1668
+ ...toolCardMeta(),
1669
+ "openai/toolInvocation/invoking": "Searching workspace...",
1670
+ "openai/toolInvocation/invoked": "Workspace search complete"
1671
+ }
1672
+ }, async (args) => {
1673
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1674
+ const result = await searchWorkspace(config, guard, workspace, {
1675
+ query: args.query,
1676
+ regex: parseBool(args.regex, false),
1677
+ root: args.path ?? ".",
1678
+ glob: args.glob,
1679
+ includeHidden: parseBool(args.include_hidden, false),
1680
+ maxResults: limitInt(args.max_results, config.maxSearchResults, 1, config.maxSearchResults),
1681
+ intent: args.intent,
1682
+ symbol: args.symbol,
1683
+ includeTests: args.include_tests === undefined ? undefined : parseBool(args.include_tests, false)
1684
+ });
1685
+ const structured = {
1686
+ workspace_id: workspace.id,
1687
+ root: workspace.root,
1688
+ matches: result.matches,
1689
+ truncated: result.truncated,
1690
+ used: result.used
1691
+ };
1692
+ if (result.analysis) {
1693
+ structured.analysis = config.toolCards
1694
+ ? {
1695
+ ...result.analysis,
1696
+ groups: Object.fromEntries(Object.entries(result.analysis.groups).map(([name, matches]) => [name, matches.slice(0, 24)])),
1697
+ matches: result.analysis.matches.slice(0, 80)
1698
+ }
1699
+ : result.analysis;
1700
+ }
1701
+ // The tool card widget renders search hits from structuredContent.text.
1702
+ // When cards are disabled (the default), including it would only duplicate
1703
+ // the human-readable content payload, so omit the large blob in that case.
1704
+ if (config.toolCards)
1705
+ structured.text = result.text;
1706
+ return textResult(result.text, structured);
1707
+ });
1708
+ registerCodexTool(config, server, "read", {
1709
+ title: "Read File",
1710
+ description: "Read a specific text file with line numbers. Avoid rereading files after write/edit/apply_patch unless exact final content is needed.",
1711
+ inputSchema: {
1712
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1713
+ path: z.string().describe("File path relative to workspace root."),
1714
+ start_line: z.number().int().min(1).optional().describe("First line to read. Default: 1."),
1715
+ end_line: z.number().int().min(1).optional().describe("Last line to read. Default: end of file."),
1716
+ max_bytes: z.number().int().min(1000).max(2000000).optional().describe("Maximum file bytes. Capped by server config.")
1717
+ },
1718
+ annotations: READ_ONLY_ANNOTATIONS,
1719
+ _meta: {
1720
+ ...toolCardMeta(),
1721
+ "openai/toolInvocation/invoking": "Reading file...",
1722
+ "openai/toolInvocation/invoked": "File read"
1723
+ }
1724
+ }, async (args) => {
1725
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1726
+ const result = await readTextFile(config, guard, workspace, args.path, {
1727
+ startLine: args.start_line,
1728
+ endLine: args.end_line,
1729
+ maxBytes: args.max_bytes
1730
+ });
1731
+ const text = `# Read File\n\nPath: ${result.path}\nLines: ${result.startLine}-${result.endLine} of ${result.totalLines}\nBytes: ${result.bytes}\nSHA-256: ${result.sha256}\n\n\`\`\`text\n${result.text}\n\`\`\``;
1732
+ return textResult(text, { workspace_id: workspace.id, root: workspace.root, ...result });
1733
+ });
1734
+ registerCodexTool(config, server, "write", {
1735
+ title: "Write File",
1736
+ description: "Create or overwrite a meaningful text file inside the workspace. Returns a unified diff; do not create empty placeholder files.",
1737
+ inputSchema: {
1738
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1739
+ path: z.string().describe("File path relative to workspace root."),
1740
+ content: z.string().describe("Complete file contents to write."),
1741
+ create_dirs: z.boolean().optional().describe("Create parent directories if missing. Default: true."),
1742
+ overwrite: z.boolean().optional().describe("Allow overwriting existing files. Default: true.")
1743
+ },
1744
+ annotations: LOCAL_WRITE_ANNOTATIONS,
1745
+ _meta: {
1746
+ ...toolCardMeta(),
1747
+ "openai/toolInvocation/invoking": "Writing file...",
1748
+ "openai/toolInvocation/invoked": "File written"
1749
+ }
1750
+ }, async (args) => {
1751
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1752
+ const resolved = guard.resolve(workspace, args.path, { forWrite: true });
1753
+ assertWriteToolAllowed(config, resolved.relPath);
1754
+ const result = await writeTextFile(config, guard, workspace, args.path, String(args.content ?? ""), {
1755
+ createDirs: args.create_dirs !== false,
1756
+ overwrite: args.overwrite !== false
1757
+ });
1758
+ if (result.diff.changed)
1759
+ invalidateWorkspaceAnalysis(workspace.id);
1760
+ const text = `# Write File\n\nPath: ${result.path}\nExisted before: ${result.existed}\nBytes: ${result.bytes}\nSHA-256: ${result.sha256}\nDiff stats: +${result.diff.additions} -${result.diff.deletions}${diffBlock(result.diff.diff)}`;
1761
+ return textResult(text, {
1762
+ workspace_id: workspace.id,
1763
+ root: workspace.root,
1764
+ path: result.path,
1765
+ existed: result.existed,
1766
+ bytes: result.bytes,
1767
+ sha256: result.sha256,
1768
+ additions: result.diff.additions,
1769
+ deletions: result.diff.deletions,
1770
+ diff: result.diff.diff
1771
+ });
1772
+ });
1773
+ registerCodexTool(config, server, "edit", {
1774
+ title: "Edit File",
1775
+ description: "Apply a targeted exact text replacement inside a workspace text file. Returns a unified diff.",
1776
+ inputSchema: {
1777
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1778
+ path: z.string().describe("File path relative to workspace root."),
1779
+ old_text: z.string().describe("Exact text to replace. Must match once unless replace_all=true."),
1780
+ new_text: z.string().describe("Replacement text."),
1781
+ replace_all: z.boolean().optional().describe("Replace all occurrences. Default: false."),
1782
+ expected_replacements: z.number().int().min(1).optional().describe("Fail if actual replacement count differs.")
1783
+ },
1784
+ annotations: LOCAL_WRITE_ANNOTATIONS,
1785
+ _meta: {
1786
+ ...toolCardMeta(),
1787
+ "openai/toolInvocation/invoking": "Editing file...",
1788
+ "openai/toolInvocation/invoked": "File edited"
1789
+ }
1790
+ }, async (args) => {
1791
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1792
+ const resolved = guard.resolve(workspace, args.path, { forWrite: true });
1793
+ assertWriteToolAllowed(config, resolved.relPath);
1794
+ const result = await editTextFile(config, guard, workspace, args.path, String(args.old_text ?? ""), String(args.new_text ?? ""), {
1795
+ replaceAll: parseBool(args.replace_all, false),
1796
+ expectedReplacements: args.expected_replacements
1797
+ });
1798
+ if (result.diff.changed)
1799
+ invalidateWorkspaceAnalysis(workspace.id);
1800
+ const text = `# Edit File\n\nPath: ${result.path}\nReplacements: ${result.replacements}\nBytes: ${result.bytes}\nSHA-256: ${result.sha256}\nDiff stats: +${result.diff.additions} -${result.diff.deletions}${diffBlock(result.diff.diff)}`;
1801
+ return textResult(text, {
1802
+ workspace_id: workspace.id,
1803
+ root: workspace.root,
1804
+ path: result.path,
1805
+ replacements: result.replacements,
1806
+ bytes: result.bytes,
1807
+ sha256: result.sha256,
1808
+ additions: result.diff.additions,
1809
+ deletions: result.diff.deletions,
1810
+ diff: result.diff.diff
1811
+ });
1812
+ });
1813
+ registerCodexTool(config, server, "apply_patch", {
1814
+ title: "Apply Patch",
1815
+ description: "Apply one unified diff patch inside the workspace. Paths are validated before applying. Prefer edit for tiny replacements and apply_patch for multi-file diffs.",
1816
+ inputSchema: {
1817
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1818
+ patch: z.string().describe("Unified diff patch to apply. File paths must stay inside the workspace and avoid blocked paths.")
1819
+ },
1820
+ annotations: LOCAL_WRITE_ANNOTATIONS,
1821
+ _meta: {
1822
+ ...toolCardMeta(),
1823
+ "openai/toolInvocation/invoking": "Applying patch...",
1824
+ "openai/toolInvocation/invoked": "Patch applied"
1825
+ }
1826
+ }, async (args) => {
1827
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1828
+ const result = applyWorkspacePatch(config, guard, workspace, String(args.patch ?? ""));
1829
+ if (result.changed)
1830
+ invalidateWorkspaceAnalysis(workspace.id);
1831
+ const text = [
1832
+ "# Apply Patch",
1833
+ "",
1834
+ `Paths: ${result.paths.join(", ")}`,
1835
+ `Diff stats: +${result.additions} -${result.deletions}`,
1836
+ result.stderr ? `stderr: ${result.stderr}` : "",
1837
+ result.diff ? diffBlock(result.diff) : "No diff output."
1838
+ ].filter(Boolean).join("\n");
1839
+ return textResult(text, {
1840
+ workspace_id: workspace.id,
1841
+ root: workspace.root,
1842
+ paths: result.paths,
1843
+ stdout: result.stdout,
1844
+ stderr: result.stderr,
1845
+ additions: result.additions,
1846
+ deletions: result.deletions,
1847
+ changed: result.changed,
1848
+ diff: result.diff
1849
+ });
1850
+ });
1851
+ registerCodexTool(config, server, "bash", {
1852
+ title: "Bash",
1853
+ description: "Run one allowlisted verification command in the workspace, such as tests, build, lint, typecheck, or a project script. Do not use for git status/diff or file inspection; use show_changes, tree, search, and read instead. Do not chain commands with &&, pipes, redirects, or shell file readers.",
1854
+ inputSchema: {
1855
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1856
+ command: z.string().describe("Command to run."),
1857
+ session_id: z.string().optional().describe(config.requireBashSession && config.bashSessionId ? `Required bash session id for this server: ${config.bashSessionId}.` : "Optional bash session id. If configured on the server, a provided value must match it."),
1858
+ cwd: z.string().optional().describe("Working directory relative to workspace root. Default: ."),
1859
+ timeout_ms: z.number().int().min(1000).max(180000).optional().describe("Timeout in milliseconds. Default: 30000.")
1860
+ },
1861
+ annotations: BASH_ANNOTATIONS,
1862
+ _meta: {
1863
+ ...toolCardMeta(),
1864
+ "openai/toolInvocation/invoking": "Running bash command...",
1865
+ "openai/toolInvocation/invoked": "Bash command finished"
1866
+ }
1867
+ }, async (args) => {
1868
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1869
+ const result = await runBash(config, guard, workspace, String(args.command ?? ""), {
1870
+ cwd: args.cwd,
1871
+ timeoutMs: args.timeout_ms,
1872
+ sessionId: args.session_id
1873
+ });
1874
+ const text = bashTextResult(config, result);
1875
+ return textResult(text, { workspace_id: workspace.id, root: workspace.root, ...result, bash_session_id: result.bashSessionId ?? null });
1876
+ });
1877
+ registerCodexTool(config, server, "git_status", {
1878
+ title: "Git Status",
1879
+ description: "Show git branch and changed files for the workspace.",
1880
+ inputSchema: {
1881
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1882
+ path: z.string().optional().describe("Optional file path relative to workspace root.")
1883
+ },
1884
+ annotations: READ_ONLY_ANNOTATIONS,
1885
+ _meta: {
1886
+ ...toolCardMeta(),
1887
+ "openai/toolInvocation/invoking": "Reading git status...",
1888
+ "openai/toolInvocation/invoked": "Git status ready"
1889
+ }
1890
+ }, async (args) => {
1891
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1892
+ const scopedPath = typeof args.path === "string" ? args.path : undefined;
1893
+ const status = gitStatus(config, workspace, guard, scopedPath);
1894
+ const statusError = looksLikeGitError(status) ? status : "";
1895
+ const changedFiles = statusError ? [] : changedStatusLines(status);
1896
+ return textResult(status, {
1897
+ workspace_id: workspace.id,
1898
+ root: workspace.root,
1899
+ path: args.path ?? "workspace status",
1900
+ status,
1901
+ status_error: statusError || undefined,
1902
+ changed_files: changedFiles,
1903
+ changed: !statusError && changedFiles.length > 0
1904
+ });
1905
+ });
1906
+ registerCodexTool(config, server, "git_diff", {
1907
+ title: "Git Diff",
1908
+ description: "Show current unstaged or staged git diff, optionally scoped to a file.",
1909
+ inputSchema: {
1910
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1911
+ path: z.string().optional().describe("Optional file path relative to workspace root."),
1912
+ staged: z.boolean().optional().describe("Show staged diff. Default: false."),
1913
+ include_diff: z.boolean().optional().describe("Include the raw unified diff in the response. Default: true. Set false for stats-only checks.")
1914
+ },
1915
+ annotations: READ_ONLY_ANNOTATIONS,
1916
+ _meta: {
1917
+ ...toolCardMeta(),
1918
+ "openai/toolInvocation/invoking": "Reading git diff...",
1919
+ "openai/toolInvocation/invoked": "Git diff ready"
1920
+ }
1921
+ }, async (args) => {
1922
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1923
+ const rawDiff = normalizeGitOutput(gitDiff(config, guard, workspace, args.path, parseBool(args.staged, false)));
1924
+ const diffError = rawDiff && looksLikeGitError(rawDiff) ? rawDiff : "";
1925
+ const stats = diffError ? { additions: 0, deletions: 0, changed: false } : diffStats(rawDiff);
1926
+ const includeDiff = parseBool(args.include_diff, true);
1927
+ const text = diffError
1928
+ ? diffError
1929
+ : includeDiff
1930
+ ? rawDiff
1931
+ : [
1932
+ "# Git Diff",
1933
+ "",
1934
+ `Workspace: ${workspace.root}`,
1935
+ `Path: ${args.path ?? "workspace diff"}`,
1936
+ `Staged: ${parseBool(args.staged, false)}`,
1937
+ `Diff stats: +${stats.additions} -${stats.deletions}`,
1938
+ "",
1939
+ "Raw diff omitted by include_diff=false."
1940
+ ].join("\n");
1941
+ return textResult(text, {
1942
+ workspace_id: workspace.id,
1943
+ root: workspace.root,
1944
+ path: args.path ?? "workspace diff",
1945
+ staged: parseBool(args.staged, false),
1946
+ include_diff: includeDiff,
1947
+ diff_error: diffError || undefined,
1948
+ additions: stats.additions,
1949
+ deletions: stats.deletions,
1950
+ changed: !diffError && stats.changed,
1951
+ diff: diffError || includeDiff ? rawDiff : ""
1952
+ });
1953
+ });
1954
+ registerCodexTool(config, server, "show_changes", {
1955
+ title: "Show Changes",
1956
+ description: "Summarize the current workspace changes in one review-oriented result with git status, diff stats, and optional diff. Use this instead of bash git status, bash git diff, git_status, or git_diff when reviewing work.",
1957
+ inputSchema: {
1958
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
1959
+ path: z.string().optional().describe("Optional file path relative to workspace root."),
1960
+ staged: z.boolean().optional().describe("Show staged diff. Default: false."),
1961
+ include_diff: z.boolean().optional().describe("Include the unified diff. Default: true."),
1962
+ since: z.enum(["last_shown", "workspace"]).optional().describe("Use last_shown to suppress unchanged repeated reviews. Default: last_shown."),
1963
+ mark_reviewed: z.boolean().optional().describe("Update the last-shown review checkpoint after this call. Default: true.")
1964
+ },
1965
+ annotations: READ_ONLY_ANNOTATIONS,
1966
+ _meta: {
1967
+ ...toolCardMeta(),
1968
+ "openai/toolInvocation/invoking": "Summarizing workspace changes...",
1969
+ "openai/toolInvocation/invoked": "Workspace changes summarized"
1970
+ }
1971
+ }, async (args) => {
1972
+ const workspace = workspaces.getWorkspace(args.workspace_id);
1973
+ const scopedPath = typeof args.path === "string" ? args.path : undefined;
1974
+ const staged = parseBool(args.staged, false);
1975
+ const normalizedScopedPath = scopedPath?.trim() ? guard.resolve(workspace, scopedPath).relPath : undefined;
1976
+ const status = normalizeGitOutput(gitDiffStatus(config, guard, workspace, normalizedScopedPath, staged));
1977
+ const includeDiff = parseBool(args.include_diff, true);
1978
+ const rawDiff = normalizeGitOutput(gitDiff(config, guard, workspace, normalizedScopedPath, staged));
1979
+ const statusError = looksLikeGitError(status) ? status : "";
1980
+ const diffError = rawDiff && looksLikeGitError(rawDiff) ? rawDiff : "";
1981
+ const diff = diffError ? "" : rawDiff;
1982
+ const stats = diffStats(diff);
1983
+ const changedFiles = statusError ? [] : changedStatusLines(status);
1984
+ const untrackedFingerprint = statusError ? "" : await untrackedReviewFingerprint(config, guard, workspace, changedFiles);
1985
+ const since = args.since === "workspace" ? "workspace" : "last_shown";
1986
+ const markReviewed = parseBool(args.mark_reviewed, true);
1987
+ const checkpointKey = reviewCheckpointKey(workspace, { path: normalizedScopedPath, staged });
1988
+ const fingerprint = reviewFingerprint(status, `${diff}\0${untrackedFingerprint}`);
1989
+ const checkpointHit = includeDiff && since === "last_shown" && reviewCheckpoints.get(checkpointKey) === fingerprint;
1990
+ const checkpointWritten = markReviewed && includeDiff;
1991
+ if (checkpointWritten)
1992
+ reviewCheckpoints.set(checkpointKey, fingerprint);
1993
+ const responseDiff = checkpointHit ? "" : includeDiff ? diff : "";
1994
+ const responseStats = checkpointHit ? { additions: 0, deletions: 0, changed: false } : stats;
1995
+ const changedPaths = statusError ? [] : changedPathsFromStatus(changedFiles);
1996
+ let analysis;
1997
+ if (config.analysisEnabled && changedPaths.length && !checkpointHit) {
1998
+ try {
1999
+ const impact = await reviewWorkspaceChanges(config, guard, workspace, { changedPaths });
2000
+ analysis = {
2001
+ schema_version: impact.schemaVersion,
2002
+ changed_paths: impact.changedPaths,
2003
+ affected_areas: impact.affectedAreas,
2004
+ dependent_files: impact.dependentFiles,
2005
+ related_tests: impact.relatedTests,
2006
+ risk_signals: impact.riskSignals,
2007
+ recommended_commands: impact.recommendedCommands,
2008
+ coverage: impact.coverage,
2009
+ warnings: impact.warnings,
2010
+ cache: impact.cache
2011
+ };
2012
+ }
2013
+ catch (error) {
2014
+ analysis = {
2015
+ schema_version: 1,
2016
+ changed_paths: changedPaths,
2017
+ affected_areas: [],
2018
+ dependent_files: [],
2019
+ related_tests: [],
2020
+ risk_signals: [],
2021
+ recommended_commands: [],
2022
+ warnings: [`Change analysis unavailable: ${errorText(error)}`]
2023
+ };
2024
+ }
2025
+ }
2026
+ const changedText = statusError
2027
+ ? `- Git status unavailable: ${statusError}`
2028
+ : checkpointHit
2029
+ ? "- No changes since last shown review."
2030
+ : changedFiles.length
2031
+ ? changedFiles.map((line) => `- ${line}`).join("\n")
2032
+ : "- No changed files.";
2033
+ const diffText = checkpointHit
2034
+ ? "\n\nNo new diff since last shown review."
2035
+ : includeDiff
2036
+ ? diffError
2037
+ ? `\n\nGit diff unavailable: ${diffError}`
2038
+ : diff
2039
+ ? diffBlock(diff)
2040
+ : "\n\nNo diff output."
2041
+ : "\n\nDiff omitted by request.";
2042
+ const analysisText = analysis
2043
+ ? `\n\n## Analysis\n\nAffected areas: ${analysis.affected_areas.join(", ") || "none"}\nRisks: ${(analysis.risk_signals ?? []).map((risk) => risk.label).filter(Boolean).join(", ") || "none"}\nRelated tests: ${(analysis.related_tests ?? []).map((file) => file.path).filter(Boolean).join(", ") || "none"}`
2044
+ : "";
2045
+ const text = `# Show Changes\n\nWorkspace: ${workspace.root}\n\n## Changed\n\n${changedText}\n\n## Diff stats\n\n+${responseStats.additions} -${responseStats.deletions}${diffText}${analysisText}`;
2046
+ return textResult(text, {
2047
+ workspace_id: workspace.id,
2048
+ root: workspace.root,
2049
+ path: args.path ?? "workspace changes",
2050
+ status,
2051
+ status_error: statusError || undefined,
2052
+ diff_error: diffError || undefined,
2053
+ changed_files: checkpointHit ? [] : changedFiles,
2054
+ staged,
2055
+ include_diff: includeDiff,
2056
+ additions: responseStats.additions,
2057
+ deletions: responseStats.deletions,
2058
+ changed: !statusError && (checkpointHit ? false : changedFiles.length > 0 || responseStats.changed),
2059
+ diff: responseDiff,
2060
+ review_since: since,
2061
+ review_marked: checkpointWritten,
2062
+ review_checkpoint_hit: checkpointHit,
2063
+ ...(analysis ? { analysis } : {})
2064
+ });
2065
+ });
2066
+ registerCodexTool(config, server, "read_handoff", {
2067
+ title: "Read Handoff",
2068
+ description: "Read the shared .ai-bridge planning files used for ChatGPT-to-agent coordination.",
2069
+ inputSchema: {
2070
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace.")
2071
+ },
2072
+ annotations: READ_ONLY_ANNOTATIONS,
2073
+ _meta: {
2074
+ ...toolCardMeta(),
2075
+ "openai/toolInvocation/invoking": "Reading agent handoff context...",
2076
+ "openai/toolInvocation/invoked": "Agent handoff context ready"
2077
+ }
2078
+ }, async (args) => {
2079
+ const workspace = workspaces.getWorkspace(args.workspace_id);
2080
+ const context = await readAiBridgeContext(config, guard, workspace);
2081
+ return textResult(context.text, {
2082
+ workspace_id: workspace.id,
2083
+ root: workspace.root,
2084
+ files: context.files,
2085
+ file_count: context.files.length,
2086
+ preview: previewText(context.text)
2087
+ });
2088
+ });
2089
+ registerCodexTool(config, server, "wait_for_handoff", {
2090
+ title: "Wait For Handoff",
2091
+ description: "Read-only long-poll of the local handoff run state so ChatGPT can stay the planner/reviewer while a local executor runs. Reads .ai-bridge/handoff-run-state.json and returns the run status plus status/diff/log/test excerpts. It never starts processes or runs shell commands; it only observes local handoff state written by execute-handoff/watch-handoff/loop-handoff.",
2092
+ inputSchema: {
2093
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
2094
+ plan_hash: z.string().optional().describe("Expected current-plan.md hash. If set, only a terminal run with this plan_hash counts as completed."),
2095
+ since_iteration: z.number().int().min(0).optional().describe("Only treat a run with iteration greater than this as the awaited completion."),
2096
+ max_wait_seconds: z.number().int().min(1).max(60).optional().describe("Maximum seconds to long-poll before returning the current state. Default: 20."),
2097
+ poll_ms: z.number().int().min(250).max(5000).optional().describe("Poll interval in milliseconds. Default: 1000."),
2098
+ include_diff: z.boolean().optional().describe("Include the implementation diff excerpt when completed. Default: true."),
2099
+ include_log_excerpt: z.boolean().optional().describe("Include the tail of execution-log.jsonl when completed. Default: true."),
2100
+ include_tests: z.boolean().optional().describe("Include the loop-tests.txt excerpt when completed. Default: true.")
2101
+ },
2102
+ annotations: { ...READ_ONLY_ANNOTATIONS, idempotentHint: false },
2103
+ _meta: {
2104
+ ...toolCardMeta(),
2105
+ "openai/toolInvocation/invoking": "Waiting for local handoff result...",
2106
+ "openai/toolInvocation/invoked": "Local handoff state ready"
2107
+ }
2108
+ }, async (args) => {
2109
+ const workspace = workspaces.getWorkspace(args.workspace_id);
2110
+ const maxWaitSeconds = limitInt(args.max_wait_seconds, 20, 1, 60);
2111
+ const pollMs = limitInt(args.poll_ms, 1000, 250, 5000);
2112
+ const includeDiff = parseBool(args.include_diff, true);
2113
+ const includeLog = parseBool(args.include_log_excerpt, true);
2114
+ const includeTests = parseBool(args.include_tests, true);
2115
+ const expectedPlanHash = typeof args.plan_hash === "string" && args.plan_hash.trim() ? args.plan_hash.trim() : undefined;
2116
+ const sinceIteration = Number.isFinite(Number(args.since_iteration)) && args.since_iteration !== undefined
2117
+ ? Math.floor(Number(args.since_iteration))
2118
+ : undefined;
2119
+ const stateRel = `${config.contextDir}/handoff-run-state.json`;
2120
+ const contextPrefix = `${config.contextDir.replace(/\/+$/, "")}/`;
2121
+ const terminalStates = new Set(["completed", "failed", "timed_out"]);
2122
+ const readState = async () => {
2123
+ try {
2124
+ const raw = await readRawTextFileBounded(config, guard, workspace, stateRel);
2125
+ const parsed = JSON.parse(raw);
2126
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
2127
+ }
2128
+ catch {
2129
+ return undefined;
2130
+ }
2131
+ };
2132
+ const isAwaited = (state) => Boolean(state &&
2133
+ terminalStates.has(state.state) &&
2134
+ (!expectedPlanHash || state.plan_hash === expectedPlanHash) &&
2135
+ (sinceIteration === undefined || (typeof state.iteration === "number" && state.iteration > sinceIteration)));
2136
+ const deadline = Date.now() + maxWaitSeconds * 1000;
2137
+ let state = await readState();
2138
+ while (Date.now() < deadline && !isAwaited(state)) {
2139
+ await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
2140
+ state = await readState();
2141
+ }
2142
+ const awaitedTerminal = isAwaited(state);
2143
+ const awaitedCompleted = awaitedTerminal && state?.state === "completed";
2144
+ const planHashMismatch = Boolean(expectedPlanHash && state && state.plan_hash !== expectedPlanHash);
2145
+ const reportedState = awaitedTerminal
2146
+ ? String(state?.state)
2147
+ : state
2148
+ ? state.state === "running" || planHashMismatch || sinceIteration !== undefined
2149
+ ? "running"
2150
+ : String(state.state)
2151
+ : "unknown";
2152
+ const excerpt = async (rel, maxChars, tailLines) => {
2153
+ try {
2154
+ const raw = await readRawTextFileBounded(config, guard, workspace, rel);
2155
+ const body = tailLines
2156
+ ? raw.split(/\r?\n/).filter(Boolean).slice(-tailLines).join("\n")
2157
+ : raw;
2158
+ const trimmed = body.length > maxChars ? `${body.slice(0, maxChars)}\n...[excerpt truncated]` : body;
2159
+ return redactSensitiveText(trimmed);
2160
+ }
2161
+ catch {
2162
+ return undefined;
2163
+ }
2164
+ };
2165
+ const bridgeArtifact = (value, fallback) => {
2166
+ const raw = typeof value === "string" && value.trim() ? value.trim() : fallback;
2167
+ const normalized = path.posix.normalize(raw.split(path.sep).join("/")).replace(/^\.\//, "");
2168
+ return normalized.startsWith(contextPrefix) ? normalized : fallback;
2169
+ };
2170
+ const structured = {
2171
+ workspace_id: workspace.id,
2172
+ root: workspace.root,
2173
+ state: reportedState,
2174
+ awaited_completed: awaitedCompleted,
2175
+ awaited_terminal: awaitedTerminal,
2176
+ succeeded: awaitedCompleted,
2177
+ state_file: stateRel,
2178
+ ...(state ? { run_state: state.state } : {}),
2179
+ ...(typeof state?.iteration === "number" ? { iteration: state.iteration } : {}),
2180
+ ...(state?.plan_hash ? { plan_hash: state.plan_hash } : {}),
2181
+ ...(expectedPlanHash ? { expected_plan_hash: expectedPlanHash, plan_hash_mismatch: planHashMismatch } : {}),
2182
+ ...(state && "exit_code" in state ? { exit_code: state.exit_code } : {}),
2183
+ ...(state && "timed_out" in state ? { timed_out: state.timed_out } : {}),
2184
+ ...(state?.started_at ? { started_at: state.started_at } : {}),
2185
+ ...(state?.finished_at ? { finished_at: state.finished_at } : {}),
2186
+ ...(state?.executor ? { executor: state.executor } : {}),
2187
+ ...(state?.model ? { model: state.model } : {}),
2188
+ ...(awaitedTerminal ? {} : { next_poll_after_seconds: Math.max(1, Math.ceil(pollMs / 1000)) })
2189
+ };
2190
+ if (awaitedTerminal) {
2191
+ const statusFile = bridgeArtifact(state?.status_file, `${config.contextDir}/agent-status.md`);
2192
+ const diffFile = bridgeArtifact(state?.diff_file, `${config.contextDir}/implementation-diff.patch`);
2193
+ const logFile = bridgeArtifact(state?.log_file, `${config.contextDir}/execution-log.jsonl`);
2194
+ const testsFile = bridgeArtifact(state?.tests_file, `${config.contextDir}/loop-tests.txt`);
2195
+ structured.status_file = statusFile;
2196
+ structured.diff_file = diffFile;
2197
+ structured.log_file = logFile;
2198
+ const status = await excerpt(statusFile, 6_000);
2199
+ if (status)
2200
+ structured.status_excerpt = status;
2201
+ if (includeDiff) {
2202
+ const diff = await excerpt(diffFile, 12_000);
2203
+ if (diff)
2204
+ structured.diff_excerpt = diff;
2205
+ }
2206
+ if (includeLog) {
2207
+ const log = await excerpt(logFile, 6_000, 20);
2208
+ if (log)
2209
+ structured.log_excerpt = log;
2210
+ }
2211
+ if (includeTests) {
2212
+ const tests = await excerpt(testsFile, 4_000);
2213
+ if (tests) {
2214
+ structured.tests_file = testsFile;
2215
+ structured.tests_excerpt = tests;
2216
+ }
2217
+ }
2218
+ }
2219
+ const summary = !state
2220
+ ? `No handoff run state found at ${stateRel}. Start a run with handoff_to_agent + local execute-handoff/watch-handoff, then call wait_for_handoff again.`
2221
+ : awaitedTerminal
2222
+ ? `Handoff run ${state.state} (iteration ${state.iteration ?? 1}, exit ${state.exit_code ?? "null"}).`
2223
+ : planHashMismatch
2224
+ ? `Executor has not completed the expected plan yet (last known run plan_hash=${state.plan_hash ?? "unknown"}). Still waiting.`
2225
+ : `Handoff run is ${state.state}. Re-poll after ~${Math.max(1, Math.ceil(pollMs / 1000))}s.`;
2226
+ const lines = [
2227
+ "# Wait For Handoff",
2228
+ "",
2229
+ summary,
2230
+ "",
2231
+ `State file: ${stateRel}`,
2232
+ ...(state?.plan_hash ? [`Plan hash: ${state.plan_hash}`] : []),
2233
+ ...(awaitedTerminal && structured.status_excerpt ? ["", "## Status", "", `\`\`\`text\n${structured.status_excerpt}\n\`\`\``] : []),
2234
+ ...(awaitedTerminal && structured.diff_excerpt ? ["", "## Diff", "", `\`\`\`diff\n${structured.diff_excerpt}\n\`\`\``] : []),
2235
+ ...(awaitedTerminal && structured.tests_excerpt ? ["", "## Tests", "", `\`\`\`text\n${structured.tests_excerpt}\n\`\`\``] : []),
2236
+ ...(awaitedTerminal && structured.log_excerpt ? ["", "## Log tail", "", `\`\`\`text\n${structured.log_excerpt}\n\`\`\``] : [])
2237
+ ];
2238
+ return textResult(lines.join("\n"), structured);
2239
+ });
2240
+ registerCodexTool(config, server, "codex_context", {
2241
+ title: "Codex Context",
2242
+ description: "Load Codex-style workspace context in one call: AGENTS instructions for a target path, .ai-bridge handoff files, and optional git status/diff.",
2243
+ inputSchema: {
2244
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
2245
+ target_path: z.string().optional().describe("Workspace-relative file or directory whose AGENTS instruction chain should be loaded. Default: ."),
2246
+ include_ai_bridge: z.boolean().optional().describe("Include .ai-bridge plan, agent status, diff, decisions, questions, and execution log. Default: true."),
2247
+ include_git: z.boolean().optional().describe("Include git status. Default: true."),
2248
+ include_diff: z.boolean().optional().describe("Include full git diff. Default: false for speed/noise."),
2249
+ max_agent_bytes: z.number().int().min(1000).max(200000).optional().describe("Maximum bytes per AGENTS file. Default: 60000.")
2250
+ },
2251
+ annotations: READ_ONLY_ANNOTATIONS,
2252
+ _meta: {
2253
+ ...toolCardMeta(),
2254
+ "openai/toolInvocation/invoking": "Loading Codex context...",
2255
+ "openai/toolInvocation/invoked": "Codex context ready"
2256
+ }
2257
+ }, async (args) => {
2258
+ const workspace = workspaces.getWorkspace(args.workspace_id);
2259
+ const context = await readCodexContext(config, guard, workspace, {
2260
+ targetPath: args.target_path,
2261
+ includeAiBridge: args.include_ai_bridge,
2262
+ includeGit: args.include_git,
2263
+ includeDiff: parseBool(args.include_diff, false),
2264
+ maxAgentBytes: args.max_agent_bytes
2265
+ });
2266
+ return textResult(context.text, {
2267
+ workspace_id: context.workspaceId,
2268
+ root: context.root,
2269
+ target_path: context.targetPath,
2270
+ agents_files: context.agentsFiles,
2271
+ ai_context_files: context.aiContextFiles,
2272
+ included_git_status: context.gitStatus !== undefined,
2273
+ included_git_diff: context.gitDiff !== undefined,
2274
+ preview: previewText(context.text)
2275
+ });
2276
+ });
2277
+ registerCodexTool(config, server, "export_pro_context", {
2278
+ title: "Export Pro Context",
2279
+ description: "Create .ai-bridge/pro-context.md with repo tree, git state, selected files, and handoff context for high-context ChatGPT planning without live MCP tool calls.",
2280
+ inputSchema: {
2281
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
2282
+ title: z.string().optional().describe("Markdown title for the context bundle."),
2283
+ selected_paths: z.array(z.string()).optional().describe("Specific workspace-relative files to include."),
2284
+ extra_globs: z.array(z.string()).optional().describe("Additional workspace-relative glob patterns to include, for example src/**/*.ts."),
2285
+ include_important_files: z.boolean().optional().describe("Auto-include important root config/docs such as AGENTS.md, README.md, and package.json. Default: true."),
2286
+ include_changed_files: z.boolean().optional().describe("Auto-include currently changed files from git status. Default: true."),
2287
+ include_diff: z.boolean().optional().describe("Include the current git diff. Default: true."),
2288
+ include_ai_bridge: z.boolean().optional().describe("Include existing .ai-bridge planning files. Default: true."),
2289
+ max_depth: z.number().int().min(1).max(6).optional().describe("Repository tree depth. Default: 3."),
2290
+ max_files: z.number().int().min(1).max(80).optional().describe("Maximum file contents to include. Default: 24."),
2291
+ max_file_bytes: z.number().int().min(1000).max(250000).optional().describe("Maximum bytes per included file. Default: 60000."),
2292
+ max_total_bytes: z.number().int().min(20000).max(2000000).optional().describe("Maximum bytes in the generated bundle.")
2293
+ },
2294
+ annotations: HANDOFF_WRITE_ANNOTATIONS,
2295
+ _meta: {
2296
+ ...toolCardMeta(),
2297
+ "openai/toolInvocation/invoking": "Exporting Pro context...",
2298
+ "openai/toolInvocation/invoked": "Pro context exported"
2299
+ }
2300
+ }, async (args) => {
2301
+ const workspace = workspaces.getWorkspace(args.workspace_id);
2302
+ const result = await exportProContext(config, guard, workspace, {
2303
+ title: args.title,
2304
+ selectedPaths: args.selected_paths,
2305
+ extraGlobs: args.extra_globs,
2306
+ includeImportantFiles: args.include_important_files,
2307
+ includeChangedFiles: args.include_changed_files,
2308
+ includeDiff: args.include_diff,
2309
+ includeAiBridge: args.include_ai_bridge,
2310
+ maxDepth: args.max_depth,
2311
+ maxFiles: args.max_files,
2312
+ maxFileBytes: args.max_file_bytes,
2313
+ maxTotalBytes: args.max_total_bytes
2314
+ });
2315
+ const text = `# Export Pro Context\n\nWrote ${result.path}.\nBytes: ${result.bytes}\nFiles included: ${result.filesIncluded.length}\nFiles skipped: ${result.filesSkipped.length}\nTruncated: ${result.truncated}\n\nPaste ${result.path} into a high-context planning model when MCP tools are unavailable, then save the returned plan with codexflow pro-apply.`;
2316
+ return textResult(text, {
2317
+ workspace_id: workspace.id,
2318
+ root: workspace.root,
2319
+ path: result.path,
2320
+ bytes: result.bytes,
2321
+ files_included: result.filesIncluded,
2322
+ files_skipped: result.filesSkipped,
2323
+ truncated: result.truncated
2324
+ });
2325
+ });
2326
+ if (config.codexSessions !== "off") {
2327
+ registerCodexTool(config, server, "codex_sessions", {
2328
+ title: "Codex Sessions",
2329
+ description: "Opt-in, read-only local Codex session history browser. Lists metadata from the user's configured Codex session JSONL files without reading full transcripts.",
2330
+ inputSchema: {
2331
+ max_sessions: z.number().int().min(1).max(200).optional().describe("Maximum sessions to return. Default: 30."),
2332
+ query: z.string().optional().describe("Optional case-insensitive search over session id, title, cwd, and source path.")
2333
+ },
2334
+ annotations: READ_ONLY_ANNOTATIONS,
2335
+ _meta: {
2336
+ ...toolCardMeta(),
2337
+ "openai/toolInvocation/invoking": "Listing local Codex sessions...",
2338
+ "openai/toolInvocation/invoked": "Codex sessions ready"
2339
+ }
2340
+ }, async (args) => {
2341
+ const result = await listCodexSessions(config, {
2342
+ maxSessions: args.max_sessions,
2343
+ query: args.query
2344
+ });
2345
+ const rows = result.sessions.length
2346
+ ? result.sessions.map((session) => `- ${session.session_id} ${session.title || "(untitled)"}${session.project_dir ? ` cwd=${session.project_dir}` : ""}`).join("\n")
2347
+ : "- No Codex sessions found.";
2348
+ const text = `# Codex Sessions\n\nCodex dir: ${result.codex_dir}\nMode: ${config.codexSessions}\nTotal matched: ${result.total_found}\n\n${rows}`;
2349
+ return textResult(text, {
2350
+ codex_dir: result.codex_dir,
2351
+ roots: result.roots,
2352
+ sessions: result.sessions,
2353
+ total_found: result.total_found,
2354
+ codex_sessions_mode: config.codexSessions
2355
+ });
2356
+ });
2357
+ if (config.codexSessions === "read") {
2358
+ registerCodexTool(config, server, "read_codex_session", {
2359
+ title: "Read Codex Session",
2360
+ description: "Opt-in, read-only local Codex transcript reader. Requires --codex-sessions read and returns a bounded transcript from a local Codex session JSONL file.",
2361
+ inputSchema: {
2362
+ session_id: z.string().optional().describe("Codex session id from codex_sessions."),
2363
+ source_path: z.string().optional().describe("Source path from codex_sessions. Must be inside the configured Codex session roots."),
2364
+ max_messages: z.number().int().min(1).max(400).optional().describe("Maximum transcript messages. Default: 80."),
2365
+ max_total_bytes: z.number().int().min(4000).max(400000).optional().describe("Maximum transcript content bytes. Default: 80000.")
2366
+ },
2367
+ annotations: READ_ONLY_ANNOTATIONS,
2368
+ _meta: {
2369
+ ...toolCardMeta(),
2370
+ "openai/toolInvocation/invoking": "Reading local Codex session...",
2371
+ "openai/toolInvocation/invoked": "Codex session read"
2372
+ }
2373
+ }, async (args) => {
2374
+ const result = await readCodexSession(config, {
2375
+ sessionId: args.session_id,
2376
+ sourcePath: args.source_path,
2377
+ maxMessages: args.max_messages,
2378
+ maxTotalBytes: args.max_total_bytes
2379
+ });
2380
+ return textResult(result.text, {
2381
+ session: result.session,
2382
+ messages: result.messages,
2383
+ message_count: result.messages.length,
2384
+ truncated: result.truncated,
2385
+ codex_sessions_mode: config.codexSessions
2386
+ });
2387
+ });
2388
+ }
2389
+ }
2390
+ registerCodexTool(config, server, "handoff_to_agent", {
2391
+ title: "Handoff To Agent",
2392
+ description: "Write .ai-bridge/current-plan.md for Codex, OpenCode, Pi, or another local implementation agent. This only creates handoff files; it does not execute local agent commands.",
2393
+ inputSchema: {
2394
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
2395
+ agent: z.string().optional().describe("Target agent id, for example codex, opencode, pi, or custom. Default: custom."),
2396
+ agent_name: z.string().optional().describe("Human-readable agent name for custom agents."),
2397
+ model: z.string().optional().describe("Optional model identifier to include in the handoff plan."),
2398
+ title: z.string().optional().describe("Short task title."),
2399
+ plan: z.string().describe("Detailed implementation plan for the local agent."),
2400
+ append: z.boolean().optional().describe("Append to existing current-plan.md instead of overwriting. Default: false.")
2401
+ },
2402
+ annotations: HANDOFF_WRITE_ANNOTATIONS,
2403
+ _meta: {
2404
+ ...toolCardMeta(),
2405
+ "openai/toolInvocation/invoking": "Writing agent handoff plan...",
2406
+ "openai/toolInvocation/invoked": "Agent handoff plan written"
2407
+ }
2408
+ }, async (args) => {
2409
+ const workspace = workspaces.getWorkspace(args.workspace_id);
2410
+ const result = await writeAgentHandoff(config, guard, workspace, {
2411
+ agent: args.agent ?? "custom",
2412
+ agentName: args.agent_name,
2413
+ model: args.model,
2414
+ title: cleanOneLine(args.title, "Agent implementation plan"),
2415
+ plan: String(args.plan ?? ""),
2416
+ append: parseBool(args.append, false),
2417
+ eventName: "handoff_to_agent"
2418
+ });
2419
+ const text = `# Handoff To Agent
2420
+
2421
+ Agent: ${result.agentName} (${result.agent})
2422
+ ${result.model ? `Model: ${result.model}\n` : ""}Wrote ${result.planPath}.
2423
+ Status path: ${result.statusPath}
2424
+ Diff path: ${result.diffPath}
2425
+ Execution log: ${result.executionLogPath}
2426
+ Diff stats: +${result.writeResult.diff.additions} -${result.writeResult.diff.deletions}
2427
+
2428
+ Agent prompt:
2429
+
2430
+ \`\`\`text
2431
+ ${result.prompt}
2432
+ \`\`\`${diffBlock(result.writeResult.diff.diff)}`;
2433
+ return textResult(text, {
2434
+ workspace_id: workspace.id,
2435
+ root: workspace.root,
2436
+ agent: result.agent,
2437
+ agent_name: result.agentName,
2438
+ model: result.model,
2439
+ plan_path: result.planPath,
2440
+ status_path: result.statusPath,
2441
+ diff_path: result.diffPath,
2442
+ log_path: result.logPath,
2443
+ execution_log_path: result.executionLogPath,
2444
+ additions: result.writeResult.diff.additions,
2445
+ deletions: result.writeResult.diff.deletions,
2446
+ diff: result.writeResult.diff.diff
2447
+ });
2448
+ });
2449
+ registerCodexTool(config, server, "handoff_to_codex", {
2450
+ title: "Handoff To Codex",
2451
+ description: "Compatibility wrapper for handoff_to_agent with agent=codex.",
2452
+ inputSchema: {
2453
+ workspace_id: z.string().optional().describe("Workspace id from open_workspace. Omit to use default workspace."),
2454
+ title: z.string().optional().describe("Short task title."),
2455
+ plan: z.string().describe("Detailed implementation plan for Codex."),
2456
+ append: z.boolean().optional().describe("Append to existing current-plan.md instead of overwriting. Default: false.")
2457
+ },
2458
+ annotations: HANDOFF_WRITE_ANNOTATIONS,
2459
+ _meta: {
2460
+ ...toolCardMeta(),
2461
+ "openai/toolInvocation/invoking": "Writing Codex handoff plan...",
2462
+ "openai/toolInvocation/invoked": "Codex handoff plan written"
2463
+ }
2464
+ }, async (args) => {
2465
+ const workspace = workspaces.getWorkspace(args.workspace_id);
2466
+ const result = await writeAgentHandoff(config, guard, workspace, {
2467
+ agent: "codex",
2468
+ title: cleanOneLine(args.title, "Codex implementation plan"),
2469
+ plan: String(args.plan ?? ""),
2470
+ append: parseBool(args.append, false),
2471
+ eventName: "handoff_to_codex"
2472
+ });
2473
+ const text = `# Handoff To Codex
2474
+
2475
+ Wrote ${result.planPath}.
2476
+ Status path: ${result.statusPath}
2477
+ Diff path: ${result.diffPath}
2478
+ Diff stats: +${result.writeResult.diff.additions} -${result.writeResult.diff.deletions}
2479
+
2480
+ Codex prompt:
2481
+
2482
+ \`\`\`text
2483
+ ${result.prompt}
2484
+ \`\`\`${diffBlock(result.writeResult.diff.diff)}`;
2485
+ return textResult(text, {
2486
+ workspace_id: workspace.id,
2487
+ root: workspace.root,
2488
+ agent: result.agent,
2489
+ agent_name: result.agentName,
2490
+ plan_path: result.planPath,
2491
+ status_path: result.statusPath,
2492
+ diff_path: result.diffPath,
2493
+ log_path: result.logPath,
2494
+ execution_log_path: result.executionLogPath,
2495
+ additions: result.writeResult.diff.additions,
2496
+ deletions: result.writeResult.diff.deletions,
2497
+ diff: result.writeResult.diff.diff
2498
+ });
2499
+ });
2500
+ return server;
2501
+ }
2502
+ //# sourceMappingURL=server.js.map