@virtengine/openfleet 0.25.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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,1274 @@
1
+ /**
2
+ * codex-config.mjs — Manages the Codex CLI config (~/.codex/config.toml)
3
+ *
4
+ * Ensures the user's Codex CLI configuration has:
5
+ * 1. A vibe_kanban MCP server section with the correct env vars
6
+ * 2. Sufficient stream_idle_timeout_ms on all model providers
7
+ * 3. Recommended defaults for long-running agentic workloads
8
+ * 4. Feature flags for sub-agents, memory, undo, collaboration
9
+ * 5. Sandbox permissions and shell environment policy
10
+ * 6. Common MCP servers (context7, microsoft-docs)
11
+ *
12
+ * Uses string-based TOML manipulation (no parser dependency) — we only
13
+ * append or patch well-known sections rather than rewriting the whole file.
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
+ import { resolve, dirname } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { fileURLToPath } from "node:url";
20
+ import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+
25
+ /**
26
+ * Read the vibe-kanban version from the local package.json dependency.
27
+ * Falls back to "latest" if not found (shouldn't happen in normal usage).
28
+ */
29
+ function getVibeKanbanVersion() {
30
+ try {
31
+ const pkgPath = resolve(__dirname, "package.json");
32
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
33
+ return pkg.dependencies?.["vibe-kanban"] || "latest";
34
+ } catch {
35
+ return "latest";
36
+ }
37
+ }
38
+
39
+ // ── Constants ────────────────────────────────────────────────────────────────
40
+
41
+ const CODEX_DIR = resolve(homedir(), ".codex");
42
+ const CONFIG_PATH = resolve(CODEX_DIR, "config.toml");
43
+
44
+ /** Minimum recommended stream idle timeout (ms) for complex agentic tasks. */
45
+ const MIN_STREAM_IDLE_TIMEOUT_MS = 300_000; // 5 minutes
46
+
47
+ /** The recommended (generous) timeout for heavy reasoning models. */
48
+ const RECOMMENDED_STREAM_IDLE_TIMEOUT_MS = 3_600_000; // 60 minutes
49
+
50
+ // ── Agent SDK Selection (config.toml) ───────────────────────────────────────
51
+
52
+ const AGENT_SDK_HEADER = "[agent_sdk]";
53
+ const AGENT_SDK_CAPS_HEADER = "[agent_sdk.capabilities]";
54
+
55
+ const AGENTS_HEADER = "[agents]";
56
+ const DEFAULT_AGENT_MAX_THREADS = 12;
57
+
58
+ const DEFAULT_AGENT_SDK_BLOCK = [
59
+ "",
60
+ "# ── Agent SDK selection (added by openfleet) ──",
61
+ AGENT_SDK_HEADER,
62
+ "# Primary agent SDK used for in-process automation.",
63
+ '# Supported: "codex", "copilot", "claude"',
64
+ 'primary = "codex"',
65
+ "",
66
+ AGENT_SDK_CAPS_HEADER,
67
+ "# Live steering updates during an active run.",
68
+ "steering = true",
69
+ "# Ability to spawn subagents/child tasks.",
70
+ "subagents = true",
71
+ "# Access to VS Code tools (Copilot extension).",
72
+ "vscode_tools = false",
73
+ "",
74
+ ].join("\n");
75
+
76
+ const buildAgentsBlock = (maxThreads) =>
77
+ [
78
+ "",
79
+ "# ── Agent limits (added by openfleet) ──",
80
+ AGENTS_HEADER,
81
+ "# Max concurrent agent threads per Codex session.",
82
+ `max_threads = ${maxThreads}`,
83
+ "",
84
+ ].join("\n");
85
+
86
+ // ── Feature Flags ────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Feature flags that should be enabled for sub-agents, collaboration,
90
+ * memory, and continuous operation. Keys are the [features] TOML keys;
91
+ * values are { default, envVar, comment }.
92
+ */
93
+ const RECOMMENDED_FEATURES = {
94
+ // Sub-agents & collaboration
95
+ child_agents_md: { default: true, envVar: "CODEX_FEATURES_CHILD_AGENTS_MD", comment: "Enable sub-agent discovery via CODEX.md" },
96
+ collab: { default: true, envVar: "CODEX_FEATURES_COLLAB", comment: "Enable collaboration mode" },
97
+ collaboration_modes: { default: true, envVar: "CODEX_FEATURES_COLLABORATION_MODES", comment: "Enable collaboration mode selection" },
98
+
99
+ // Continuity & recovery
100
+ memory_tool: { default: true, envVar: "CODEX_FEATURES_MEMORY_TOOL", comment: "Persistent memory across sessions" },
101
+ undo: { default: true, envVar: "CODEX_FEATURES_UNDO", comment: "Safe rollback of agent changes" },
102
+ steer: { default: true, envVar: "CODEX_FEATURES_STEER", comment: "Live steering during runs" },
103
+ personality: { default: true, envVar: "CODEX_FEATURES_PERSONALITY", comment: "Agent personality persistence" },
104
+
105
+ // Sandbox & execution
106
+ use_linux_sandbox_bwrap:{ default: true, envVar: "CODEX_FEATURES_BWRAP", comment: "Linux bubblewrap sandbox" },
107
+ shell_tool: { default: true, envVar: null, comment: "Shell tool access" },
108
+ unified_exec: { default: true, envVar: null, comment: "Unified execution" },
109
+ shell_snapshot: { default: true, envVar: null, comment: "Shell state snapshots" },
110
+ request_rule: { default: true, envVar: null, comment: "Request-level approval rules" },
111
+
112
+ // Performance & networking
113
+ enable_request_compression: { default: true, envVar: null, comment: "Compress requests" },
114
+ remote_models: { default: true, envVar: null, comment: "Remote model support" },
115
+ skill_mcp_dependency_install: { default: true, envVar: null, comment: "Auto-install MCP skill deps" },
116
+
117
+ // Experimental (disabled by default unless explicitly enabled)
118
+ apps: { default: true, envVar: "CODEX_FEATURES_APPS", comment: "ChatGPT Apps integration" },
119
+ };
120
+
121
+ const CRITICAL_ALWAYS_ON_FEATURES = new Set([
122
+ "child_agents_md",
123
+ "memory_tool",
124
+ "collab",
125
+ "collaboration_modes",
126
+ "shell_tool",
127
+ "unified_exec",
128
+ ]);
129
+
130
+ function parsePositiveInt(value) {
131
+ const parsed = Number.parseInt(String(value ?? "").trim(), 10);
132
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
133
+ return parsed;
134
+ }
135
+
136
+ function resolveAgentMaxThreads(envOverrides = process.env) {
137
+ const raw =
138
+ envOverrides.CODEX_AGENT_MAX_THREADS ??
139
+ envOverrides.CODEX_AGENTS_MAX_THREADS ??
140
+ envOverrides.CODEX_MAX_THREADS;
141
+ if (raw !== undefined) {
142
+ return {
143
+ value: parsePositiveInt(raw),
144
+ explicit: true,
145
+ raw,
146
+ };
147
+ }
148
+ return {
149
+ value: DEFAULT_AGENT_MAX_THREADS,
150
+ explicit: false,
151
+ raw: null,
152
+ };
153
+ }
154
+
155
+ export function ensureAgentMaxThreads(
156
+ toml,
157
+ { maxThreads, overwrite = false } = {},
158
+ ) {
159
+ const result = {
160
+ toml,
161
+ changed: false,
162
+ existing: null,
163
+ applied: null,
164
+ added: false,
165
+ updated: false,
166
+ skipped: false,
167
+ };
168
+
169
+ const desired = parsePositiveInt(maxThreads);
170
+ if (!desired) {
171
+ result.skipped = true;
172
+ return result;
173
+ }
174
+ result.applied = desired;
175
+
176
+ const headerIdx = toml.indexOf(AGENTS_HEADER);
177
+ if (headerIdx === -1) {
178
+ result.changed = true;
179
+ result.added = true;
180
+ result.toml = toml.trimEnd() + buildAgentsBlock(desired);
181
+ return result;
182
+ }
183
+
184
+ const afterHeader = headerIdx + AGENTS_HEADER.length;
185
+ const nextSection = toml.indexOf("\n[", afterHeader);
186
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
187
+ let section = toml.substring(afterHeader, sectionEnd);
188
+
189
+ const maxThreadsRegex = /^max_threads\s*=\s*(\d+)/m;
190
+ const match = section.match(maxThreadsRegex);
191
+ if (match) {
192
+ result.existing = parsePositiveInt(match[1]);
193
+ if (overwrite && result.existing !== desired) {
194
+ section = section.replace(maxThreadsRegex, `max_threads = ${desired}`);
195
+ result.changed = true;
196
+ result.updated = true;
197
+ }
198
+ } else {
199
+ section = section.trimEnd() + `\nmax_threads = ${desired}\n`;
200
+ result.changed = true;
201
+ result.added = true;
202
+ }
203
+
204
+ if (result.changed) {
205
+ result.toml = toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
206
+ }
207
+
208
+ return result;
209
+ }
210
+
211
+ /**
212
+ * Check whether config has a [features] section.
213
+ */
214
+ export function hasFeaturesSection(toml) {
215
+ return /^\[features\]/m.test(toml);
216
+ }
217
+
218
+ /**
219
+ * Check whether config has a [shell_environment_policy] section.
220
+ */
221
+ export function hasShellEnvPolicy(toml) {
222
+ return /^\[shell_environment_policy\]/m.test(toml);
223
+ }
224
+
225
+ /**
226
+ * Check whether config has sandbox_permissions set (top-level key).
227
+ */
228
+ export function hasSandboxPermissions(toml) {
229
+ return /^sandbox_permissions\s*=/m.test(toml);
230
+ }
231
+
232
+ /**
233
+ * Build a [features] block with the recommended flags.
234
+ * Reads environment overrides: set CODEX_FEATURES_<NAME>=false to disable.
235
+ *
236
+ * @param {object} [envOverrides] Optional env map (defaults to process.env)
237
+ * @returns {string} TOML block
238
+ */
239
+ export function buildFeaturesBlock(envOverrides = process.env) {
240
+ const lines = [
241
+ "",
242
+ "# ── Feature flags (added by openfleet) ──",
243
+ "[features]",
244
+ ];
245
+
246
+ for (const [key, meta] of Object.entries(RECOMMENDED_FEATURES)) {
247
+ let enabled = meta.default;
248
+ // Check env override
249
+ if (meta.envVar && envOverrides[meta.envVar] !== undefined) {
250
+ enabled = parseBoolEnv(envOverrides[meta.envVar]);
251
+ }
252
+ if (meta.comment) lines.push(`# ${meta.comment}`);
253
+ lines.push(`${key} = ${enabled}`);
254
+ }
255
+
256
+ lines.push("");
257
+ return lines.join("\n");
258
+ }
259
+
260
+ /**
261
+ * Ensure all recommended feature flags are present in an existing [features]
262
+ * section. Only adds missing keys — never overwrites user choices.
263
+ *
264
+ * @param {string} toml Current config.toml content
265
+ * @param {object} [envOverrides] Optional env map (defaults to process.env)
266
+ * @returns {{ toml: string, added: string[] }}
267
+ */
268
+ export function ensureFeatureFlags(toml, envOverrides = process.env) {
269
+ const added = [];
270
+
271
+ if (!hasFeaturesSection(toml)) {
272
+ toml += buildFeaturesBlock(envOverrides);
273
+ added.push(...Object.keys(RECOMMENDED_FEATURES));
274
+ return { toml, added };
275
+ }
276
+
277
+ // Find the [features] section boundaries
278
+ const header = "[features]";
279
+ const headerIdx = toml.indexOf(header);
280
+ const afterHeader = headerIdx + header.length;
281
+ const nextSection = toml.indexOf("\n[", afterHeader);
282
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
283
+ let section = toml.substring(afterHeader, sectionEnd);
284
+
285
+ for (const [key, meta] of Object.entries(RECOMMENDED_FEATURES)) {
286
+ const keyRegex = new RegExp(`^${escapeRegex(key)}\\s*=`, "m");
287
+ const hasEnvOverride =
288
+ meta.envVar && envOverrides[meta.envVar] !== undefined;
289
+ const envValue = hasEnvOverride
290
+ ? parseBoolEnv(envOverrides[meta.envVar])
291
+ : null;
292
+
293
+ if (!keyRegex.test(section)) {
294
+ const enabled = hasEnvOverride ? envValue : meta.default;
295
+ section = section.trimEnd() + `\n${key} = ${enabled}\n`;
296
+ added.push(key);
297
+ continue;
298
+ }
299
+
300
+ if (hasEnvOverride) {
301
+ const valueRegex = new RegExp(
302
+ `^(${escapeRegex(key)}\\s*=\\s*)(true|false)\\b.*$`,
303
+ "m",
304
+ );
305
+ if (valueRegex.test(section)) {
306
+ section = section.replace(valueRegex, `$1${envValue}`);
307
+ }
308
+ }
309
+
310
+ if (CRITICAL_ALWAYS_ON_FEATURES.has(key)) {
311
+ const disabledRegex = new RegExp(
312
+ `^(${escapeRegex(key)}\\s*=\\s*)false\\b.*$`,
313
+ "m",
314
+ );
315
+ if (disabledRegex.test(section)) {
316
+ section = section.replace(disabledRegex, `$1true`);
317
+ }
318
+ }
319
+ }
320
+
321
+ toml = toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
322
+ return { toml, added };
323
+ }
324
+
325
+ /**
326
+ * Build the sandbox_permissions top-level key.
327
+ * Default: ["disk-full-write-access"] for agentic workloads.
328
+ *
329
+ * @param {string} [envValue] CODEX_SANDBOX_PERMISSIONS env var value
330
+ * @returns {string} TOML line(s)
331
+ */
332
+ export function buildSandboxPermissions(envValue) {
333
+ const perms = envValue
334
+ ? envValue.split(",").map((s) => `"${s.trim()}"`)
335
+ : ['"disk-full-write-access"'];
336
+ return `\n# Sandbox permissions (added by openfleet)\nsandbox_permissions = [${perms.join(", ")}]\n`;
337
+ }
338
+
339
+ function parseTomlArrayLiteral(raw) {
340
+ if (!raw) return [];
341
+ const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
342
+ if (!inner.trim()) return [];
343
+ return inner
344
+ .split(",")
345
+ .map((item) => item.trim())
346
+ .filter(Boolean)
347
+ .map((item) => item.replace(/^"(.*)"$/, "$1"));
348
+ }
349
+
350
+ function formatTomlArray(values) {
351
+ return `[${values.map((value) => `"${String(value).replace(/"/g, '\\"')}"`).join(", ")}]`;
352
+ }
353
+
354
+ function normalizeWritableRoots(input, { repoRoot } = {}) {
355
+ const roots = new Set();
356
+ const addRoot = (value) => {
357
+ const trimmed = String(value || "").trim();
358
+ if (!trimmed) return;
359
+ roots.add(trimmed);
360
+ };
361
+ if (Array.isArray(input)) {
362
+ input.forEach(addRoot);
363
+ } else if (typeof input === "string") {
364
+ input
365
+ .split(",")
366
+ .map((entry) => entry.trim())
367
+ .filter(Boolean)
368
+ .forEach(addRoot);
369
+ }
370
+
371
+ if (repoRoot) {
372
+ const repo = String(repoRoot);
373
+ if (repo) {
374
+ addRoot(repo);
375
+ addRoot(resolve(repo, ".git"));
376
+ const parent = dirname(repo);
377
+ if (parent && parent !== repo) addRoot(parent);
378
+ }
379
+ }
380
+
381
+ return Array.from(roots);
382
+ }
383
+
384
+ export function hasSandboxWorkspaceWrite(toml) {
385
+ return /^\[sandbox_workspace_write\]/m.test(toml);
386
+ }
387
+
388
+ export function ensureSandboxWorkspaceWrite(toml, options = {}) {
389
+ const {
390
+ writableRoots = [],
391
+ repoRoot,
392
+ networkAccess = true,
393
+ excludeTmpdirEnvVar = false,
394
+ excludeSlashTmp = false,
395
+ } = options;
396
+
397
+ const desiredRoots = normalizeWritableRoots(writableRoots, { repoRoot });
398
+ if (!hasSandboxWorkspaceWrite(toml)) {
399
+ if (desiredRoots.length === 0) {
400
+ return { toml, changed: false, added: false, rootsAdded: [] };
401
+ }
402
+ const block = [
403
+ "",
404
+ "# ── Workspace-write sandbox defaults (added by openfleet) ──",
405
+ "[sandbox_workspace_write]",
406
+ `network_access = ${networkAccess}`,
407
+ `exclude_tmpdir_env_var = ${excludeTmpdirEnvVar}`,
408
+ `exclude_slash_tmp = ${excludeSlashTmp}`,
409
+ `writable_roots = ${formatTomlArray(desiredRoots)}`,
410
+ "",
411
+ ].join("\n");
412
+ return {
413
+ toml: toml.trimEnd() + "\n" + block,
414
+ changed: true,
415
+ added: true,
416
+ rootsAdded: desiredRoots,
417
+ };
418
+ }
419
+
420
+ const header = "[sandbox_workspace_write]";
421
+ const headerIdx = toml.indexOf(header);
422
+ if (headerIdx === -1) {
423
+ return { toml, changed: false, added: false, rootsAdded: [] };
424
+ }
425
+
426
+ const afterHeader = headerIdx + header.length;
427
+ const nextSection = toml.indexOf("\n[", afterHeader);
428
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
429
+ let section = toml.substring(afterHeader, sectionEnd);
430
+ let changed = false;
431
+ let rootsAdded = [];
432
+
433
+ const ensureFlag = (key, value) => {
434
+ const keyRegex = new RegExp(`^${escapeRegex(key)}\\s*=`, "m");
435
+ if (!keyRegex.test(section)) {
436
+ section = section.trimEnd() + `\n${key} = ${value}\n`;
437
+ changed = true;
438
+ }
439
+ };
440
+
441
+ ensureFlag("network_access", networkAccess);
442
+ ensureFlag("exclude_tmpdir_env_var", excludeTmpdirEnvVar);
443
+ ensureFlag("exclude_slash_tmp", excludeSlashTmp);
444
+
445
+ const rootsRegex = /^writable_roots\s*=\s*(\[[^\]]*\])\s*$/m;
446
+ const match = section.match(rootsRegex);
447
+ if (match) {
448
+ const existingRoots = parseTomlArrayLiteral(match[1]);
449
+ const merged = normalizeWritableRoots(existingRoots, { repoRoot });
450
+ for (const root of desiredRoots) {
451
+ if (!merged.includes(root)) {
452
+ merged.push(root);
453
+ rootsAdded.push(root);
454
+ }
455
+ }
456
+ const formatted = formatTomlArray(merged);
457
+ if (formatted !== match[1]) {
458
+ section = section.replace(rootsRegex, `writable_roots = ${formatted}`);
459
+ changed = true;
460
+ }
461
+ } else if (desiredRoots.length > 0) {
462
+ section = section.trimEnd() + `\nwritable_roots = ${formatTomlArray(desiredRoots)}\n`;
463
+ rootsAdded = desiredRoots;
464
+ changed = true;
465
+ }
466
+
467
+ if (!changed) {
468
+ return { toml, changed: false, added: false, rootsAdded: [] };
469
+ }
470
+
471
+ const updatedToml =
472
+ toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
473
+
474
+ return {
475
+ toml: updatedToml,
476
+ changed: true,
477
+ added: false,
478
+ rootsAdded,
479
+ };
480
+ }
481
+
482
+ /**
483
+ * Build the [shell_environment_policy] section.
484
+ * Default: inherit = "all" so .NET, Go, Node etc. env vars are visible.
485
+ *
486
+ * @param {string} [policy] "all" | "none" | "allowlist"
487
+ * @returns {string} TOML block
488
+ */
489
+ export function buildShellEnvPolicy(policy = "all") {
490
+ return [
491
+ "",
492
+ "# ── Shell environment policy (added by openfleet) ──",
493
+ "[shell_environment_policy]",
494
+ `inherit = "${policy}"`,
495
+ "",
496
+ ].join("\n");
497
+ }
498
+
499
+ /**
500
+ * Check whether config has a [mcp_servers.context7] section.
501
+ */
502
+ export function hasContext7Mcp(toml) {
503
+ return /^\[mcp_servers\.context7\]/m.test(toml);
504
+ }
505
+
506
+ /**
507
+ * Check whether config has a [mcp_servers.microsoft-docs] or microsoft_docs section.
508
+ */
509
+ export function hasMicrosoftDocsMcp(toml) {
510
+ return /^\[mcp_servers\.microsoft[_-]docs\]/m.test(toml);
511
+ }
512
+
513
+ /**
514
+ * Build MCP server blocks for context7 and microsoft-docs.
515
+ * These are universally useful for documentation lookups.
516
+ */
517
+ export function buildCommonMcpBlocks() {
518
+ return [
519
+ "",
520
+ "# ── Common MCP servers (added by openfleet) ──",
521
+ "[mcp_servers.context7]",
522
+ 'command = "npx"',
523
+ 'args = ["-y", "@upstash/context7-mcp"]',
524
+ "",
525
+ "[mcp_servers.sequential-thinking]",
526
+ 'command = "npx"',
527
+ 'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]',
528
+ "",
529
+ "[mcp_servers.playwright]",
530
+ 'command = "npx"',
531
+ 'args = ["-y", "@playwright/mcp@latest"]',
532
+ "",
533
+ "[mcp_servers.microsoft-docs]",
534
+ 'url = "https://learn.microsoft.com/api/mcp"',
535
+ "",
536
+ ].join("\n");
537
+ }
538
+
539
+ function hasNamedMcpServer(toml, name) {
540
+ return new RegExp(`^\\[mcp_servers\\.${escapeRegex(name)}\\]`, "m").test(
541
+ toml,
542
+ );
543
+ }
544
+
545
+ // ── Public API ───────────────────────────────────────────────────────────────
546
+
547
+ /**
548
+ * Read the current config.toml (or return empty string if it doesn't exist).
549
+ */
550
+ export function readCodexConfig() {
551
+ if (!existsSync(CONFIG_PATH)) return "";
552
+ return readFileSync(CONFIG_PATH, "utf8");
553
+ }
554
+
555
+ /**
556
+ * Write the config.toml, creating ~/.codex/ if needed.
557
+ */
558
+ export function writeCodexConfig(content) {
559
+ mkdirSync(CODEX_DIR, { recursive: true });
560
+ writeFileSync(CONFIG_PATH, content, "utf8");
561
+ }
562
+
563
+ /**
564
+ * Get the path to the Codex config file.
565
+ */
566
+ export function getConfigPath() {
567
+ return CONFIG_PATH;
568
+ }
569
+
570
+ /**
571
+ * Check whether the config already has a [mcp_servers.vibe_kanban] section.
572
+ */
573
+ export function hasVibeKanbanMcp(toml) {
574
+ return /^\[mcp_servers\.vibe_kanban\]/m.test(toml);
575
+ }
576
+
577
+ /**
578
+ * Check whether the config already has a [mcp_servers.vibe_kanban.env] section.
579
+ */
580
+ export function hasVibeKanbanEnv(toml) {
581
+ return /^\[mcp_servers\.vibe_kanban\.env\]/m.test(toml);
582
+ }
583
+
584
+ /**
585
+ * Remove the [mcp_servers.vibe_kanban] and [mcp_servers.vibe_kanban.env]
586
+ * sections (and their contents) from config.toml.
587
+ * Returns the cleaned TOML string.
588
+ */
589
+ export function removeVibeKanbanMcp(toml) {
590
+ // Line-based approach: walk lines and skip VK-related sections.
591
+ const lines = toml.split("\n");
592
+ const out = [];
593
+ let skipping = false;
594
+ // Track comment lines immediately preceding a VK section header
595
+ let pendingComments = [];
596
+
597
+ for (const line of lines) {
598
+ // Detect section headers: lines starting with [ that aren't array values
599
+ const isSectionHeader = /^\[[\w]/.test(line);
600
+ const isVkSection =
601
+ /^\[mcp_servers\.vibe_kanban\b/.test(line);
602
+
603
+ if (isVkSection) {
604
+ // Drop any pending comment lines (they belong to this VK section)
605
+ pendingComments = [];
606
+ skipping = true;
607
+ continue;
608
+ }
609
+
610
+ if (skipping && isSectionHeader) {
611
+ // We've reached the next non-VK section — stop skipping
612
+ skipping = false;
613
+ }
614
+
615
+ if (skipping) continue;
616
+
617
+ // Buffer comment/blank lines that might precede a VK section header
618
+ if (/^#.*[Vv]ibe.[Kk]anban/.test(line) || /^# ── .*[Vv]ibe.[Kk]anban/.test(line)) {
619
+ pendingComments.push(line);
620
+ continue;
621
+ }
622
+
623
+ // Flush pending comments (they weren't followed by a VK header)
624
+ if (pendingComments.length) {
625
+ out.push(...pendingComments);
626
+ pendingComments = [];
627
+ }
628
+
629
+ out.push(line);
630
+ }
631
+
632
+ // Flush any remaining pending comments
633
+ if (pendingComments.length) {
634
+ out.push(...pendingComments);
635
+ }
636
+
637
+ // Clean up excessive blank lines
638
+ return out.join("\n").replace(/\n{3,}/g, "\n\n");
639
+ }
640
+
641
+ /**
642
+ * Check whether the config already has an [agent_sdk] section.
643
+ */
644
+ export function hasAgentSdkConfig(toml) {
645
+ return /^\[agent_sdk\]/m.test(toml);
646
+ }
647
+
648
+ /**
649
+ * Build the default agent SDK block.
650
+ */
651
+ export function buildAgentSdkBlock() {
652
+ return DEFAULT_AGENT_SDK_BLOCK;
653
+ }
654
+
655
+ /**
656
+ * Build the vibe_kanban MCP server block (including env vars).
657
+ *
658
+ * The version is pinned from the local package.json dependency to avoid
659
+ * slow npx re-downloads when @latest resolves to a new version.
660
+ *
661
+ * Only VK_BASE_URL and VK_ENDPOINT_URL are set — the MCP server reads
662
+ * the backend port from the VK port file, so PORT/HOST env vars are not
663
+ * needed and were removed to avoid confusion.
664
+ *
665
+ * @param {object} opts
666
+ * @param {string} opts.vkBaseUrl e.g. "http://127.0.0.1:54089"
667
+ */
668
+ export function buildVibeKanbanBlock({
669
+ vkBaseUrl = "http://127.0.0.1:54089",
670
+ } = {}) {
671
+ const vkVersion = getVibeKanbanVersion();
672
+ return [
673
+ "",
674
+ "# ── Vibe-Kanban MCP (added by openfleet) ──",
675
+ "[mcp_servers.vibe_kanban]",
676
+ "startup_timeout_sec = 120",
677
+ "args = [",
678
+ ' "-y",',
679
+ ` "vibe-kanban@${vkVersion}",`,
680
+ ' "--mcp",',
681
+ "]",
682
+ 'command = "npx"',
683
+ 'tools = ["*"]',
684
+ "",
685
+ "[mcp_servers.vibe_kanban.env]",
686
+ "# Ensure MCP always targets the correct VK API endpoint.",
687
+ `VK_BASE_URL = "${vkBaseUrl}"`,
688
+ `VK_ENDPOINT_URL = "${vkBaseUrl}"`,
689
+ "",
690
+ ].join("\n");
691
+ }
692
+
693
+ /**
694
+ * Update the env vars inside an existing [mcp_servers.vibe_kanban.env] section.
695
+ * If a key already exists with a different value, it is replaced.
696
+ * If a key is missing, it is appended to the section.
697
+ *
698
+ * @param {string} toml Current config.toml content
699
+ * @param {object} envVars Key-value pairs to ensure
700
+ * @returns {string} Updated TOML
701
+ */
702
+ export function updateVibeKanbanEnv(toml, envVars) {
703
+ const envHeader = "[mcp_servers.vibe_kanban.env]";
704
+ const headerIdx = toml.indexOf(envHeader);
705
+ if (headerIdx === -1) return toml; // section doesn't exist
706
+
707
+ // Find the end of this section (next [header] or EOF)
708
+ const afterHeader = headerIdx + envHeader.length;
709
+ const nextSection = toml.indexOf("\n[", afterHeader);
710
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
711
+
712
+ let section = toml.substring(afterHeader, sectionEnd);
713
+
714
+ for (const [key, value] of Object.entries(envVars)) {
715
+ // Check if key already exists in section
716
+ const keyRegex = new RegExp(`^${escapeRegex(key)}\\s*=\\s*.*$`, "m");
717
+ const match = section.match(keyRegex);
718
+ if (match) {
719
+ // Replace existing value
720
+ section = section.replace(keyRegex, `${key} = "${value}"`);
721
+ } else {
722
+ // Append before end of section
723
+ section = section.trimEnd() + `\n${key} = "${value}"\n`;
724
+ }
725
+ }
726
+
727
+ return toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
728
+ }
729
+
730
+ /**
731
+ * Scan all [model_providers.*] sections for stream_idle_timeout_ms.
732
+ * Returns an array of { provider, currentValue, needsUpdate }.
733
+ */
734
+ export function auditStreamTimeouts(toml) {
735
+ const results = [];
736
+ // Find all model_providers sections
737
+ const providerRegex = /^\[model_providers\.(\w+)\]/gm;
738
+ let match;
739
+ while ((match = providerRegex.exec(toml)) !== null) {
740
+ const providerName = match[1];
741
+ const sectionStart = match.index + match[0].length;
742
+ const nextSection = toml.indexOf("\n[", sectionStart);
743
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
744
+ const section = toml.substring(sectionStart, sectionEnd);
745
+
746
+ const timeoutMatch = section.match(/stream_idle_timeout_ms\s*=\s*(\d+)/);
747
+ const currentValue = timeoutMatch ? Number(timeoutMatch[1]) : null;
748
+
749
+ results.push({
750
+ provider: providerName,
751
+ currentValue,
752
+ needsUpdate:
753
+ currentValue === null || currentValue < MIN_STREAM_IDLE_TIMEOUT_MS,
754
+ recommended: RECOMMENDED_STREAM_IDLE_TIMEOUT_MS,
755
+ });
756
+ }
757
+ return results;
758
+ }
759
+
760
+ /**
761
+ * Set stream_idle_timeout_ms on a specific model provider section.
762
+ * If the key already exists, update it. If not, append it at the end of the section.
763
+ *
764
+ * @param {string} toml Current TOML content
765
+ * @param {string} providerName e.g. "azure", "openai"
766
+ * @param {number} value Timeout in ms
767
+ * @returns {string} Updated TOML
768
+ */
769
+ export function setStreamTimeout(toml, providerName, value) {
770
+ const header = `[model_providers.${providerName}]`;
771
+ const headerIdx = toml.indexOf(header);
772
+ if (headerIdx === -1) return toml;
773
+
774
+ const afterHeader = headerIdx + header.length;
775
+ const nextSection = toml.indexOf("\n[", afterHeader);
776
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
777
+
778
+ let section = toml.substring(afterHeader, sectionEnd);
779
+
780
+ const timeoutRegex = /^stream_idle_timeout_ms\s*=\s*\d+.*$/m;
781
+ if (timeoutRegex.test(section)) {
782
+ section = section.replace(
783
+ timeoutRegex,
784
+ `stream_idle_timeout_ms = ${value} # Updated by openfleet`,
785
+ );
786
+ } else {
787
+ // Append to end of section
788
+ section =
789
+ section.trimEnd() +
790
+ `\nstream_idle_timeout_ms = ${value} # Added by openfleet\n`;
791
+ }
792
+
793
+ return toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
794
+ }
795
+
796
+ function hasModelProviderSection(toml, providerName) {
797
+ return new RegExp(`^\\[model_providers\\.${escapeRegex(providerName)}\\]`, "m").test(
798
+ toml,
799
+ );
800
+ }
801
+
802
+ function buildModelProviderSection(providerName, config = {}) {
803
+ const lines = ["", `[model_providers.${providerName}]`];
804
+ if (config.name) lines.push(`name = "${config.name}"`);
805
+ if (config.baseUrl) lines.push(`base_url = "${config.baseUrl}"`);
806
+ if (config.envKey) lines.push(`env_key = "${config.envKey}"`);
807
+ if (config.wireApi) lines.push(`wire_api = "${config.wireApi}"`);
808
+ if (config.model) lines.push(`model = "${config.model}"`);
809
+ lines.push("");
810
+ return lines.join("\n");
811
+ }
812
+
813
+ function ensureModelProviderSectionsFromEnv(toml, env = process.env) {
814
+ const added = [];
815
+ const { env: resolvedEnv, active } = resolveCodexProfileRuntime(env);
816
+
817
+ const activeProvider = String(active?.provider || "").toLowerCase();
818
+ const activeBaseUrl =
819
+ active?.baseUrl ||
820
+ resolvedEnv.OPENAI_BASE_URL ||
821
+ "";
822
+
823
+ if (
824
+ activeProvider === "azure" ||
825
+ String(activeBaseUrl).toLowerCase().includes(".openai.azure.com")
826
+ ) {
827
+ if (!hasModelProviderSection(toml, "azure")) {
828
+ toml += buildModelProviderSection("azure", {
829
+ name: "Azure OpenAI",
830
+ baseUrl: activeBaseUrl,
831
+ envKey: "AZURE_OPENAI_API_KEY",
832
+ wireApi: "responses",
833
+ model: active?.model || resolvedEnv.CODEX_MODEL || "",
834
+ });
835
+ added.push("azure");
836
+ }
837
+ }
838
+
839
+ if (!hasModelProviderSection(toml, "openai")) {
840
+ toml += buildModelProviderSection("openai", {
841
+ name: "OpenAI",
842
+ envKey: "OPENAI_API_KEY",
843
+ });
844
+ added.push("openai");
845
+ }
846
+
847
+ return { toml, added };
848
+ }
849
+
850
+ /**
851
+ * Ensure retry settings exist on a model provider section.
852
+ * Adds sensible defaults for long-running agentic workloads.
853
+ */
854
+ export function ensureRetrySettings(toml, providerName) {
855
+ const header = `[model_providers.${providerName}]`;
856
+ const headerIdx = toml.indexOf(header);
857
+ if (headerIdx === -1) return toml;
858
+
859
+ const afterHeader = headerIdx + header.length;
860
+ const nextSection = toml.indexOf("\n[", afterHeader);
861
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
862
+
863
+ let section = toml.substring(afterHeader, sectionEnd);
864
+
865
+ const defaults = {
866
+ request_max_retries: 6,
867
+ stream_max_retries: 15,
868
+ };
869
+
870
+ for (const [key, defaultVal] of Object.entries(defaults)) {
871
+ const keyRegex = new RegExp(`^${key}\\s*=`, "m");
872
+ if (!keyRegex.test(section)) {
873
+ section =
874
+ section.trimEnd() +
875
+ `\n${key} = ${defaultVal} # Added by openfleet\n`;
876
+ }
877
+ }
878
+
879
+ return toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
880
+ }
881
+
882
+ /**
883
+ * High-level: ensure the config.toml is properly configured for openfleet.
884
+ *
885
+ * Returns an object describing what was done:
886
+ * { created, vkAdded, vkEnvUpdated, timeoutsFixed[], retriesAdded[],
887
+ * featuresAdded[], sandboxAdded, shellEnvAdded, commonMcpAdded, path }
888
+ *
889
+ * @param {object} opts
890
+ * @param {string} [opts.vkBaseUrl]
891
+ * @param {boolean} [opts.skipVk]
892
+ * @param {boolean} [opts.dryRun] If true, returns result without writing
893
+ * @param {object} [opts.env] Environment overrides (defaults to process.env)
894
+ */
895
+ export function ensureCodexConfig({
896
+ vkBaseUrl = "http://127.0.0.1:54089",
897
+ skipVk = false,
898
+ dryRun = false,
899
+ env = process.env,
900
+ } = {}) {
901
+ const result = {
902
+ path: CONFIG_PATH,
903
+ created: false,
904
+ vkAdded: false,
905
+ vkRemoved: false,
906
+ vkEnvUpdated: false,
907
+ agentSdkAdded: false,
908
+ featuresAdded: [],
909
+ agentMaxThreads: null,
910
+ agentMaxThreadsSkipped: null,
911
+ sandboxAdded: false,
912
+ sandboxWorkspaceAdded: false,
913
+ sandboxWorkspaceUpdated: false,
914
+ sandboxWorkspaceRootsAdded: [],
915
+ shellEnvAdded: false,
916
+ commonMcpAdded: false,
917
+ profileProvidersAdded: [],
918
+ timeoutsFixed: [],
919
+ retriesAdded: [],
920
+ noChanges: false,
921
+ };
922
+
923
+ let toml = readCodexConfig();
924
+
925
+ // If config.toml doesn't exist at all, create a minimal one
926
+ if (!toml) {
927
+ result.created = true;
928
+ toml = [
929
+ "# Codex CLI configuration",
930
+ "# Generated by openfleet setup wizard",
931
+ "#",
932
+ "# See: codex --help or https://github.com/openai/codex for details.",
933
+ "",
934
+ "",
935
+ ].join("\n");
936
+ }
937
+
938
+ // ── 1. Vibe-Kanban MCP server ────────────────────────────
939
+ // When VK is not the active kanban backend, remove the MCP section
940
+ // so the Codex CLI doesn't try to spawn it.
941
+
942
+ if (skipVk) {
943
+ if (hasVibeKanbanMcp(toml)) {
944
+ toml = removeVibeKanbanMcp(toml);
945
+ result.vkRemoved = true;
946
+ }
947
+ } else if (!hasVibeKanbanMcp(toml)) {
948
+ toml += buildVibeKanbanBlock({ vkBaseUrl });
949
+ result.vkAdded = true;
950
+ } else {
951
+ // MCP section exists — ensure env vars are up to date
952
+ if (!hasVibeKanbanEnv(toml)) {
953
+ // Has the server but no env section — append env block
954
+ const envBlock = [
955
+ "",
956
+ "[mcp_servers.vibe_kanban.env]",
957
+ "# Ensure MCP always targets the correct VK API endpoint.",
958
+ `VK_BASE_URL = "${vkBaseUrl}"`,
959
+ `VK_ENDPOINT_URL = "${vkBaseUrl}"`,
960
+ "",
961
+ ].join("\n");
962
+
963
+ // Insert after [mcp_servers.vibe_kanban] section content, before next section
964
+ const vkHeader = "[mcp_servers.vibe_kanban]";
965
+ const vkIdx = toml.indexOf(vkHeader);
966
+ const afterVk = vkIdx + vkHeader.length;
967
+ const nextSectionAfterVk = toml.indexOf("\n[", afterVk);
968
+
969
+ if (nextSectionAfterVk === -1) {
970
+ toml += envBlock;
971
+ } else {
972
+ toml =
973
+ toml.substring(0, nextSectionAfterVk) +
974
+ "\n" +
975
+ envBlock +
976
+ toml.substring(nextSectionAfterVk);
977
+ }
978
+ result.vkEnvUpdated = true;
979
+ } else {
980
+ // Both server and env exist — ensure values match
981
+ const envVars = {
982
+ VK_BASE_URL: vkBaseUrl,
983
+ VK_ENDPOINT_URL: vkBaseUrl,
984
+ };
985
+ const before = toml;
986
+ toml = updateVibeKanbanEnv(toml, envVars);
987
+ if (toml !== before) {
988
+ result.vkEnvUpdated = true;
989
+ }
990
+ }
991
+ }
992
+
993
+ // ── 1b. Ensure agent SDK selection block ──────────────────
994
+
995
+ if (!hasAgentSdkConfig(toml)) {
996
+ toml += buildAgentSdkBlock();
997
+ result.agentSdkAdded = true;
998
+ }
999
+
1000
+ // ── 1c. Ensure feature flags (sub-agents, memory, etc.) ──
1001
+
1002
+ {
1003
+ const { toml: updated, added } = ensureFeatureFlags(toml, env);
1004
+ if (added.length > 0) {
1005
+ toml = updated;
1006
+ result.featuresAdded = added;
1007
+ }
1008
+ }
1009
+
1010
+ // ── 1d. Ensure agent thread limits ──────────────────────
1011
+
1012
+ {
1013
+ const desired = resolveAgentMaxThreads(env);
1014
+ const ensured = ensureAgentMaxThreads(toml, {
1015
+ maxThreads: desired.value,
1016
+ overwrite: desired.explicit,
1017
+ });
1018
+ if (ensured.changed) {
1019
+ toml = ensured.toml;
1020
+ result.agentMaxThreads = {
1021
+ from: ensured.existing,
1022
+ to: ensured.applied,
1023
+ explicit: desired.explicit,
1024
+ };
1025
+ } else if (ensured.skipped && desired.explicit) {
1026
+ result.agentMaxThreadsSkipped = desired.raw;
1027
+ }
1028
+ }
1029
+
1030
+ // ── 1e. Ensure sandbox permissions ────────────────────────
1031
+
1032
+ if (!hasSandboxPermissions(toml)) {
1033
+ const envPerms = env.CODEX_SANDBOX_PERMISSIONS || "";
1034
+ toml = toml.trimEnd() + "\n" + buildSandboxPermissions(envPerms || undefined);
1035
+ result.sandboxAdded = true;
1036
+ }
1037
+
1038
+ // ── 1f. Ensure sandbox workspace-write defaults ───────────
1039
+
1040
+ {
1041
+ const ensured = ensureSandboxWorkspaceWrite(toml, {
1042
+ writableRoots: env.CODEX_SANDBOX_WRITABLE_ROOTS || "",
1043
+ repoRoot: env.REPO_ROOT || "",
1044
+ });
1045
+ if (ensured.changed) {
1046
+ toml = ensured.toml;
1047
+ result.sandboxWorkspaceAdded = ensured.added;
1048
+ result.sandboxWorkspaceUpdated = !ensured.added;
1049
+ result.sandboxWorkspaceRootsAdded = ensured.rootsAdded || [];
1050
+ }
1051
+ }
1052
+
1053
+ // ── 1g. Ensure shell environment policy ───────────────────
1054
+
1055
+ if (!hasShellEnvPolicy(toml)) {
1056
+ const policy = env.CODEX_SHELL_ENV_POLICY || "all";
1057
+ toml += buildShellEnvPolicy(policy);
1058
+ result.shellEnvAdded = true;
1059
+ }
1060
+
1061
+ // ── 1f. Ensure common MCP servers ───────────────────────────
1062
+
1063
+ {
1064
+ const missing = [];
1065
+ if (!hasContext7Mcp(toml)) missing.push("context7");
1066
+ if (!hasNamedMcpServer(toml, "sequential-thinking")) {
1067
+ missing.push("sequential-thinking");
1068
+ }
1069
+ if (!hasNamedMcpServer(toml, "playwright")) missing.push("playwright");
1070
+ if (!hasMicrosoftDocsMcp(toml)) missing.push("microsoft-docs");
1071
+
1072
+ if (missing.length > 0) {
1073
+ if (missing.length >= 4) {
1074
+ toml += buildCommonMcpBlocks();
1075
+ } else {
1076
+ if (missing.includes("context7")) {
1077
+ toml += [
1078
+ "",
1079
+ "[mcp_servers.context7]",
1080
+ 'command = "npx"',
1081
+ 'args = ["-y", "@upstash/context7-mcp"]',
1082
+ "",
1083
+ ].join("\n");
1084
+ }
1085
+ if (missing.includes("sequential-thinking")) {
1086
+ toml += [
1087
+ "",
1088
+ "[mcp_servers.sequential-thinking]",
1089
+ 'command = "npx"',
1090
+ 'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]',
1091
+ "",
1092
+ ].join("\n");
1093
+ }
1094
+ if (missing.includes("playwright")) {
1095
+ toml += [
1096
+ "",
1097
+ "[mcp_servers.playwright]",
1098
+ 'command = "npx"',
1099
+ 'args = ["-y", "@playwright/mcp@latest"]',
1100
+ "",
1101
+ ].join("\n");
1102
+ }
1103
+ if (missing.includes("microsoft-docs")) {
1104
+ toml += [
1105
+ "",
1106
+ "[mcp_servers.microsoft-docs]",
1107
+ 'url = "https://learn.microsoft.com/api/mcp"',
1108
+ "",
1109
+ ].join("\n");
1110
+ }
1111
+ }
1112
+ result.commonMcpAdded = true;
1113
+ }
1114
+ }
1115
+
1116
+ // ── 2. Audit and fix stream timeouts ──────────────────────
1117
+
1118
+ {
1119
+ const ensured = ensureModelProviderSectionsFromEnv(toml, env);
1120
+ toml = ensured.toml;
1121
+ result.profileProvidersAdded = ensured.added;
1122
+ }
1123
+
1124
+ const timeouts = auditStreamTimeouts(toml);
1125
+ for (const t of timeouts) {
1126
+ if (t.needsUpdate) {
1127
+ toml = setStreamTimeout(toml, t.provider, t.recommended);
1128
+ result.timeoutsFixed.push({
1129
+ provider: t.provider,
1130
+ from: t.currentValue,
1131
+ to: t.recommended,
1132
+ });
1133
+ }
1134
+ }
1135
+
1136
+ // ── 3. Ensure retry settings ──────────────────────────────
1137
+
1138
+ for (const t of timeouts) {
1139
+ const before = toml;
1140
+ toml = ensureRetrySettings(toml, t.provider);
1141
+ if (toml !== before) {
1142
+ result.retriesAdded.push(t.provider);
1143
+ }
1144
+ }
1145
+
1146
+ // ── Check if anything changed ─────────────────────────────
1147
+
1148
+ const original = readCodexConfig();
1149
+ if (toml === original && !result.created) {
1150
+ result.noChanges = true;
1151
+ return result;
1152
+ }
1153
+
1154
+ // ── Write ─────────────────────────────────────────────────
1155
+
1156
+ if (!dryRun) {
1157
+ writeCodexConfig(toml);
1158
+ }
1159
+
1160
+ return result;
1161
+ }
1162
+
1163
+ /**
1164
+ * Print a human-friendly summary of what ensureCodexConfig() did.
1165
+ * @param {object} result Return value from ensureCodexConfig()
1166
+ * @param {(msg: string) => void} [log] Logger (default: console.log)
1167
+ */
1168
+ export function printConfigSummary(result, log = console.log) {
1169
+ if (result.noChanges) {
1170
+ log(" ✅ Codex CLI config is already up to date");
1171
+ log(` ${result.path}`);
1172
+ return;
1173
+ }
1174
+
1175
+ if (result.created) {
1176
+ log(" 📝 Created new Codex CLI config");
1177
+ }
1178
+
1179
+ if (result.vkAdded) {
1180
+ log(" ✅ Added Vibe-Kanban MCP server to Codex config");
1181
+ }
1182
+
1183
+ if (result.vkRemoved) {
1184
+ log(" 🗑️ Removed Vibe-Kanban MCP server (VK backend not active)");
1185
+ }
1186
+
1187
+ if (result.vkEnvUpdated) {
1188
+ log(" ✅ Updated Vibe-Kanban MCP environment variables");
1189
+ }
1190
+
1191
+ if (result.agentSdkAdded) {
1192
+ log(" ✅ Added agent SDK selection block");
1193
+ }
1194
+
1195
+ if (result.featuresAdded && result.featuresAdded.length > 0) {
1196
+ const key = result.featuresAdded.length <= 5
1197
+ ? result.featuresAdded.join(", ")
1198
+ : `${result.featuresAdded.length} feature flags`;
1199
+ log(` ✅ Added feature flags: ${key}`);
1200
+ }
1201
+
1202
+ if (result.sandboxAdded) {
1203
+ log(" ✅ Added sandbox permissions (disk-full-write-access)");
1204
+ }
1205
+
1206
+ if (result.sandboxWorkspaceAdded) {
1207
+ log(" ✅ Added sandbox workspace-write defaults");
1208
+ } else if (result.sandboxWorkspaceUpdated) {
1209
+ log(" ✅ Updated sandbox workspace-write defaults");
1210
+ }
1211
+
1212
+ if (result.sandboxWorkspaceRootsAdded && result.sandboxWorkspaceRootsAdded.length > 0) {
1213
+ log(
1214
+ ` Writable roots: ${result.sandboxWorkspaceRootsAdded.join(", ")}`,
1215
+ );
1216
+ }
1217
+
1218
+ if (result.shellEnvAdded) {
1219
+ log(" ✅ Added shell environment policy (inherit=all)");
1220
+ }
1221
+
1222
+ if (result.agentMaxThreads) {
1223
+ const fromLabel =
1224
+ result.agentMaxThreads.from === null
1225
+ ? "unset"
1226
+ : String(result.agentMaxThreads.from);
1227
+ const toLabel = String(result.agentMaxThreads.to);
1228
+ const note = result.agentMaxThreads.explicit ? " (env override)" : "";
1229
+ log(` ✅ Set agents.max_threads: ${fromLabel} → ${toLabel}${note}`);
1230
+ } else if (result.agentMaxThreadsSkipped) {
1231
+ log(
1232
+ ` ⚠ Skipped agents.max_threads (invalid value: ${result.agentMaxThreadsSkipped})`,
1233
+ );
1234
+ }
1235
+
1236
+ if (result.commonMcpAdded) {
1237
+ log(
1238
+ " ✅ Added common MCP servers (context7, sequential-thinking, playwright, microsoft-docs)",
1239
+ );
1240
+ }
1241
+
1242
+ if (result.profileProvidersAdded && result.profileProvidersAdded.length > 0) {
1243
+ log(
1244
+ ` ✅ Added model provider sections: ${result.profileProvidersAdded.join(", ")}`,
1245
+ );
1246
+ }
1247
+
1248
+ for (const t of result.timeoutsFixed) {
1249
+ const fromLabel =
1250
+ t.from === null ? "not set" : `${(t.from / 1000).toFixed(0)}s`;
1251
+ const toLabel = `${(t.to / 1000 / 60).toFixed(0)} min`;
1252
+ log(
1253
+ ` ✅ Set stream_idle_timeout_ms on [${t.provider}]: ${fromLabel} → ${toLabel}`,
1254
+ );
1255
+ }
1256
+
1257
+ for (const p of result.retriesAdded) {
1258
+ log(` ✅ Added retry settings to [${p}]`);
1259
+ }
1260
+
1261
+ log(` Config: ${result.path}`);
1262
+ }
1263
+
1264
+ // ── Internal Helpers ─────────────────────────────────────────────────────────
1265
+
1266
+ function escapeRegex(str) {
1267
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1268
+ }
1269
+
1270
+ function parseBoolEnv(value) {
1271
+ const raw = String(value ?? "").trim().toLowerCase();
1272
+ if (["0", "false", "no", "off", "n"].includes(raw)) return false;
1273
+ return true;
1274
+ }