@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,762 @@
1
+ /**
2
+ * codex-shell.mjs — Persistent Codex agent for openfleet.
3
+ *
4
+ * Uses the Codex SDK (@openai/codex-sdk) to maintain a REAL persistent thread
5
+ * with multi-turn conversation, tool use (shell, file I/O, MCP), and streaming.
6
+ *
7
+ * This is NOT a chatbot. Each user message dispatches a full agentic turn where
8
+ * Codex can read files, run commands, call MCP tools, and produce structured
9
+ * output — all streamed back in real-time via ThreadEvent callbacks.
10
+ *
11
+ * Thread persistence: The SDK stores threads in ~/.codex/sessions. We save the
12
+ * thread_id so we can resume the same conversation across restarts.
13
+ */
14
+
15
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { resolveAgentSdkConfig } from "./agent-sdk.mjs";
19
+ import { resolveRepoRoot } from "./repo-root.mjs";
20
+ import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
21
+
22
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
23
+
24
+ // ── Configuration ────────────────────────────────────────────────────────────
25
+
26
+ const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min for agentic tasks (matches Azure stream timeout)
27
+ const STATE_FILE = resolve(__dirname, "logs", "codex-shell-state.json");
28
+ const SESSIONS_DIR = resolve(__dirname, "logs", "sessions");
29
+ const MAX_PERSISTENT_TURNS = 50;
30
+ const REPO_ROOT = resolveRepoRoot();
31
+
32
+ // ── State ────────────────────────────────────────────────────────────────────
33
+
34
+ let CodexClass = null; // The Codex class from SDK
35
+ let codexInstance = null; // Singleton Codex instance
36
+ let activeThread = null; // Current persistent Thread
37
+ let activeThreadId = null; // Thread ID for resume
38
+ let activeTurn = null; // Whether a turn is in-flight
39
+ let turnCount = 0; // Number of turns in this thread
40
+ let currentSessionId = null; // Active session identifier
41
+ let codexRuntimeCaps = {
42
+ hasSteeringApi: false,
43
+ steeringMethod: null,
44
+ };
45
+ let agentSdk = resolveAgentSdkConfig();
46
+
47
+ // ── Helpers ──────────────────────────────────────────────────────────────────
48
+
49
+ function timestamp() {
50
+ return new Date().toISOString();
51
+ }
52
+
53
+ function resolveCodexTransport() {
54
+ const raw = String(process.env.CODEX_TRANSPORT || "auto")
55
+ .trim()
56
+ .toLowerCase();
57
+ if (["auto", "sdk", "cli"].includes(raw)) {
58
+ return raw;
59
+ }
60
+ console.warn(
61
+ `[codex-shell] invalid CODEX_TRANSPORT='${raw}', defaulting to 'auto'`,
62
+ );
63
+ return "auto";
64
+ }
65
+
66
+ // ── SDK Loading ──────────────────────────────────────────────────────────────
67
+
68
+ async function loadCodexSdk() {
69
+ agentSdk = resolveAgentSdkConfig({ reload: true });
70
+ if (agentSdk.primary !== "codex") {
71
+ console.warn(
72
+ `[codex-shell] agent_sdk.primary=${agentSdk.primary} — Codex SDK disabled`,
73
+ );
74
+ return null;
75
+ }
76
+ const transport = resolveCodexTransport();
77
+ if (transport === "cli") {
78
+ console.warn(
79
+ "[codex-shell] CODEX_TRANSPORT=cli uses SDK compatibility mode with persistent thread resume",
80
+ );
81
+ }
82
+ if (CodexClass) return CodexClass;
83
+ try {
84
+ const mod = await import("@openai/codex-sdk");
85
+ CodexClass = mod.Codex;
86
+ console.log("[codex-shell] SDK loaded successfully");
87
+ return CodexClass;
88
+ } catch (err) {
89
+ console.error(`[codex-shell] failed to load SDK: ${err.message}`);
90
+ return null;
91
+ }
92
+ }
93
+
94
+ // ── State Persistence ────────────────────────────────────────────────────────
95
+
96
+ async function loadState() {
97
+ try {
98
+ const raw = await readFile(STATE_FILE, "utf8");
99
+ const data = JSON.parse(raw);
100
+ activeThreadId = data.threadId || null;
101
+ turnCount = data.turnCount || 0;
102
+ currentSessionId = data.currentSessionId || null;
103
+ console.log(
104
+ `[codex-shell] loaded state: threadId=${activeThreadId}, turns=${turnCount}, session=${currentSessionId}`,
105
+ );
106
+ } catch {
107
+ activeThreadId = null;
108
+ turnCount = 0;
109
+ currentSessionId = null;
110
+ }
111
+ }
112
+
113
+ async function saveState() {
114
+ try {
115
+ await mkdir(resolve(__dirname, "logs"), { recursive: true });
116
+ await writeFile(
117
+ STATE_FILE,
118
+ JSON.stringify(
119
+ {
120
+ threadId: activeThreadId,
121
+ turnCount,
122
+ currentSessionId,
123
+ updatedAt: timestamp(),
124
+ },
125
+ null,
126
+ 2,
127
+ ),
128
+ "utf8",
129
+ );
130
+ } catch (err) {
131
+ console.warn(`[codex-shell] failed to save state: ${err.message}`);
132
+ }
133
+ }
134
+
135
+ // ── Session Persistence ──────────────────────────────────────────────────────
136
+
137
+ function sessionFilePath(sessionId) {
138
+ return resolve(SESSIONS_DIR, `${sessionId}.json`);
139
+ }
140
+
141
+ async function loadSessionData(sessionId) {
142
+ try {
143
+ const raw = await readFile(sessionFilePath(sessionId), "utf8");
144
+ return JSON.parse(raw);
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ async function saveSessionData(sessionId, data) {
151
+ try {
152
+ await mkdir(SESSIONS_DIR, { recursive: true });
153
+ await writeFile(sessionFilePath(sessionId), JSON.stringify(data, null, 2), "utf8");
154
+ } catch (err) {
155
+ console.warn(`[codex-shell] failed to save session ${sessionId}: ${err.message}`);
156
+ }
157
+ }
158
+
159
+ async function saveCurrentSession() {
160
+ if (!currentSessionId) return;
161
+ await saveSessionData(currentSessionId, {
162
+ threadId: activeThreadId,
163
+ turnCount,
164
+ createdAt: (await loadSessionData(currentSessionId))?.createdAt || timestamp(),
165
+ lastActiveAt: timestamp(),
166
+ });
167
+ }
168
+
169
+ async function loadSession(sessionId) {
170
+ // Save current session before switching
171
+ await saveCurrentSession();
172
+ const data = await loadSessionData(sessionId);
173
+ if (data) {
174
+ activeThreadId = data.threadId || null;
175
+ turnCount = data.turnCount || 0;
176
+ activeThread = null; // will be re-created/resumed via getThread()
177
+ currentSessionId = sessionId;
178
+ console.log(`[codex-shell] loaded session ${sessionId}: threadId=${activeThreadId}, turns=${turnCount}`);
179
+ } else {
180
+ activeThread = null;
181
+ activeThreadId = null;
182
+ turnCount = 0;
183
+ currentSessionId = sessionId;
184
+ console.log(`[codex-shell] created new session ${sessionId}`);
185
+ }
186
+ await saveState();
187
+ }
188
+
189
+ // ── Thread Management ────────────────────────────────────────────────────────
190
+
191
+ const SYSTEM_PROMPT = `# AGENT DIRECTIVE — EXECUTE IMMEDIATELY
192
+
193
+ You are an autonomous AI coding agent deployed inside openfleet.
194
+ You are NOT a chatbot. You are NOT waiting for input. You EXECUTE tasks.
195
+
196
+ CRITICAL RULES:
197
+ 1. NEVER respond with "Ready" or "What would you like me to do?" — you already have your task below.
198
+ 2. NEVER ask clarifying questions — infer intent and take action.
199
+ 3. DO the work. Read files, run commands, analyze code, write output.
200
+ 4. Show your work as you go — print what you're reading, what you found, what you're doing next.
201
+ 5. Produce DETAILED, STRUCTURED output with your findings and actions taken.
202
+ 6. If the task involves analysis, actually READ the files and show what you found.
203
+ 7. If the task involves code changes, actually MAKE the changes.
204
+ 8. Think step-by-step, show your reasoning, then act.
205
+
206
+ You have FULL ACCESS to:
207
+ - The target repository checked out for this openfleet instance
208
+ - Shell: git, gh, node, go, make, and all system commands (pwsh optional)
209
+ - File read/write: read any file, create/edit any file
210
+ - MCP servers configured in this environment (availability varies)
211
+
212
+ Key files:
213
+ ${REPO_ROOT} — Repository root
214
+ .cache/ve-orchestrator-status.json — Live status data (if enabled)
215
+ scripts/openfleet/logs/ — Monitor logs (if available)
216
+ AGENTS.md — Repo guide for agents
217
+ `;
218
+
219
+ const THREAD_OPTIONS = {
220
+ sandboxMode: process.env.CODEX_SANDBOX || "workspace-write",
221
+ workingDirectory: REPO_ROOT,
222
+ skipGitRepoCheck: true,
223
+ webSearchMode: "live",
224
+ approvalPolicy: "never",
225
+ // Note: sub-agent features (child_agents_md, collab, memory_tool, etc.)
226
+ // are configured via ~/.codex/config.toml [features] section, not SDK ThreadOptions.
227
+ // codex-config.mjs ensureFeatureFlags() handles this during setup.
228
+ };
229
+
230
+ /**
231
+ * Get or create a thread.
232
+ * Uses fresh-thread mode by default to avoid context bloat.
233
+ * In CLI transport compatibility mode, reuse persisted thread IDs when possible.
234
+ */
235
+ async function getThread() {
236
+ if (activeThread) return activeThread;
237
+
238
+ const { env: resolvedEnv } = resolveCodexProfileRuntime(process.env);
239
+ Object.assign(process.env, resolvedEnv);
240
+
241
+ if (!codexInstance) {
242
+ const Cls = await loadCodexSdk();
243
+ if (!Cls) throw new Error("Codex SDK not available");
244
+ // Pass feature overrides via --config so they apply even if config.toml
245
+ // hasn't been patched by codex-config.mjs yet.
246
+ codexInstance = new Cls({
247
+ config: {
248
+ features: {
249
+ child_agents_md: true,
250
+ collab: true,
251
+ memory_tool: true,
252
+ undo: true,
253
+ steer: true,
254
+ },
255
+ },
256
+ });
257
+ }
258
+
259
+ const transport = resolveCodexTransport();
260
+ const shouldResume = transport === "cli";
261
+
262
+ if (activeThreadId && shouldResume) {
263
+ if (typeof codexInstance.resumeThread === "function") {
264
+ try {
265
+ activeThread = codexInstance.resumeThread(
266
+ activeThreadId,
267
+ THREAD_OPTIONS,
268
+ );
269
+ if (activeThread) {
270
+ detectThreadCapabilities(activeThread);
271
+ console.log(`[codex-shell] resumed thread ${activeThreadId}`);
272
+ return activeThread;
273
+ }
274
+ } catch (err) {
275
+ console.warn(
276
+ `[codex-shell] failed to resume thread ${activeThreadId}: ${err.message} — starting fresh`,
277
+ );
278
+ }
279
+ } else {
280
+ console.warn(
281
+ "[codex-shell] SDK does not expose resumeThread(); starting fresh thread",
282
+ );
283
+ }
284
+ activeThreadId = null;
285
+ }
286
+
287
+ // Fresh-thread mode (default): avoid token overflow from long-running reuse.
288
+ if (activeThreadId && !shouldResume) {
289
+ console.log(
290
+ `[codex-shell] discarding previous thread ${activeThreadId} — creating fresh thread per task`,
291
+ );
292
+ activeThreadId = null;
293
+ }
294
+
295
+ // Start a new thread with the system prompt as the first turn
296
+ activeThread = codexInstance.startThread(THREAD_OPTIONS);
297
+ detectThreadCapabilities(activeThread);
298
+
299
+ // Prime the thread with the system prompt so subsequent turns have context
300
+ try {
301
+ await activeThread.run(SYSTEM_PROMPT);
302
+ // Capture the thread ID from the prime turn
303
+ if (activeThread.id) {
304
+ activeThreadId = activeThread.id;
305
+ await saveState();
306
+ console.log(`[codex-shell] new thread started: ${activeThreadId}`);
307
+ }
308
+ } catch (err) {
309
+ console.warn(`[codex-shell] prime turn failed: ${err.message}`);
310
+ // Thread is still usable even if prime fails
311
+ }
312
+
313
+ return activeThread;
314
+ }
315
+
316
+ function detectThreadCapabilities(thread) {
317
+ if (!thread || typeof thread !== "object") {
318
+ codexRuntimeCaps = { hasSteeringApi: false, steeringMethod: null };
319
+ return codexRuntimeCaps;
320
+ }
321
+ const candidates = ["steer", "sendSteer", "steering"];
322
+ const method =
323
+ candidates.find((name) => typeof thread?.[name] === "function") || null;
324
+ codexRuntimeCaps = {
325
+ hasSteeringApi: !!method,
326
+ steeringMethod: method,
327
+ };
328
+ return codexRuntimeCaps;
329
+ }
330
+
331
+ // ── Event Formatting ─────────────────────────────────────────────────────────
332
+
333
+ /**
334
+ * Format a ThreadEvent into a human-readable string for Telegram streaming.
335
+ * Returns null for events that shouldn't be sent.
336
+ */
337
+ function formatEvent(event) {
338
+ switch (event.type) {
339
+ case "item.started": {
340
+ const item = event.item;
341
+ switch (item.type) {
342
+ case "command_execution":
343
+ return `⚡ Running: \`${item.command}\``;
344
+ case "file_change":
345
+ return null; // wait for completed
346
+ case "mcp_tool_call":
347
+ return `🔌 MCP [${item.server}]: ${item.tool}`;
348
+ case "reasoning":
349
+ return item.text ? `💭 ${item.text.slice(0, 300)}` : null;
350
+ case "agent_message":
351
+ return null; // wait for completed for full text
352
+ case "todo_list":
353
+ if (item.items && item.items.length > 0) {
354
+ const todoLines = item.items.map(
355
+ (t) => ` ${t.completed ? "✅" : "⬜"} ${t.text}`,
356
+ );
357
+ return `📋 Plan:\n${todoLines.join("\n")}`;
358
+ }
359
+ return null;
360
+ case "web_search":
361
+ return `🔍 Searching: ${item.query}`;
362
+ default:
363
+ return null;
364
+ }
365
+ }
366
+
367
+ case "item.completed": {
368
+ const item = event.item;
369
+ switch (item.type) {
370
+ case "command_execution": {
371
+ const status = item.exit_code === 0 ? "✅" : "❌";
372
+ const output = item.aggregated_output
373
+ ? `\n${item.aggregated_output.slice(-500)}`
374
+ : "";
375
+ return `${status} Command done: \`${item.command}\` (exit ${item.exit_code ?? "?"})${output}`;
376
+ }
377
+ case "file_change": {
378
+ if (item.changes && item.changes.length > 0) {
379
+ const fileLines = item.changes.map(
380
+ (c) =>
381
+ ` ${c.kind === "add" ? "➕" : c.kind === "delete" ? "🗑️" : "✏️"} ${c.path}`,
382
+ );
383
+ return `📁 Files changed:\n${fileLines.join("\n")}`;
384
+ }
385
+ return null;
386
+ }
387
+ case "agent_message":
388
+ return item.text || null;
389
+ case "mcp_tool_call": {
390
+ const status = item.status === "completed" ? "✅" : "❌";
391
+ const resultInfo = item.error
392
+ ? `Error: ${item.error.message}`
393
+ : "done";
394
+ return `${status} MCP [${item.server}/${item.tool}]: ${resultInfo}`;
395
+ }
396
+ case "todo_list": {
397
+ if (item.items && item.items.length > 0) {
398
+ const todoLines = item.items.map(
399
+ (t) => ` ${t.completed ? "✅" : "⬜"} ${t.text}`,
400
+ );
401
+ return `📋 Updated plan:\n${todoLines.join("\n")}`;
402
+ }
403
+ return null;
404
+ }
405
+ default:
406
+ return null;
407
+ }
408
+ }
409
+
410
+ case "item.updated": {
411
+ const item = event.item;
412
+ // Stream partial reasoning and command output
413
+ if (item.type === "reasoning" && item.text) {
414
+ return `💭 ${item.text.slice(0, 300)}`;
415
+ }
416
+ if (item.type === "todo_list" && item.items) {
417
+ const todoLines = item.items.map(
418
+ (t) => ` ${t.completed ? "✅" : "⬜"} ${t.text}`,
419
+ );
420
+ return `📋 Plan update:\n${todoLines.join("\n")}`;
421
+ }
422
+ return null;
423
+ }
424
+
425
+ case "turn.completed":
426
+ return null; // handled by caller
427
+ case "turn.failed":
428
+ return `❌ Turn failed: ${event.error?.message || "unknown error"}`;
429
+ case "error":
430
+ return `❌ Error: ${event.message}`;
431
+ default:
432
+ return null;
433
+ }
434
+ }
435
+
436
+ function isRecoverableThreadError(err) {
437
+ const message = err?.message || String(err || "");
438
+ const lower = message.toLowerCase();
439
+ return (
440
+ lower.includes("invalid_encrypted_content") ||
441
+ lower.includes("could not be verified") ||
442
+ lower.includes("state db missing rollout path") ||
443
+ lower.includes("rollout path") ||
444
+ lower.includes("tool call must have a tool call id") ||
445
+ lower.includes("tool_call_id") ||
446
+ (lower.includes("400") && lower.includes("tool call"))
447
+ );
448
+ }
449
+
450
+ // ── Main Execution ───────────────────────────────────────────────────────────
451
+
452
+ /**
453
+ * Send a message to the Codex agent and stream events back.
454
+ *
455
+ * @param {string} userMessage - The user's message/task
456
+ * @param {object} options
457
+ * @param {function} options.onEvent - Callback for each formatted event string
458
+ * @param {object} options.statusData - Current orchestrator status (for context)
459
+ * @param {number} options.timeoutMs - Timeout in ms
460
+ * @returns {Promise<{finalResponse: string, items: Array, usage: object|null}>}
461
+ */
462
+ export async function execCodexPrompt(userMessage, options = {}) {
463
+ const {
464
+ onEvent = null,
465
+ statusData = null,
466
+ timeoutMs = DEFAULT_TIMEOUT_MS,
467
+ sendRawEvents = false,
468
+ abortController = null,
469
+ persistent = false,
470
+ sessionId = null,
471
+ } = options;
472
+
473
+ agentSdk = resolveAgentSdkConfig({ reload: true });
474
+ if (agentSdk.primary !== "codex") {
475
+ return {
476
+ finalResponse: `❌ Agent SDK set to "${agentSdk.primary}" — Codex SDK disabled.`,
477
+ items: [],
478
+ usage: null,
479
+ };
480
+ }
481
+
482
+ if (activeTurn) {
483
+ return {
484
+ finalResponse:
485
+ "⏳ Agent is still executing a previous task. Please wait.",
486
+ items: [],
487
+ usage: null,
488
+ };
489
+ }
490
+
491
+ activeTurn = true;
492
+
493
+ try {
494
+ if (!persistent) {
495
+ // Task executor path — keep existing fresh-thread behavior
496
+ activeThread = null;
497
+ } else if (sessionId && sessionId !== currentSessionId) {
498
+ // Switching to a different persistent session
499
+ await loadSession(sessionId);
500
+ } else if (!currentSessionId) {
501
+ // First persistent call — initialise the default "primary" session
502
+ await loadSession(sessionId || "primary");
503
+ } else if (turnCount >= MAX_PERSISTENT_TURNS) {
504
+ // Thread is too long — start fresh within the same session
505
+ console.log(`[codex-shell] session ${currentSessionId} hit ${MAX_PERSISTENT_TURNS} turns — rotating thread`);
506
+ activeThread = null;
507
+ activeThreadId = null;
508
+ turnCount = 0;
509
+ }
510
+ // else: persistent && same session && under limit → reuse activeThread
511
+
512
+ for (let attempt = 0; attempt < 2; attempt += 1) {
513
+ const thread = await getThread();
514
+
515
+ // Build the user prompt with optional status context
516
+ let prompt = userMessage;
517
+ if (statusData) {
518
+ const statusSnippet = JSON.stringify(statusData, null, 2).slice(
519
+ 0,
520
+ 2000,
521
+ );
522
+ prompt = `[Orchestrator Status]\n\`\`\`json\n${statusSnippet}\n\`\`\`\n\n# YOUR TASK — EXECUTE NOW\n\n${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
523
+ } else {
524
+ prompt = `# YOUR TASK — EXECUTE NOW\n\n${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
525
+ }
526
+
527
+ // Set up timeout
528
+ const controller = abortController || new AbortController();
529
+ const timer = setTimeout(() => controller.abort("timeout"), timeoutMs);
530
+
531
+ try {
532
+ // Use runStreamed for real-time event streaming
533
+ const streamedTurn = await thread.runStreamed(prompt, {
534
+ signal: controller.signal,
535
+ });
536
+
537
+ let finalResponse = "";
538
+ const allItems = [];
539
+
540
+ // Process events from the async generator
541
+ for await (const event of streamedTurn.events) {
542
+ // Capture thread ID on first turn
543
+ if (event.type === "thread.started" && event.thread_id) {
544
+ activeThreadId = event.thread_id;
545
+ await saveState();
546
+ }
547
+
548
+ // Format and emit event
549
+ if (onEvent) {
550
+ const formatted = formatEvent(event);
551
+ if (formatted || sendRawEvents) {
552
+ try {
553
+ if (sendRawEvents) {
554
+ await onEvent(formatted, event);
555
+ } else {
556
+ await onEvent(formatted);
557
+ }
558
+ } catch {
559
+ /* best effort */
560
+ }
561
+ }
562
+ }
563
+
564
+ // Collect items
565
+ if (event.type === "item.completed") {
566
+ allItems.push(event.item);
567
+ if (event.item.type === "agent_message" && event.item.text) {
568
+ finalResponse += event.item.text + "\n";
569
+ }
570
+ }
571
+
572
+ // Track usage
573
+ if (event.type === "turn.completed") {
574
+ turnCount++;
575
+ await saveState();
576
+ if (persistent && currentSessionId) {
577
+ await saveCurrentSession();
578
+ }
579
+ }
580
+ }
581
+
582
+ clearTimeout(timer);
583
+
584
+ return {
585
+ finalResponse:
586
+ finalResponse.trim() || "(Agent completed with no text output)",
587
+ items: allItems,
588
+ usage: null,
589
+ };
590
+ } catch (err) {
591
+ clearTimeout(timer);
592
+ if (err.name === "AbortError") {
593
+ const reason = controller.signal.reason;
594
+ const msg =
595
+ reason === "user_stop"
596
+ ? "🛑 Agent stopped by user."
597
+ : `⏱️ Agent timed out after ${timeoutMs / 1000}s`;
598
+ return { finalResponse: msg, items: [], usage: null };
599
+ }
600
+ if (attempt === 0 && isRecoverableThreadError(err)) {
601
+ console.warn(
602
+ `[codex-shell] recoverable thread error: ${err.message || err} — resetting thread`,
603
+ );
604
+ await resetThread();
605
+ continue;
606
+ }
607
+ throw err;
608
+ }
609
+ }
610
+ return {
611
+ finalResponse: "❌ Agent failed after retry.",
612
+ items: [],
613
+ usage: null,
614
+ };
615
+ } finally {
616
+ activeTurn = false;
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Try to steer an in-flight agent without stopping the run.
622
+ * Best-effort: uses SDK steering APIs if available, else returns unsupported.
623
+ */
624
+ export async function steerCodexPrompt(message) {
625
+ try {
626
+ agentSdk = resolveAgentSdkConfig({ reload: true });
627
+ if (agentSdk.primary !== "codex") {
628
+ return { ok: false, reason: "agent_sdk_not_codex" };
629
+ }
630
+ if (!agentSdk.capabilities?.steering) {
631
+ return { ok: false, reason: "steering_disabled" };
632
+ }
633
+ const thread = await getThread();
634
+ const runtimeCaps = detectThreadCapabilities(thread);
635
+ const steerFn = runtimeCaps.steeringMethod
636
+ ? thread?.[runtimeCaps.steeringMethod]
637
+ : null;
638
+
639
+ if (typeof steerFn === "function") {
640
+ await steerFn.call(thread, message);
641
+ return { ok: true, mode: "steer" };
642
+ }
643
+
644
+ return {
645
+ ok: false,
646
+ reason: "sdk_no_steering_api",
647
+ detail: "Current Codex SDK Thread exposes only run()/runStreamed()",
648
+ };
649
+ } catch (err) {
650
+ return { ok: false, reason: err.message || "steer_failed" };
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Check if a turn is currently in flight.
656
+ */
657
+ export function isCodexBusy() {
658
+ return !!activeTurn;
659
+ }
660
+
661
+ /**
662
+ * Get thread info for display.
663
+ */
664
+ export function getThreadInfo() {
665
+ return {
666
+ threadId: activeThreadId,
667
+ turnCount,
668
+ isActive: !!activeThread,
669
+ isBusy: !!activeTurn,
670
+ sessionId: currentSessionId,
671
+ };
672
+ }
673
+
674
+ /**
675
+ * Reset the thread — starts a fresh conversation.
676
+ */
677
+ export async function resetThread() {
678
+ activeThread = null;
679
+ activeThreadId = null;
680
+ turnCount = 0;
681
+ activeTurn = null;
682
+ currentSessionId = null;
683
+ await saveState();
684
+ console.log("[codex-shell] thread reset");
685
+ }
686
+
687
+ // ── Session Exports ──────────────────────────────────────────────────────────
688
+
689
+ /**
690
+ * Get the currently active session ID.
691
+ */
692
+ export function getActiveSessionId() {
693
+ return currentSessionId;
694
+ }
695
+
696
+ /**
697
+ * List all saved sessions from logs/sessions/.
698
+ */
699
+ export async function listSessions() {
700
+ try {
701
+ await mkdir(SESSIONS_DIR, { recursive: true });
702
+ const files = await readdir(SESSIONS_DIR);
703
+ const sessions = [];
704
+ for (const f of files) {
705
+ if (!f.endsWith(".json")) continue;
706
+ const id = f.replace(/\.json$/, "");
707
+ const data = await loadSessionData(id);
708
+ if (data) sessions.push({ id, ...data });
709
+ }
710
+ return sessions;
711
+ } catch {
712
+ return [];
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Switch to a different session (saves current, loads target).
718
+ */
719
+ export async function switchSession(id) {
720
+ await loadSession(id);
721
+ }
722
+
723
+ /**
724
+ * Create a new named session (does not switch to it).
725
+ */
726
+ export async function createSession(id) {
727
+ const data = {
728
+ threadId: null,
729
+ turnCount: 0,
730
+ createdAt: timestamp(),
731
+ lastActiveAt: timestamp(),
732
+ };
733
+ await saveSessionData(id, data);
734
+ return data;
735
+ }
736
+
737
+ // ── Initialisation ──────────────────────────────────────────────────────────
738
+
739
+ export async function initCodexShell() {
740
+ await loadState();
741
+
742
+ // Pre-load SDK
743
+ const Cls = await loadCodexSdk();
744
+ if (Cls) {
745
+ codexInstance = new Cls({
746
+ config: {
747
+ features: {
748
+ child_agents_md: true,
749
+ collab: true,
750
+ memory_tool: true,
751
+ undo: true,
752
+ steer: true,
753
+ },
754
+ },
755
+ });
756
+ console.log("[codex-shell] initialised with Codex SDK (sub-agent features enabled)");
757
+ } else {
758
+ console.warn(
759
+ "[codex-shell] initialised WITHOUT Codex SDK — agent will not work",
760
+ );
761
+ }
762
+ }