@addai/node 0.4.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 (133) hide show
  1. package/README.md +96 -0
  2. package/dist/agent-auth.d.ts +9 -0
  3. package/dist/agent-auth.js +96 -0
  4. package/dist/attachments.d.ts +20 -0
  5. package/dist/attachments.js +164 -0
  6. package/dist/capabilities.d.ts +64 -0
  7. package/dist/capabilities.js +406 -0
  8. package/dist/claude-binary.d.ts +16 -0
  9. package/dist/claude-binary.js +145 -0
  10. package/dist/claude-config.d.ts +11 -0
  11. package/dist/claude-config.js +144 -0
  12. package/dist/claude-print.d.ts +35 -0
  13. package/dist/claude-print.js +312 -0
  14. package/dist/claude-spawn.d.ts +40 -0
  15. package/dist/claude-spawn.js +220 -0
  16. package/dist/cli.d.ts +2 -0
  17. package/dist/cli.js +137 -0
  18. package/dist/codex-binary.d.ts +4 -0
  19. package/dist/codex-binary.js +70 -0
  20. package/dist/codex-spawn.d.ts +47 -0
  21. package/dist/codex-spawn.js +320 -0
  22. package/dist/command-runner.d.ts +9 -0
  23. package/dist/command-runner.js +592 -0
  24. package/dist/config.d.ts +7 -0
  25. package/dist/config.js +38 -0
  26. package/dist/control-server.d.ts +5 -0
  27. package/dist/control-server.js +292 -0
  28. package/dist/diskguard.d.ts +96 -0
  29. package/dist/diskguard.js +435 -0
  30. package/dist/error-classify.d.ts +20 -0
  31. package/dist/error-classify.js +159 -0
  32. package/dist/events.d.ts +26 -0
  33. package/dist/events.js +97 -0
  34. package/dist/gemini-binary.d.ts +4 -0
  35. package/dist/gemini-binary.js +64 -0
  36. package/dist/gemini-spawn.d.ts +52 -0
  37. package/dist/gemini-spawn.js +369 -0
  38. package/dist/git-identity.d.ts +18 -0
  39. package/dist/git-identity.js +104 -0
  40. package/dist/grok-binary.d.ts +4 -0
  41. package/dist/grok-binary.js +70 -0
  42. package/dist/grok-spawn.d.ts +41 -0
  43. package/dist/grok-spawn.js +495 -0
  44. package/dist/guardrails.d.ts +32 -0
  45. package/dist/guardrails.js +125 -0
  46. package/dist/hang-watchdog.d.ts +17 -0
  47. package/dist/hang-watchdog.js +48 -0
  48. package/dist/harness-registry.d.ts +55 -0
  49. package/dist/harness-registry.js +180 -0
  50. package/dist/heartbeat.d.ts +13 -0
  51. package/dist/heartbeat.js +95 -0
  52. package/dist/index.d.ts +12 -0
  53. package/dist/index.js +440 -0
  54. package/dist/install.d.ts +63 -0
  55. package/dist/install.js +467 -0
  56. package/dist/jsonl-tail.d.ts +20 -0
  57. package/dist/jsonl-tail.js +192 -0
  58. package/dist/kimi-binary.d.ts +4 -0
  59. package/dist/kimi-binary.js +68 -0
  60. package/dist/kimi-spawn.d.ts +47 -0
  61. package/dist/kimi-spawn.js +319 -0
  62. package/dist/lockfile.d.ts +30 -0
  63. package/dist/lockfile.js +169 -0
  64. package/dist/mcp-config.d.ts +23 -0
  65. package/dist/mcp-config.js +133 -0
  66. package/dist/memory-capture.d.ts +20 -0
  67. package/dist/memory-capture.js +45 -0
  68. package/dist/memory-pack.d.ts +34 -0
  69. package/dist/memory-pack.js +171 -0
  70. package/dist/model-filter.d.ts +4 -0
  71. package/dist/model-filter.js +34 -0
  72. package/dist/node-health.d.ts +18 -0
  73. package/dist/node-health.js +74 -0
  74. package/dist/pairing.d.ts +20 -0
  75. package/dist/pairing.js +129 -0
  76. package/dist/paths.d.ts +9 -0
  77. package/dist/paths.js +48 -0
  78. package/dist/precompact-hook.d.ts +27 -0
  79. package/dist/precompact-hook.js +136 -0
  80. package/dist/projects.d.ts +25 -0
  81. package/dist/projects.js +255 -0
  82. package/dist/prompt-keystrokes.d.ts +3 -0
  83. package/dist/prompt-keystrokes.js +56 -0
  84. package/dist/pty-helper.d.ts +26 -0
  85. package/dist/pty-helper.js +156 -0
  86. package/dist/request-pump.d.ts +12 -0
  87. package/dist/request-pump.js +249 -0
  88. package/dist/run-retry.d.ts +28 -0
  89. package/dist/run-retry.js +81 -0
  90. package/dist/self-update.d.ts +75 -0
  91. package/dist/self-update.js +192 -0
  92. package/dist/session-runner.d.ts +130 -0
  93. package/dist/session-runner.js +2271 -0
  94. package/dist/sleep-detector.d.ts +11 -0
  95. package/dist/sleep-detector.js +32 -0
  96. package/dist/state-migrate.d.ts +8 -0
  97. package/dist/state-migrate.js +91 -0
  98. package/dist/store.d.ts +5 -0
  99. package/dist/store.js +82 -0
  100. package/dist/supabase-client.d.ts +15 -0
  101. package/dist/supabase-client.js +119 -0
  102. package/dist/time-context.d.ts +10 -0
  103. package/dist/time-context.js +71 -0
  104. package/dist/tokens.d.ts +4 -0
  105. package/dist/tokens.js +33 -0
  106. package/dist/tui/app.d.ts +35 -0
  107. package/dist/tui/app.js +82 -0
  108. package/dist/tui/console-capture.d.ts +13 -0
  109. package/dist/tui/console-capture.js +62 -0
  110. package/dist/tui/dashboard.d.ts +42 -0
  111. package/dist/tui/dashboard.js +205 -0
  112. package/dist/tui/data.d.ts +57 -0
  113. package/dist/tui/data.js +62 -0
  114. package/dist/tui/harnesses.d.ts +16 -0
  115. package/dist/tui/harnesses.js +354 -0
  116. package/dist/tui/render.d.ts +21 -0
  117. package/dist/tui/render.js +116 -0
  118. package/dist/tui/requests.d.ts +18 -0
  119. package/dist/tui/requests.js +127 -0
  120. package/dist/tui/run.d.ts +13 -0
  121. package/dist/tui/run.js +178 -0
  122. package/dist/tui/transcript.d.ts +39 -0
  123. package/dist/tui/transcript.js +234 -0
  124. package/dist/tui.d.ts +1 -0
  125. package/dist/tui.js +314 -0
  126. package/dist/types.d.ts +65 -0
  127. package/dist/types.js +3 -0
  128. package/dist/win.d.ts +67 -0
  129. package/dist/win.js +317 -0
  130. package/package.json +60 -0
  131. package/scripts/fix-pty-helper.js +28 -0
  132. package/scripts/precompact-capture.js +292 -0
  133. package/scripts/smoke-test.sh +74 -0
@@ -0,0 +1,2271 @@
1
+ "use strict";
2
+ // Runs one Claude / Codex session for a Supabase-issued request.
3
+ //
4
+ // Three agent paths, picked by req.agent + req.mode:
5
+ // 1. claude / claude-bypass + mode=tui → PTY + JSONL tail (claude-spawn.ts)
6
+ // 2. claude / claude-bypass + mode=print → claude --print stream-json (claude-print.ts)
7
+ // 3. codex + mode=* → codex exec --json (codex-spawn.ts)
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.notifyFlowsIfFlowRequest = notifyFlowsIfFlowRequest;
43
+ exports.notifyWidgetstudioIfWidgetRequest = notifyWidgetstudioIfWidgetRequest;
44
+ exports.notifySelfImproveIfEnabled = notifySelfImproveIfEnabled;
45
+ exports.resumeSessionIsVoid = resumeSessionIsVoid;
46
+ exports.runRequest = runRequest;
47
+ const fs = __importStar(require("fs"));
48
+ const os = __importStar(require("os"));
49
+ const path = __importStar(require("path"));
50
+ const supabase_client_1 = require("./supabase-client");
51
+ const git_identity_1 = require("./git-identity");
52
+ const config_1 = require("./config");
53
+ const store_1 = require("./store");
54
+ const claude_spawn_1 = require("./claude-spawn");
55
+ const claude_config_1 = require("./claude-config");
56
+ const precompact_hook_1 = require("./precompact-hook");
57
+ const jsonl_tail_1 = require("./jsonl-tail");
58
+ const events_1 = require("./events");
59
+ const paths_1 = require("./paths");
60
+ const projects_1 = require("./projects");
61
+ const claude_print_1 = require("./claude-print");
62
+ const attachments_1 = require("./attachments");
63
+ const codex_spawn_1 = require("./codex-spawn");
64
+ const diskguard_1 = require("./diskguard");
65
+ const kimi_spawn_1 = require("./kimi-spawn");
66
+ const gemini_spawn_1 = require("./gemini-spawn");
67
+ const grok_spawn_1 = require("./grok-spawn");
68
+ const model_filter_1 = require("./model-filter");
69
+ const agent_auth_1 = require("./agent-auth");
70
+ const prompt_keystrokes_1 = require("./prompt-keystrokes");
71
+ const projects_2 = require("./projects");
72
+ const run_retry_1 = require("./run-retry");
73
+ const hang_watchdog_1 = require("./hang-watchdog");
74
+ const install_1 = require("./install");
75
+ const memory_capture_1 = require("./memory-capture");
76
+ const memory_pack_1 = require("./memory-pack");
77
+ const guardrails_1 = require("./guardrails");
78
+ const time_context_1 = require("./time-context");
79
+ // Idle window after the LAST mid-turn event before we declare the turn
80
+ // complete. Reset on JSONL events AND on raw PTY stdout activity from
81
+ // claude (the "Brewing…" / "Thinking…" spinner emits bytes while claude
82
+ // is processing — bytes mean claude is NOT idle, regardless of whether
83
+ // JSONL events fire). When the spinner truly stops, no bytes for
84
+ // TURN_IDLE_MS → fire turn_complete. 60s is comfortable for normal
85
+ // inter-tool reasoning.
86
+ const TURN_IDLE_MS = 60_000;
87
+ // Prompts longer than this are NOT typed into the TUI keystroke-by-keystroke
88
+ // (a large burst overflows the PTY input buffer under MCP load and drops
89
+ // bytes — mangling the prompt). Instead the daemon writes the full prompt to
90
+ // a file in the cwd and types only a short pointer; the agent reads the file
91
+ // at full fidelity. Short prompts are still typed inline.
92
+ const TUI_INLINE_PROMPT_MAX = 2000;
93
+ // NOTE: there is intentionally NO wall-clock hard cap on a turn. An
94
+ // actively-working agent keeps emitting PTY frames (which reset the idle
95
+ // timer), so it is never killed by elapsed time — only by going idle
96
+ // (turn finished) or by the node's configured timeout (armNodeTimeout).
97
+ // Module-level registry of active claude-tui spawns keyed by their
98
+ // claude session id. Lets continuation turns (chat sessions w/
99
+ // resume_session_id) gracefully replace the prior spawn instead of
100
+ // starting a SECOND claude that races the first one on the same
101
+ // JSONL — two tails on the same file produced cross-attributed events
102
+ // and apparent "replays" in entity studio chat.
103
+ const activeClaudeTuiSpawns = new Map();
104
+ // Set of request ids whose spawn was SIGHUP'd by a continuation turn's
105
+ // pre-spawn teardown. Their `spawn.onExit` will fire with exitCode 129
106
+ // — that's OUR teardown signal, not a real failure. The handler emits
107
+ // a clean `session_end` event (or skips it) instead of one carrying
108
+ // exitCode 129, which would otherwise show an "exit_129" badge in
109
+ // entity studio chat on the prior message bubble.
110
+ const supersededByContinuation = new Set();
111
+ const TRANSCRIPT_TIMEOUT_MS = 30_000;
112
+ // TUI settle tuning. The daemon waits for at least INITIAL_OUTPUT_MIN_BYTES
113
+ // of stdout, then a TUI_SETTLE_MS quiet window, before typing the prompt.
114
+ // Shorter values mean lower first-token latency but risk typing into a
115
+ // half-drawn input box.
116
+ //
117
+ // Empirical: claude renders its initial frame in ~300-500ms on a fast box;
118
+ // the quiet window only needs to outlast inter-frame redraws (~30-60ms).
119
+ // 600ms is comfortable margin. INITIAL_OUTPUT_MIN_BYTES dropped to 60
120
+ // since claude's first frame is sometimes very compact (~80 bytes) on
121
+ // resume.
122
+ const TUI_SETTLE_MS = 600;
123
+ const INITIAL_OUTPUT_MIN_BYTES = 60;
124
+ // Even shorter settle for --resume: claude already has the transcript
125
+ // in memory and renders almost immediately.
126
+ const TUI_SETTLE_MS_RESUME = 350;
127
+ function token() {
128
+ const p = (0, store_1.readPairing)();
129
+ if (!p)
130
+ throw new Error('not_paired');
131
+ return p.daemonToken;
132
+ }
133
+ function startCancelWatcher(requestId, onCancel) {
134
+ let timer = null;
135
+ let stopped = false;
136
+ const tick = async () => {
137
+ if (stopped)
138
+ return;
139
+ try {
140
+ const status = await (0, supabase_client_1.rpc)('runtime_get_request_status', {
141
+ p_token: token(),
142
+ p_request_id: requestId,
143
+ });
144
+ if (stopped)
145
+ return;
146
+ if (status === 'canceled') {
147
+ stopped = true;
148
+ onCancel();
149
+ return;
150
+ }
151
+ // Terminal states besides canceled mean the caller already stopped
152
+ // the spawn — no point continuing to poll.
153
+ if (status === 'completed' || status === 'failed') {
154
+ stopped = true;
155
+ return;
156
+ }
157
+ }
158
+ catch (err) {
159
+ // Transient errors are fine; just keep polling.
160
+ }
161
+ if (!stopped)
162
+ timer = setTimeout(tick, 750);
163
+ };
164
+ timer = setTimeout(tick, 750);
165
+ return {
166
+ stop() {
167
+ stopped = true;
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ timer = null;
171
+ }
172
+ },
173
+ };
174
+ }
175
+ async function emit(requestId, type, payload) {
176
+ try {
177
+ await (0, supabase_client_1.rpc)('runtime_event_emit', {
178
+ p_token: token(),
179
+ p_request_id: requestId,
180
+ p_type: type,
181
+ p_payload: payload ?? null,
182
+ });
183
+ }
184
+ catch (err) {
185
+ console.error(`[session-runner] event_emit [reqId=${requestId}, type=${type}] failed: ${err.message}`);
186
+ }
187
+ }
188
+ async function setStatus(requestId, fields) {
189
+ try {
190
+ await (0, supabase_client_1.rpc)('runtime_request_update', {
191
+ p_token: token(),
192
+ p_request_id: requestId,
193
+ p_status: fields.status ?? null,
194
+ p_spawned_session_id: fields.spawnedSessionId ?? null,
195
+ p_jsonl_path: fields.jsonlPath ?? null,
196
+ p_error_code: fields.errorCode ?? null,
197
+ p_error_message: fields.errorMessage ?? null,
198
+ p_working_directory: fields.workingDirectory ?? null,
199
+ });
200
+ }
201
+ catch (err) {
202
+ console.error(`[session-runner] request_update [reqId=${requestId}, status=${fields.status ?? '-'}] failed: ${err.message}`);
203
+ }
204
+ // Async-pause completion hook — when a flow-issued request reaches a
205
+ // terminal status, ping addai-flows so it can resume the paused run.
206
+ // Fire-and-forget so a slow webhook never blocks the next request
207
+ // pickup. The function has its own retry / timeout logic.
208
+ if (fields.status === 'completed' || fields.status === 'failed' || fields.status === 'canceled') {
209
+ void notifyFlowsIfFlowRequest(requestId).catch(err => console.error(`[session-runner] notify flow [reqId=${requestId}] error: ${err.message}`));
210
+ void notifyWidgetstudioIfWidgetRequest(requestId).catch(err => console.error(`[session-runner] notify widgetstudio [reqId=${requestId}] error: ${err.message}`));
211
+ }
212
+ // Self-improve hook — ONLY on a clean `completed`. A failed/canceled run
213
+ // didn't produce a trustworthy outcome to learn from. Best-effort and
214
+ // fully non-fatal: a missing endpoint, disabled flag, or non-entity run
215
+ // all silently no-op.
216
+ if (fields.status === 'completed') {
217
+ void notifySelfImproveIfEnabled(requestId).catch(err => console.error(`[session-runner] notify self-improve [reqId=${requestId}] error: ${err.message}`));
218
+ // +Ai Memory (Task 5) — episode capture. Same gating rationale as
219
+ // self-improve above (only a clean completion is a trustworthy turn to
220
+ // remember), and the same best-effort/non-fatal contract.
221
+ void notifyCaptureIfEnabled(requestId).catch(err => console.error(`[session-runner] notify capture [reqId=${requestId}] error: ${err.message}`));
222
+ }
223
+ }
224
+ // Hard ceiling on any single run's execution time. Acts as the fallback
225
+ // "max processing time" when the dispatching node / entity does NOT specify
226
+ // req.timeout_seconds. Without an upper bound an agent that hangs (e.g. a
227
+ // wedged MCP, an infinite tool loop, or a TUI that never goes idle) would
228
+ // hold an inflight slot forever. An explicit positive req.timeout_seconds
229
+ // always wins over this default; a sentinel of <0 means "truly unlimited"
230
+ // for callers that opt out deliberately.
231
+ const DEFAULT_RUN_TIMEOUT_SECONDS = 60 * 60; // 1h
232
+ /**
233
+ * Node-configured execution timeout. When `req.timeout_seconds` is set,
234
+ * stop the agent after that many seconds via `onTimeout` (kill the spawn).
235
+ * The caller treats a timed-out run as a clean completion so whatever the
236
+ * agent produced is surfaced (complete-with-partial).
237
+ *
238
+ * Fallback semantics:
239
+ * - req.timeout_seconds > 0 → honor it exactly (per-entity cap).
240
+ * - req.timeout_seconds null/0 → fall back to DEFAULT_RUN_TIMEOUT_SECONDS
241
+ * so a run can never hang forever and starve the inflight pool.
242
+ * - req.timeout_seconds < 0 → opt out: truly unlimited, no timer armed.
243
+ *
244
+ * This replaces the daemon's old fixed caps (15-min TUI hard cap / 30-min
245
+ * pump cap) that killed healthy long-running agents while still giving the
246
+ * dispatching node / entity a real hard cap to enforce.
247
+ */
248
+ function armNodeTimeout(req, onTimeout) {
249
+ let fired = false;
250
+ const raw = req.timeout_seconds;
251
+ // Explicit negative sentinel = caller deliberately opts out of any cap.
252
+ if (typeof raw === 'number' && raw < 0) {
253
+ return { clear: () => { }, timedOut: () => false };
254
+ }
255
+ // Positive value wins; null/0 falls back to the daemon's safety ceiling.
256
+ const secs = raw && raw > 0 ? raw : DEFAULT_RUN_TIMEOUT_SECONDS;
257
+ if (!secs || secs <= 0) {
258
+ return { clear: () => { }, timedOut: () => false };
259
+ }
260
+ const t = setTimeout(() => {
261
+ fired = true;
262
+ void emit(req.id, 'timeout', { seconds: secs, reason: 'node_timeout' });
263
+ try {
264
+ onTimeout();
265
+ }
266
+ catch { /* spawn already gone */ }
267
+ }, secs * 1000);
268
+ t.unref?.();
269
+ return { clear: () => clearTimeout(t), timedOut: () => fired };
270
+ }
271
+ // Per-run hang watchdog. Reset on every agent event; if the child goes fully
272
+ // silent for HANG_WATCHDOG_IDLE_MS we emit an 'agent_hang' error (transient →
273
+ // the run is retried) and kill the child, so a wedged spawn recovers in
274
+ // minutes instead of dangling until the 1h node-timeout. Returns a no-op when
275
+ // disabled (HANG_WATCHDOG_IDLE_MS=0). Caller must .touch() on each event and
276
+ // .stop() once the run reaches a terminal point.
277
+ function armHangWatchdog(req, lastError, kill, opts) {
278
+ if (!config_1.HANG_WATCHDOG_IDLE_MS || config_1.HANG_WATCHDOG_IDLE_MS <= 0) {
279
+ return { touch: () => { }, stop: () => { } };
280
+ }
281
+ return (0, hang_watchdog_1.createHangWatchdog)({
282
+ idleMs: config_1.HANG_WATCHDOG_IDLE_MS,
283
+ idleMsFn: opts?.idleMsFn,
284
+ onHang: () => {
285
+ // Compose the reason at fire time — the window and the silent-resume
286
+ // determination both depend on run state at the moment of the kill.
287
+ const idleMs = opts?.idleMsFn ? opts.idleMsFn() : config_1.HANG_WATCHDOG_IDLE_MS;
288
+ const silentResume = Boolean(req.resume_session_id) && opts?.sawActivity != null && !opts.sawActivity();
289
+ const err = {
290
+ code: 'agent_hang',
291
+ message: silentResume
292
+ ? `agent produced no output for ${Math.round(idleMs / 60000)}m while resuming a session — killed by node hang-watchdog (silent resume hang; clearing the session and retrying fresh)`
293
+ : `agent produced no output for ${Math.round(idleMs / 60000)}m — killed by node hang-watchdog`,
294
+ };
295
+ // Record + surface the reason so the terminal write classifies it
296
+ // 'agent_hang' (transient → retried) instead of a bare exit code.
297
+ lastError.current = err;
298
+ void emit(req.id, 'error', err);
299
+ try {
300
+ kill();
301
+ }
302
+ catch { /* already gone */ }
303
+ },
304
+ });
305
+ }
306
+ // Webhook delivery: bounded per-attempt timeout + exponential backoff.
307
+ // We intentionally don't push to a dead-letter queue — the flows backend
308
+ // can also reconcile state by polling entity_runtime_requests on a cron
309
+ // if it cares about durability beyond best-effort.
310
+ const WEBHOOK_ATTEMPT_TIMEOUT_MS = 5_000;
311
+ const WEBHOOK_BACKOFF_MS = [500, 2_000, 8_000];
312
+ async function notifyFlowsIfFlowRequest(requestId) {
313
+ let row = null;
314
+ try {
315
+ row = await (0, supabase_client_1.rpc)('runtime_request_get_meta', {
316
+ p_token: token(),
317
+ p_request_id: requestId,
318
+ });
319
+ }
320
+ catch (err) {
321
+ console.error(`[session-runner] flow resume meta lookup [reqId=${requestId}] error: ${err.message}`);
322
+ return;
323
+ }
324
+ if (!row?.flow_run_id)
325
+ return;
326
+ // flow_run_id is shared between issued_via='flow' and 'widgetstudio'
327
+ // (widgetstudio stuffs the widget session id in there for the resume
328
+ // webhook). Only flows should hit the flows endpoint — without this
329
+ // check, every widgetstudio request also pings flows and gets 404.
330
+ if (row.issued_via && row.issued_via !== 'flow')
331
+ return;
332
+ const flowsUrl = process.env.ADDAI_FLOWS_URL || 'https://addai-flows-backend-29522465016.europe-west2.run.app';
333
+ const endpoint = `${flowsUrl}/api/entity-request/complete`;
334
+ // Up to 1 + 3 = 4 attempts spaced by [0, 500, 2000, 8000] ms.
335
+ for (let attempt = 0; attempt < WEBHOOK_BACKOFF_MS.length + 1; attempt++) {
336
+ if (attempt > 0)
337
+ await new Promise(r => setTimeout(r, WEBHOOK_BACKOFF_MS[attempt - 1]));
338
+ try {
339
+ const res = await fetch(endpoint, {
340
+ method: 'POST',
341
+ headers: { 'Content-Type': 'application/json' },
342
+ body: JSON.stringify({ requestId }),
343
+ signal: AbortSignal.timeout(WEBHOOK_ATTEMPT_TIMEOUT_MS),
344
+ });
345
+ if (res.ok)
346
+ return;
347
+ // 4xx (except 429) is non-retryable — the request is malformed
348
+ // or the flow run no longer exists. Stop and log.
349
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
350
+ const text = await res.text().catch(() => '');
351
+ console.error(`[session-runner] flow resume notify [reqId=${requestId}] gave up (${res.status}, non-retryable): ${text.slice(0, 200)}`);
352
+ return;
353
+ }
354
+ // 5xx / 429 / network errors fall through to retry
355
+ const text = await res.text().catch(() => '');
356
+ console.warn(`[session-runner] flow resume notify [reqId=${requestId}] attempt ${attempt + 1} failed (${res.status}): ${text.slice(0, 120)}`);
357
+ }
358
+ catch (err) {
359
+ console.warn(`[session-runner] flow resume notify [reqId=${requestId}] attempt ${attempt + 1} error: ${err.message}`);
360
+ }
361
+ }
362
+ console.error(`[session-runner] flow resume notify [reqId=${requestId}] exhausted retries`);
363
+ }
364
+ // Async-pause completion hook for Widget Studio vibecode sessions. Mirrors
365
+ // notifyFlowsIfFlowRequest but POSTs to the widget-vibecode-resume edge fn
366
+ // when the request was issued by Widget Studio. Same backoff strategy.
367
+ async function notifyWidgetstudioIfWidgetRequest(requestId) {
368
+ let row = null;
369
+ try {
370
+ row = await (0, supabase_client_1.rpc)('runtime_request_get_meta', { p_token: token(), p_request_id: requestId });
371
+ }
372
+ catch (err) {
373
+ console.error(`[session-runner] widgetstudio resume meta lookup [reqId=${requestId}] error: ${err.message}`);
374
+ return;
375
+ }
376
+ if (row?.issued_via !== 'widgetstudio')
377
+ return;
378
+ if (!row.flow_run_id)
379
+ return; // sanity — widgetstudio always populates flow_run_id=widget_id
380
+ const url = process.env.WIDGETSTUDIO_RESUME_URL
381
+ || 'https://syhzpqqvrplaqdipcymw.supabase.co/functions/v1/widget-vibecode-resume';
382
+ for (let attempt = 0; attempt < WEBHOOK_BACKOFF_MS.length + 1; attempt++) {
383
+ if (attempt > 0)
384
+ await new Promise(r => setTimeout(r, WEBHOOK_BACKOFF_MS[attempt - 1]));
385
+ try {
386
+ const res = await fetch(url, {
387
+ method: 'POST',
388
+ headers: { 'Content-Type': 'application/json' },
389
+ body: JSON.stringify({ requestId }),
390
+ signal: AbortSignal.timeout(WEBHOOK_ATTEMPT_TIMEOUT_MS),
391
+ });
392
+ if (res.ok)
393
+ return;
394
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
395
+ const text = await res.text().catch(() => '');
396
+ console.error(`[session-runner] widgetstudio resume notify [reqId=${requestId}] gave up (${res.status}, non-retryable): ${text.slice(0, 200)}`);
397
+ return;
398
+ }
399
+ const text = await res.text().catch(() => '');
400
+ console.warn(`[session-runner] widgetstudio resume notify [reqId=${requestId}] attempt ${attempt + 1} failed (${res.status}): ${text.slice(0, 120)}`);
401
+ }
402
+ catch (err) {
403
+ console.warn(`[session-runner] widgetstudio resume notify [reqId=${requestId}] attempt ${attempt + 1} error: ${err.message}`);
404
+ }
405
+ }
406
+ console.error(`[session-runner] widgetstudio resume notify [reqId=${requestId}] exhausted retries`);
407
+ }
408
+ // ── Self-improve hook ───────────────────────────────────────────────────────
409
+ //
410
+ // When an autonomous (Tommy) entity finishes a run cleanly, give the entity
411
+ // a chance to update its OWN skill based on what just happened. The daemon is
412
+ // deliberately thin here: it only fires a best-effort POST carrying the
413
+ // request + session identifiers. The `entity-self-improve` edge function
414
+ // (owned by the Studio) does the actual work — it can pull the full
415
+ // transcript (assistant_text / tool events) and run summary from
416
+ // entity_runtime_requests / the runtime event log using the requestId, then
417
+ // mutate the entity's skill content in entity_studio_* tables.
418
+ //
419
+ // Endpoint contract (request the Studio must honor):
420
+ // POST {SELFIMPROVE_URL or syhzpqqvrplaqdipcymw .../entity-self-improve}
421
+ // body: {
422
+ // requestId: string, // entity_runtime_requests.id
423
+ // entityId: string | null, // entity_studio_entities.id (the profile)
424
+ // sessionId: string | null, // spawned agent session/thread id
425
+ // status: 'completed',
426
+ // }
427
+ // The endpoint owns: loading the run's assistant_text / activity_summary,
428
+ // deciding whether/what to learn, and writing the updated skill. The daemon
429
+ // does NOT block on the response — any 2xx is success, everything else is
430
+ // logged + retried with the shared backoff, and a final failure is
431
+ // swallowed (self-improve is an enhancement, never a hard dependency of
432
+ // the run).
433
+ //
434
+ // Flag plumbing the Studio/dispatch side must populate so this fires:
435
+ // - entity_runtime_requests.self_improve = true (boolean column)
436
+ // - entity_runtime_requests.entity_id = <entity profile id> (optional
437
+ // but recommended so the endpoint doesn't have to re-derive it)
438
+ // - runtime_request_get_meta MUST also return those two columns (this
439
+ // function reads the flag via that RPC so it stays a single round-trip).
440
+ async function notifySelfImproveIfEnabled(requestId) {
441
+ let row = null;
442
+ try {
443
+ row = await (0, supabase_client_1.rpc)('runtime_request_get_meta', {
444
+ p_token: token(),
445
+ p_request_id: requestId,
446
+ });
447
+ }
448
+ catch (err) {
449
+ console.error(`[session-runner] self-improve meta lookup [reqId=${requestId}] error: ${err.message}`);
450
+ return;
451
+ }
452
+ // Disabled / not an autonomous entity run → silent no-op. Older Studio
453
+ // builds that don't populate the column land here and cost one cheap RPC.
454
+ if (!row || row.self_improve !== true)
455
+ return;
456
+ const url = process.env.ENTITY_SELFIMPROVE_URL
457
+ || 'https://syhzpqqvrplaqdipcymw.supabase.co/functions/v1/entity-self-improve';
458
+ const body = JSON.stringify({
459
+ requestId,
460
+ entityId: row.entity_id ?? null,
461
+ sessionId: row.spawned_session_id ?? null,
462
+ status: 'completed',
463
+ });
464
+ for (let attempt = 0; attempt < WEBHOOK_BACKOFF_MS.length + 1; attempt++) {
465
+ if (attempt > 0)
466
+ await new Promise(r => setTimeout(r, WEBHOOK_BACKOFF_MS[attempt - 1]));
467
+ try {
468
+ const res = await fetch(url, {
469
+ method: 'POST',
470
+ headers: {
471
+ 'Content-Type': 'application/json',
472
+ // Supabase edge functions default to requiring a JWT. Send the
473
+ // public anon key so the call works whether or not the function
474
+ // is deployed with --no-verify-jwt; per-entity authorization
475
+ // happens inside the function via the requestId/daemon context.
476
+ apikey: config_1.SUPABASE_ANON_KEY,
477
+ authorization: `Bearer ${config_1.SUPABASE_ANON_KEY}`,
478
+ },
479
+ body,
480
+ signal: AbortSignal.timeout(WEBHOOK_ATTEMPT_TIMEOUT_MS),
481
+ });
482
+ if (res.ok)
483
+ return;
484
+ // 4xx (except 429) is non-retryable — endpoint missing or request
485
+ // malformed. Give up quietly; self-improve must never wedge a run.
486
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
487
+ const text = await res.text().catch(() => '');
488
+ console.error(`[session-runner] self-improve notify [reqId=${requestId}] gave up (${res.status}, non-retryable): ${text.slice(0, 200)}`);
489
+ return;
490
+ }
491
+ const text = await res.text().catch(() => '');
492
+ console.warn(`[session-runner] self-improve notify [reqId=${requestId}] attempt ${attempt + 1} failed (${res.status}): ${text.slice(0, 120)}`);
493
+ }
494
+ catch (err) {
495
+ console.warn(`[session-runner] self-improve notify [reqId=${requestId}] attempt ${attempt + 1} error: ${err.message}`);
496
+ }
497
+ }
498
+ console.error(`[session-runner] self-improve notify [reqId=${requestId}] exhausted retries`);
499
+ }
500
+ // ── Memory capture hook ─────────────────────────────────────────────────────
501
+ //
502
+ // +Ai Memory (Task 5): on every clean `completed` run tied to a Studio
503
+ // entity, best-effort POST the turn to entity-memory-capture so it can be
504
+ // distilled into an episode. Fires from the exact same place as
505
+ // notifySelfImproveIfEnabled, for the same reason: entity_runtime_requests
506
+ // only has a fully-aggregated `assistant_text` once the DB's
507
+ // entity_studio_aggregate_assistant_text trigger (BEFORE UPDATE OF status)
508
+ // has run — i.e. by the time the runtime_request_update RPC above
509
+ // resolves. Reading turn text off any earlier client-side state (e.g. right
510
+ // after emitting `turn_complete`) would race that trigger.
511
+ //
512
+ // Unlike self-improve, capture has no client-side enable flag to check —
513
+ // entity_runtime_requests carries no memory_v2 column, and the daemon isn't
514
+ // meant to know about it. The entity-memory-capture edge function is the
515
+ // sole gate: it loads the entity's `autonomy.memory_v2` flag itself and
516
+ // replies `{ skipped: "flag" }` when disabled. This hook only filters out
517
+ // runs with no owning entity (nothing to attribute the episode to) and the
518
+ // two internal housekeeping issued_via kinds that entity_memory_recall_inject
519
+ // already excludes from memory (self_improve_distil, memory_consolidation)
520
+ // — capturing those would create a noisy feedback loop of the memory system
521
+ // remembering its own maintenance turns.
522
+ async function notifyCaptureIfEnabled(requestId) {
523
+ let row = null;
524
+ try {
525
+ row = await (0, supabase_client_1.rpc)('runtime_request_get_meta', {
526
+ p_token: token(),
527
+ p_request_id: requestId,
528
+ });
529
+ }
530
+ catch (err) {
531
+ console.error(`[session-runner] capture meta lookup [reqId=${requestId}] error: ${err.message}`);
532
+ return;
533
+ }
534
+ if (!row || !row.entity_id)
535
+ return; // non-entity run → nothing to capture against
536
+ if (row.issued_via === 'self_improve_distil' || row.issued_via === 'memory_consolidation' || row.issued_via === 'guardrail_check')
537
+ return;
538
+ const assistantText = (row.assistant_text ?? '').trim();
539
+ if (!assistantText)
540
+ return; // nothing produced this turn
541
+ const payload = (0, memory_capture_1.buildCapturePayload)({ entityId: row.entity_id, requestId, surface: row.issued_via ?? 'chat' }, row.prompt ?? '', assistantText, row.jsonl_path ? { kind: 'jsonl_ref', path: row.jsonl_path, host: os.hostname() } : null);
542
+ await (0, memory_capture_1.postCapture)(config_1.SUPABASE_URL, config_1.SUPABASE_ANON_KEY, payload);
543
+ }
544
+ // ── Guardrails enforcement ─────────────────────────────────────────────────
545
+ //
546
+ // finalizeTerminal replaces the per-runner completed/failed setStatus call.
547
+ // On a clean completion of an entity-attributed request it runs the
548
+ // guardrail gate BEFORE the terminal status write — the whole enforcement
549
+ // mechanism, since every delivery path triggers on terminal status. Failed
550
+ // runs, canceled runs, non-entity runs, and guardrails_skip rows go straight
551
+ // through exactly as before.
552
+ const GUARDRAIL_CHECK_POLL_MS = 3_000;
553
+ const GUARDRAIL_CHECK_WAIT_MS = 180_000;
554
+ const GUARDRAIL_RETRY_TIMEOUT_MS = 300_000;
555
+ // Every "this run did not finish" path funnels through here. Transient
556
+ // failures (rate limits, 5xx, network) get requeued server-side for a
557
+ // same-provider retry with backoff instead of going terminally 'failed';
558
+ // terminal ones (auth, bad input, policy) fail exactly as before. The cap
559
+ // lives in runtime_request_mark_retry (shares retry_count with the stale
560
+ // reclaim), so a daemon crash mid-decision can't extend the budget.
561
+ async function failRequest(req, errorCode, errorMessage) {
562
+ await (0, run_retry_1.failOrRetry)({
563
+ retryCount: req.retry_count ?? 0,
564
+ ladderCount: req.ladder_hops ?? 0,
565
+ markRetry: (code, message, delayMs) => (0, supabase_client_1.rpc)('runtime_request_mark_retry', {
566
+ p_token: token(),
567
+ p_request_id: req.id,
568
+ p_error_code: code ?? null,
569
+ p_error_message: message ? (0, projects_2.redactSecrets)(message).slice(0, 1000) : null,
570
+ p_delay_ms: delayMs,
571
+ }),
572
+ // Stale/invalid resume id: null the stored session (request row + parent
573
+ // chat) and requeue fresh, in one server-side RPC. Same retry_count<3
574
+ // budget as mark_retry, so it can't loop.
575
+ clearSessionAndRetry: (code, message, delayMs) => (0, supabase_client_1.rpc)('runtime_request_clear_session_and_retry', {
576
+ p_token: token(),
577
+ p_request_id: req.id,
578
+ p_error_code: code ?? null,
579
+ p_error_message: message ? (0, projects_2.redactSecrets)(message).slice(0, 1000) : null,
580
+ p_delay_ms: delayMs,
581
+ }),
582
+ ladderOrDeadLetter: (code, message) => (0, supabase_client_1.rpc)('runtime_request_failover_agent', {
583
+ p_token: token(),
584
+ p_request_id: req.id,
585
+ p_error_code: code ?? null,
586
+ p_error_message: message ? (0, projects_2.redactSecrets)(message).slice(0, 1000) : null,
587
+ }),
588
+ writeFailed: (code, message) => setStatus(req.id, { status: 'failed', errorCode: code, errorMessage: message }),
589
+ emit: (type, payload) => emit(req.id, type, payload),
590
+ }, errorCode, errorMessage);
591
+ }
592
+ async function finalizeTerminal(req, opts) {
593
+ if (!opts.clean) {
594
+ await failRequest(req, opts.errorCode, opts.errorMessage);
595
+ return;
596
+ }
597
+ const entityId = req.entity_studio_entity_id ?? req.entity_id ?? null;
598
+ const guardrailsRan = entityId != null && req.guardrails_skip !== true;
599
+ if (guardrailsRan) {
600
+ try {
601
+ if (opts.settleMs && opts.settleMs > 0)
602
+ await new Promise(r => setTimeout(r, opts.settleMs));
603
+ await (0, guardrails_1.enforceGuardrails)(makeGuardrailIo(req, opts));
604
+ }
605
+ catch (err) {
606
+ console.error(`[guardrails] [reqId=${req.id}] enforcement error (failing open): ${err.message}`);
607
+ }
608
+ // Enforcement can run for minutes (live-session checks + retry turns). The
609
+ // cancel watcher is already stopped and the runners' currentStatus!=='canceled'
610
+ // pre-checks happened BEFORE enforcement, so a cancel landing during this
611
+ // window would otherwise get clobbered to 'completed' below. Re-check now
612
+ // and bail if the user canceled mid-enforcement. This RPC only runs on the
613
+ // guardrail path (success OR fail-open) — the zero-guardrail fast path stays
614
+ // a single status write with no added round-trip.
615
+ const nowStatus = await (0, supabase_client_1.rpc)('runtime_get_request_status', { p_token: token(), p_request_id: req.id }).catch(() => null);
616
+ if (nowStatus === 'canceled')
617
+ return;
618
+ }
619
+ await setStatus(req.id, { status: 'completed' });
620
+ }
621
+ function makeGuardrailIo(req, opts) {
622
+ const checkUrl = process.env.ENTITY_GUARDRAILS_CHECK_URL
623
+ || `${config_1.SUPABASE_URL}/functions/v1/entity-guardrails-check`;
624
+ return {
625
+ async loadGuardrails() {
626
+ const res = await (0, supabase_client_1.rpc)('entity_guardrails_for_request', { p_token: token(), p_request_id: req.id });
627
+ return res?.guardrails ?? [];
628
+ },
629
+ async fetchOutput() {
630
+ return (0, supabase_client_1.rpc)('entity_guardrails_output', { p_token: token(), p_request_id: req.id });
631
+ },
632
+ async checkFast(output, defs) {
633
+ const res = await fetch(checkUrl, {
634
+ method: 'POST',
635
+ headers: {
636
+ 'Content-Type': 'application/json',
637
+ apikey: config_1.SUPABASE_ANON_KEY,
638
+ authorization: `Bearer ${config_1.SUPABASE_ANON_KEY}`,
639
+ },
640
+ body: JSON.stringify({
641
+ requestId: req.id,
642
+ entityId: req.entity_studio_entity_id ?? req.entity_id,
643
+ output,
644
+ guardrails: defs.map(d => ({ id: d.id, name: d.name, rule: d.rule })),
645
+ }),
646
+ signal: AbortSignal.timeout(45_000),
647
+ });
648
+ if (!res.ok)
649
+ throw new Error(`guardrails-check ${res.status}`);
650
+ const body = await res.json();
651
+ return body.results ?? [];
652
+ },
653
+ async checkRuntime(output, def) {
654
+ const prompt = [
655
+ `You are a guardrail checker. Verify whether the following AI entity response complies with this rule. You may use your read-only tools to verify facts or inspect files.`,
656
+ ``,
657
+ `RULE "${def.name}": ${def.rule}`,
658
+ ``,
659
+ `<response>`,
660
+ output.slice(0, 24_000),
661
+ `</response>`,
662
+ ``,
663
+ `Reply with ONLY this JSON object and nothing else:`,
664
+ `{"verdict": "pass" | "fail", "reasoning": "<max 200 chars>"}`,
665
+ ].join('\n');
666
+ const checkId = await (0, supabase_client_1.rpc)('entity_guardrail_dispatch_check', {
667
+ p_token: token(), p_request_id: req.id, p_guardrail_id: def.id, p_prompt: prompt,
668
+ });
669
+ const deadline = Date.now() + GUARDRAIL_CHECK_WAIT_MS;
670
+ for (;;) {
671
+ if (Date.now() > deadline) {
672
+ return { id: def.id, verdict: 'pass', reasoning: 'checker_error: checker timeout', checker_error: true };
673
+ }
674
+ await new Promise(r => setTimeout(r, GUARDRAIL_CHECK_POLL_MS));
675
+ const status = await (0, supabase_client_1.rpc)('runtime_get_request_status', {
676
+ p_token: token(), p_request_id: checkId,
677
+ }).catch(() => null);
678
+ if (status === 'failed' || status === 'canceled') {
679
+ return { id: def.id, verdict: 'pass', reasoning: `checker_error: checker ${status}`, checker_error: true };
680
+ }
681
+ if (status !== 'completed')
682
+ continue;
683
+ const meta = await (0, supabase_client_1.rpc)('runtime_request_get_meta', {
684
+ p_token: token(), p_request_id: checkId,
685
+ }).catch(() => null);
686
+ const text = meta?.assistant_text ?? '';
687
+ const match = text.match(/\{[\s\S]*?"verdict"[\s\S]*?\}/);
688
+ if (!match)
689
+ return { id: def.id, verdict: 'pass', reasoning: 'checker_error: no verdict JSON', checker_error: true };
690
+ try {
691
+ const v = JSON.parse(match[0]);
692
+ return { id: def.id, verdict: v.verdict === 'fail' ? 'fail' : 'pass', reasoning: (v.reasoning ?? '').slice(0, 300) };
693
+ }
694
+ catch {
695
+ return { id: def.id, verdict: 'pass', reasoning: 'checker_error: bad verdict JSON', checker_error: true };
696
+ }
697
+ }
698
+ },
699
+ async runRetry(feedback) {
700
+ if (!opts.sessionId)
701
+ return null;
702
+ return runGuardrailRetryTurn(req, opts.sessionId, opts.cwd, opts.installed, feedback);
703
+ },
704
+ async setResult(meta, events) {
705
+ await (0, supabase_client_1.rpc)('entity_guardrail_set_result', {
706
+ p_token: token(), p_request_id: req.id, p_meta: meta, p_events: events,
707
+ });
708
+ },
709
+ async emit(type, payload) { await emit(req.id, type, payload); },
710
+ };
711
+ }
712
+ /** One print-mode continuation turn on the SAME agent session, feeding the
713
+ * guardrail violation back so the entity revises its reply. Events are
714
+ * emitted onto the original request (the UI streams the revision live);
715
+ * the revised text is also accumulated locally and returned so the state
716
+ * machine can re-judge it and pin guardrail_meta.final_text to it. */
717
+ async function runGuardrailRetryTurn(req, sessionId, cwd, installed, feedback) {
718
+ let handle;
719
+ try {
720
+ if (req.agent === 'codex') {
721
+ handle = (0, codex_spawn_1.spawnCodex)({
722
+ workingDirectory: cwd, prompt: feedback, model: (0, model_filter_1.modelForAgent)('codex', req.model),
723
+ effortLevel: req.effort ?? undefined, bypassPermissions: req.permission_mode === 'bypassPermissions',
724
+ skipGitRepoCheck: true, resumeThreadId: sessionId,
725
+ mcpServers: installed.mcps.map(m => ({ slug: m.slug, command: m.command, args: m.args ?? undefined, env: m.env ?? undefined })),
726
+ });
727
+ }
728
+ else if (req.agent === 'kimi') {
729
+ handle = (0, kimi_spawn_1.spawnKimi)({
730
+ workingDirectory: cwd, prompt: feedback, model: (0, model_filter_1.modelForAgent)('kimi', req.model),
731
+ effortLevel: req.effort ?? undefined, resumeSessionId: sessionId,
732
+ bypassPermissions: req.permission_mode === 'bypassPermissions',
733
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
734
+ mcpServers: installed.mcps.map(m => ({ slug: m.slug, command: m.command, args: m.args ?? undefined, env: m.env ?? undefined })),
735
+ });
736
+ }
737
+ else if (req.agent === 'gemini') {
738
+ handle = (0, gemini_spawn_1.spawnGemini)({
739
+ workingDirectory: cwd, prompt: feedback, model: (0, model_filter_1.modelForAgent)('gemini', req.model),
740
+ effortLevel: req.effort ?? undefined, resumeSessionId: sessionId,
741
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
742
+ mcpServers: installed.mcps.map(m => ({ slug: m.slug, command: m.command, args: m.args ?? undefined, env: m.env ?? undefined })),
743
+ });
744
+ }
745
+ else if (req.agent === 'grok') {
746
+ handle = (0, grok_spawn_1.spawnGrok)({
747
+ workingDirectory: cwd, prompt: feedback, model: (0, model_filter_1.modelForAgent)('grok', req.model),
748
+ effortLevel: req.effort ?? undefined, resumeSessionId: sessionId,
749
+ bypassPermissions: req.permission_mode === 'bypassPermissions',
750
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
751
+ mcpServers: installed.mcps.map(m => ({ slug: m.slug, command: m.command, args: m.args ?? undefined, env: m.env ?? undefined })),
752
+ });
753
+ }
754
+ else {
755
+ // claude / claude-tui / claude-bypass — resume via claude --print.
756
+ handle = (0, claude_print_1.spawnClaudePrint)({
757
+ workingDirectory: cwd, prompt: feedback,
758
+ permissionMode: req.agent === 'claude-bypass' ? 'bypassPermissions' : (req.permission_mode ?? undefined),
759
+ effortLevel: req.effort ?? undefined, model: (0, model_filter_1.modelForAgent)('claude', req.model),
760
+ mcpConfigPath: installed.mcpConfigPath ?? undefined,
761
+ resumeSessionId: sessionId,
762
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
763
+ allowedTools: req.allowed_tools ?? undefined,
764
+ });
765
+ }
766
+ }
767
+ catch (err) {
768
+ console.error(`[guardrails] retry spawn failed [reqId=${req.id}]: ${err.message}`);
769
+ return null;
770
+ }
771
+ let text = '';
772
+ let emitChain = Promise.resolve();
773
+ handle.onEvent(ev => {
774
+ if (ev.type === 'assistant_text') {
775
+ const delta = ev.delta;
776
+ if (typeof delta === 'string')
777
+ text += delta;
778
+ }
779
+ emitChain = emitChain.then(() => emit(req.id, ev.type, eventPayload(ev))).catch(() => { });
780
+ });
781
+ const killer = setTimeout(() => { try {
782
+ handle.kill();
783
+ }
784
+ catch { /* gone */ } }, GUARDRAIL_RETRY_TIMEOUT_MS);
785
+ try {
786
+ const exitCode = await handle.done;
787
+ await emitChain;
788
+ return { ok: exitCode === 0 && text.trim().length > 0, text };
789
+ }
790
+ catch (err) {
791
+ console.error(`[guardrails] retry turn error [reqId=${req.id}]: ${err.message}`);
792
+ return null;
793
+ }
794
+ finally {
795
+ clearTimeout(killer);
796
+ }
797
+ }
798
+ function ensureOrphanCwd(requestId) {
799
+ const dir = path.join(paths_1.RUNTIME_HOME, 'sessions', requestId);
800
+ fs.mkdirSync(dir, { recursive: true });
801
+ return dir;
802
+ }
803
+ /** True when `dir` is one of THIS runtime's own session directories.
804
+ * Guards against re-creating a cross-machine path (e.g. a chat that moved
805
+ * runtimes still carrying /Users/lenny/... while we run on Windows). */
806
+ function isOwnSessionDir(dir) {
807
+ const root = path.resolve(paths_1.RUNTIME_HOME, 'sessions');
808
+ const resolved = path.resolve(dir);
809
+ return resolved.startsWith(root + path.sep);
810
+ }
811
+ function eventPayload(e) {
812
+ const { type: _type, ...rest } = e;
813
+ return rest;
814
+ }
815
+ /** A stored resume id is only valid TOGETHER WITH the cwd it was created in:
816
+ * claude looks its transcript up under a cwd-derived project dir, grok buckets
817
+ * its session store by cwd. So when the recorded working_directory is gone and
818
+ * we could not re-create it (it belongs to another machine, or to this machine
819
+ * before the runtime home moved — e.g. ~/.entities-runtime → ~/.ainode), the
820
+ * session id is dead: resuming it can only fail with "No conversation found
821
+ * with session ID: <id>". Start fresh instead of burning an attempt on a
822
+ * guaranteed error and making the user read a failure we already expect.
823
+ *
824
+ * Exported for tests. */
825
+ function resumeSessionIsVoid(workingDirectory, resumeSessionId, cwdSubstituted) {
826
+ return Boolean(workingDirectory) && Boolean(resumeSessionId) && cwdSubstituted;
827
+ }
828
+ // GitHub tokens minted via vault have a finite lifetime (typically 1h
829
+ // for installation tokens). Cache them for 5 min — well inside the
830
+ // shortest expiry — so a burst of concurrent ephemeral clones from
831
+ // the same credential doesn't pile up Supabase RPCs.
832
+ const GIT_TOKEN_CACHE_TTL_MS = 5 * 60 * 1000;
833
+ const gitTokenCache = new Map();
834
+ async function fetchGitTokenForRequest(requestId, credentialId) {
835
+ const cached = gitTokenCache.get(credentialId);
836
+ if (cached && cached.expiresAt > Date.now())
837
+ return cached.token;
838
+ try {
839
+ const tok = await (0, supabase_client_1.rpc)('runtime_get_git_token_for_request', {
840
+ p_token: token(),
841
+ p_request_id: requestId,
842
+ p_credential_id: credentialId,
843
+ });
844
+ if (tok)
845
+ gitTokenCache.set(credentialId, { token: tok, expiresAt: Date.now() + GIT_TOKEN_CACHE_TTL_MS });
846
+ return tok ?? null;
847
+ }
848
+ catch (err) {
849
+ console.error(`[ephemeral-clone] failed to resolve token [reqId=${requestId}, credId=${credentialId}]: ${err.message}`);
850
+ return null;
851
+ }
852
+ }
853
+ /** Derive a workspace-subdirectory name from a git URL. Used by the
854
+ * multi-repo path to stage each clone under a unique name. */
855
+ function repoNameFromUrl(url) {
856
+ let name;
857
+ try {
858
+ const path = new URL(url).pathname; // /owner/repo[.git]
859
+ const last = path.split('/').filter(Boolean).pop() ?? 'repo';
860
+ name = last.replace(/\.git$/, '').replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 64) || 'repo';
861
+ }
862
+ catch {
863
+ name = url.split('/').pop()?.replace(/\.git$/, '').replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 64) || 'repo';
864
+ }
865
+ // Windows reserved device names (con, nul, com1…) are invalid dir names;
866
+ // trailing dots are silently stripped by Win32 which desyncs the path.
867
+ // win32-gated so POSIX staging paths stay byte-identical across upgrades.
868
+ if (process.platform === 'win32') {
869
+ name = name.replace(/\.+$/, '');
870
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(name))
871
+ name = `${name}-repo`;
872
+ }
873
+ return name || 'repo';
874
+ }
875
+ /** Normalise the ephemeral_git_remotes array. Returns [] when empty or
876
+ * malformed. Each entry is shaped { remote, branch?, credential_id? }. */
877
+ function normaliseRemotes(req) {
878
+ const out = [];
879
+ if (Array.isArray(req.ephemeral_git_remotes)) {
880
+ for (const e of req.ephemeral_git_remotes) {
881
+ if (!e || typeof e !== 'object' || typeof e.remote !== 'string' || !e.remote.trim())
882
+ continue;
883
+ out.push({
884
+ remote: e.remote.trim(),
885
+ branch: (e.branch && String(e.branch).trim()) || null,
886
+ credentialId: (e.credential_id && String(e.credential_id).trim()) || null,
887
+ });
888
+ }
889
+ }
890
+ // Older Studio + chat rows still use the singular trio. Treat as a
891
+ // single-element array so the rest of the daemon doesn't care which
892
+ // contract the chat was created under.
893
+ if (out.length === 0 && req.ephemeral_git_remote) {
894
+ out.push({
895
+ remote: req.ephemeral_git_remote,
896
+ branch: req.ephemeral_git_branch,
897
+ credentialId: req.ephemeral_git_credential_id,
898
+ });
899
+ }
900
+ return out;
901
+ }
902
+ async function shallowCloneInto(remote, branch, dir, gitToken, requestId) {
903
+ if (!fs.existsSync(path.dirname(dir)))
904
+ fs.mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 });
905
+ let url = remote;
906
+ if (gitToken) {
907
+ try {
908
+ const u = new URL(remote);
909
+ if (u.hostname === 'github.com') {
910
+ u.username = 'x-access-token';
911
+ u.password = gitToken;
912
+ url = u.toString();
913
+ }
914
+ }
915
+ catch { /* keep plain */ }
916
+ }
917
+ // core.longpaths lifts Windows' 260-char MAX_PATH for deep repo trees —
918
+ // the session base dir already eats ~100 chars. Harmless no-op elsewhere.
919
+ const longPaths = process.platform === 'win32' ? ['-c', 'core.longpaths=true'] : [];
920
+ const args = [...longPaths, 'clone', '--depth', '1', '--single-branch', ...(branch ? ['--branch', branch] : []), url, dir];
921
+ await new Promise((resolve, reject) => {
922
+ const { execFile } = require('child_process');
923
+ execFile('git', args, { timeout: 10 * 60 * 1000 }, (err) => err ? reject(err) : resolve());
924
+ });
925
+ // Strip token from saved remote
926
+ if (url !== remote) {
927
+ await new Promise(resolve => {
928
+ const { execFile } = require('child_process');
929
+ execFile('git', ['-C', dir, 'remote', 'set-url', 'origin', remote], { timeout: 10_000 }, () => resolve());
930
+ });
931
+ }
932
+ // Commit as the entity, crediting whoever asked. Best-effort by design.
933
+ if (requestId)
934
+ await (0, git_identity_1.applyGitIdentity)(dir, requestId, token());
935
+ }
936
+ /** Resolve the effective cwd + project-inherited MCP/skill/package lists. */
937
+ async function resolveCwdAndProject(req) {
938
+ if (!req.project_id) {
939
+ // CRITICAL: continuation turns MUST reuse the prior turn's cwd so
940
+ // claude's --resume <id> can find the JSONL it wrote there. The
941
+ // propagate trigger copies working_directory back to the chat after
942
+ // turn 1, and chat_send forwards it onto turn 2's request — so this
943
+ // branch fires for every continuation. We check fs.existsSync to
944
+ // gracefully fall through if the dir got cleaned up.
945
+ if (req.working_directory && fs.existsSync(req.working_directory)) {
946
+ return { cwd: req.working_directory };
947
+ }
948
+ // The dir is gone but the chat still resumes a session KEYED TO THIS PATH:
949
+ // grok buckets its session store by cwd, claude by the cwd-derived project
950
+ // dir. Substituting a fresh cwd silently breaks that key — grok then finds
951
+ // no project-scoped MCP servers at all (it has no --mcp-config flag), and
952
+ // claude 404s its transcript. Everything inside a session dir is rewritten
953
+ // every run, so re-creating it empty is enough to restore the key.
954
+ if (req.working_directory && req.resume_session_id && isOwnSessionDir(req.working_directory)) {
955
+ try {
956
+ fs.mkdirSync(req.working_directory, { recursive: true });
957
+ console.log(`[cwd] re-created swept session dir for resumed session [reqId=${req.id}]: ${req.working_directory}`);
958
+ return { cwd: req.working_directory };
959
+ }
960
+ catch (err) {
961
+ console.error(`[cwd] could not re-create ${req.working_directory}: ${err.message}`);
962
+ }
963
+ }
964
+ // Everything below hands back a DIFFERENT cwd from the one this turn was
965
+ // recorded against, which voids any stored resume id.
966
+ const resumeSessionVoid = resumeSessionIsVoid(req.working_directory, req.resume_session_id, true);
967
+ const remotes = normaliseRemotes(req);
968
+ if (remotes.length === 0) {
969
+ // working_directory already failed existsSync above (or was null). Never
970
+ // return a cross-machine path like /Users/lenny/... that doesn't exist
971
+ // here — writeFileSync on .entities-runtime.mcp.json would ENOENT and
972
+ // leave the request stuck in "starting".
973
+ return { cwd: ensureOrphanCwd(req.id), resumeSessionVoid };
974
+ }
975
+ // Single-repo: clone INTO the cwd (cwd IS the repo root). This is
976
+ // the historical behaviour — preserved so existing chats with one
977
+ // repo still work the way they always did.
978
+ if (remotes.length === 1) {
979
+ const r = remotes[0];
980
+ const cwd = ensureOrphanCwd(req.id);
981
+ try {
982
+ const tok = r.credentialId ? await fetchGitTokenForRequest(req.id, r.credentialId) : null;
983
+ await shallowCloneInto(r.remote, r.branch, cwd, tok, req.id);
984
+ }
985
+ catch (err) {
986
+ return { error: { code: 'ephemeral_clone_failed', message: (0, projects_2.redactSecrets)(err.message).slice(0, 500) } };
987
+ }
988
+ return { cwd, resumeSessionVoid };
989
+ }
990
+ // Multi-repo: clone each into a subdirectory of the session cwd so
991
+ // claude sees a workspace with N repos as siblings. Names are
992
+ // derived from the URL and deduped so two remotes with the same
993
+ // basename don't collide.
994
+ const cwd = ensureOrphanCwd(req.id);
995
+ const used = new Set();
996
+ for (let i = 0; i < remotes.length; i++) {
997
+ const r = remotes[i];
998
+ let name = repoNameFromUrl(r.remote);
999
+ if (used.has(name))
1000
+ name = `${name}-${i + 1}`;
1001
+ used.add(name);
1002
+ const subDir = path.join(cwd, name);
1003
+ try {
1004
+ const tok = r.credentialId ? await fetchGitTokenForRequest(req.id, r.credentialId) : null;
1005
+ await shallowCloneInto(r.remote, r.branch, subDir, tok, req.id);
1006
+ }
1007
+ catch (err) {
1008
+ return {
1009
+ error: {
1010
+ code: 'ephemeral_clone_failed',
1011
+ message: (0, projects_2.redactSecrets)(`while cloning ${r.remote}: ${err.message}`).slice(0, 500),
1012
+ },
1013
+ };
1014
+ }
1015
+ }
1016
+ return { cwd, resumeSessionVoid };
1017
+ }
1018
+ const project = await (0, projects_1.fetchProject)(req.project_id);
1019
+ if (!project)
1020
+ return { error: { code: 'project_not_found', message: 'project_id is not visible to this runtime' } };
1021
+ if (project.status !== 'ready') {
1022
+ return { error: { code: 'project_not_ready', message: `project status is "${project.status}"` } };
1023
+ }
1024
+ return {
1025
+ cwd: project.working_directory,
1026
+ enabledMcps: project.enabled_mcps,
1027
+ enabledSkills: project.enabled_skills,
1028
+ enabledPackages: project.enabled_packages,
1029
+ };
1030
+ }
1031
+ /** inherit | none | override → install set (ids resolved against registry). */
1032
+ function resolveInstallSet(req, project) {
1033
+ if (req.mcp_mode === 'override') {
1034
+ return {
1035
+ mcpIds: req.mcps_override ?? [],
1036
+ skillIds: req.skills_override ?? [],
1037
+ packageIds: req.packages_override ?? [],
1038
+ };
1039
+ }
1040
+ if (req.mcp_mode === 'none') {
1041
+ return { mcpIds: [], skillIds: [], packageIds: [] };
1042
+ }
1043
+ // inherit
1044
+ return {
1045
+ mcpIds: project.enabledMcps ?? [],
1046
+ skillIds: project.enabledSkills ?? [],
1047
+ packageIds: project.enabledPackages ?? [],
1048
+ };
1049
+ }
1050
+ // ── claude TUI ─────────────────────────────────────────────────────────────
1051
+ async function runClaudeTui(req, cwd, installed) {
1052
+ const permissionMode = req.agent === 'claude-bypass' ? 'bypassPermissions' : (req.permission_mode ?? undefined);
1053
+ // Pre-trust the ephemeral session cwd in ~/.claude.json so the
1054
+ // "Are you sure you want to trust this directory?" dialog doesn't
1055
+ // block the daemon-spawned TUI. Idempotent + best-effort.
1056
+ (0, claude_config_1.preTrustProject)(cwd);
1057
+ // Phase 4 T7: PreCompact capture hook (Q7, claude agent only). Written
1058
+ // ONLY for memory_v2-flagged entities (req.memoryV2Flagged, set above in
1059
+ // runRequest right after the memory-pack fetch resolves) into THIS
1060
+ // session's ephemeral cwd — never the shared host-wide
1061
+ // ~/.claude/settings.json, so every non-flagged entity and every
1062
+ // non-claude agent has byte-identical spawn env: this branch simply
1063
+ // never runs for them, not merely a no-op inside it.
1064
+ const entityIdForHook = req.entity_studio_entity_id ?? req.entity_id ?? null;
1065
+ if (req.memoryV2Flagged === true && entityIdForHook) {
1066
+ (0, precompact_hook_1.writeSessionPrecompactHook)(cwd);
1067
+ }
1068
+ // Continuation turn on a still-living TUI? Tear down the prior
1069
+ // spawn FIRST so we don't end up with two claudes writing to the
1070
+ // same JSONL — that produces cross-attributed events (events from
1071
+ // turn N+1 leak into turn N's request row) AND makes
1072
+ // `claude --resume` replay the prior transcript into the new JSONL
1073
+ // as if it were fresh content. Without this you get the user
1074
+ // reporting "the second message gets the same reply as the first,
1075
+ // then both bubbles merge into a single concatenated reply".
1076
+ if (req.resume_session_id) {
1077
+ const prior = activeClaudeTuiSpawns.get(req.resume_session_id);
1078
+ if (prior) {
1079
+ console.log(`[session-runner] continuation turn — disposing prior TUI spawn [session=${req.resume_session_id}, priorReqId=${prior.requestId}]`);
1080
+ // Pre-mark the prior request as 'completed'. Without this, the
1081
+ // spawn.kill() below produces a non-zero exit which the prior
1082
+ // request's spawn.onExit would interpret as a failure and write
1083
+ // setStatus(status='failed'). Per-turn the user already saw a
1084
+ // good response (the turn finished naturally) — calling it
1085
+ // failed in the DB is misleading and pollutes the projects /
1086
+ // runtime stats. Pre-marking races the onExit handler:
1087
+ // whichever fires last wins, but our 'completed' will already
1088
+ // be committed before kill takes effect.
1089
+ void setStatus(prior.requestId, { status: 'completed' });
1090
+ // Mark the prior request as superseded so its spawn.onExit
1091
+ // handler doesn't emit a `session_end` event with exitCode 129
1092
+ // — that's the SIGHUP we're about to send, and the UI would
1093
+ // render an "exit_129" badge on the prior assistant message
1094
+ // bubble. Emit a clean `session_end` with reason='superseded'
1095
+ // + exitCode 0 instead so any UI status badge stays empty.
1096
+ supersededByContinuation.add(prior.requestId);
1097
+ void emit(prior.requestId, 'session_end', {
1098
+ reason: 'superseded_by_continuation',
1099
+ exitCode: 0,
1100
+ });
1101
+ prior.tail?.dispose();
1102
+ try {
1103
+ prior.spawn.kill();
1104
+ }
1105
+ catch { /* already dead */ }
1106
+ activeClaudeTuiSpawns.delete(req.resume_session_id);
1107
+ // Brief pause so the OS releases the JSONL file handle before
1108
+ // the new spawn opens it. Without this the new tail can attach
1109
+ // BEFORE the old spawn flushes its final bytes.
1110
+ await new Promise(r => setTimeout(r, 250));
1111
+ }
1112
+ }
1113
+ let spawn;
1114
+ try {
1115
+ spawn = (0, claude_spawn_1.spawnClaudeForRuntime)({
1116
+ workingDirectory: cwd,
1117
+ permissionMode,
1118
+ effortLevel: req.effort ?? undefined,
1119
+ initialPrompt: req.prompt ?? undefined,
1120
+ mcpConfigPathOverride: installed.mcpConfigPath ?? undefined,
1121
+ resumeSessionId: req.resume_session_id ?? undefined,
1122
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
1123
+ allowedTools: req.allowed_tools ?? undefined,
1124
+ model: (0, model_filter_1.modelForAgent)('claude', req.model),
1125
+ // Phase 4 T7: only set when the PreCompact hook was actually written
1126
+ // above — an unflagged entity's spawn gets no extraEnv at all, not
1127
+ // just an unread one.
1128
+ extraEnv: (req.memoryV2Flagged === true && entityIdForHook)
1129
+ ? { ENTITY_STUDIO_ENTITY_ID: entityIdForHook, RUNTIME_REQUEST_ID: req.id }
1130
+ : undefined,
1131
+ });
1132
+ }
1133
+ catch (err) {
1134
+ await failRequest(req, 'spawn_failed', err.message ?? 'spawn failed');
1135
+ return;
1136
+ }
1137
+ await setStatus(req.id, { status: 'running', spawnedSessionId: spawn.sessionId, jsonlPath: spawn.jsonlPath, workingDirectory: cwd });
1138
+ void emit(req.id, 'ready', { agent: req.agent, mode: 'tui' });
1139
+ // Register so the NEXT continuation turn can find + tear down this
1140
+ // spawn cleanly. Tail handle is set after startJsonlTail returns
1141
+ // below; we patch it in then.
1142
+ activeClaudeTuiSpawns.set(spawn.sessionId, { spawn, tail: undefined, requestId: req.id });
1143
+ const watcher = startCancelWatcher(req.id, () => {
1144
+ void emit(req.id, 'canceling', { reason: 'user_cancel' });
1145
+ spawn.kill();
1146
+ });
1147
+ // Silence/hang watchdog for the TUI path. Unlike print/codex/grok, the TUI
1148
+ // has no event stream that guarantees liveness — a prompt-submit that lands
1149
+ // in an unfocused input box produces NO JSONL and NO further PTY frames, so
1150
+ // nothing else would ever kill the run (and never, if timeout_seconds<0).
1151
+ // We touch() on every PTY byte and every JSONL event; we stop() once a turn
1152
+ // completes, because a chat session then idles LEGITIMATELY between user
1153
+ // messages (the spawn is held open until the next continuation turn or
1154
+ // cancel) and a fixed idle window must not kill that. A wedged submit never
1155
+ // reaches turn-complete, so it stays armed and trips → agent_hang (transient
1156
+ // → retried).
1157
+ const lastError = { current: null };
1158
+ const hang = armHangWatchdog(req, lastError, () => spawn.kill());
1159
+ let turnTimer;
1160
+ let hadAssistantSinceLastTurn = false;
1161
+ // Flow-issued requests are single-turn: the engine resumes the flow
1162
+ // run as soon as the assistant finishes. Chat-issued requests stay
1163
+ // open for follow-up turns until the user cancels or the runtime
1164
+ // owner closes the chat. The TUI binary never exits on its own, so
1165
+ // for flow mode we kill the spawn after the first idle period and
1166
+ // let spawn.onExit set status='completed' + fire the webhook.
1167
+ const isSingleTurn = req.issued_via === 'flow' || req.issued_via === 'widgetstudio';
1168
+ let autoClosed = false;
1169
+ const fireTurnComplete = () => {
1170
+ if (!hadAssistantSinceLastTurn)
1171
+ return;
1172
+ hadAssistantSinceLastTurn = false;
1173
+ if (turnTimer) {
1174
+ clearTimeout(turnTimer);
1175
+ turnTimer = undefined;
1176
+ }
1177
+ // A completed turn means we got the reply; any further silence (chat
1178
+ // sessions idle between user messages until superseded) is legitimate,
1179
+ // so disarm the hang watchdog now.
1180
+ hang.stop();
1181
+ void emit(req.id, 'turn_complete', {});
1182
+ if (isSingleTurn && !autoClosed) {
1183
+ autoClosed = true;
1184
+ // Small grace window so the JSONL tail flushes the last few
1185
+ // bytes before we kill the spawn — keeps assistant_text in the
1186
+ // request row complete.
1187
+ setTimeout(() => spawn.kill(), 200);
1188
+ }
1189
+ };
1190
+ const scheduleTurnComplete = () => {
1191
+ if (turnTimer)
1192
+ clearTimeout(turnTimer);
1193
+ turnTimer = setTimeout(fireTurnComplete, TURN_IDLE_MS);
1194
+ };
1195
+ // No wall-clock hard cap: an actively-working agent (the TUI emits PTY
1196
+ // frames while it thinks/runs tools, which reset turnTimer) is never
1197
+ // killed by elapsed time. Turn-end is detected purely by going idle
1198
+ // (TURN_IDLE_MS of no PTY activity). The only time limit is the node's
1199
+ // configured timeout, armed below.
1200
+ const nodeTimeout = armNodeTimeout(req, () => {
1201
+ // Timeout hit → complete-with-partial. autoClosed=true makes onExit
1202
+ // treat the kill as a clean completion so the assistant_text captured
1203
+ // so far is preserved.
1204
+ autoClosed = true;
1205
+ spawn.kill();
1206
+ });
1207
+ // Widget Studio live-feed relay. For issued_via='widgetstudio' requests,
1208
+ // each tool_use/tool_result pair is also POSTed to
1209
+ // widget-vibecode-broadcast so the chat UI can render a play-by-play
1210
+ // of what the agent is doing — covers ALL tools (Bash, WebFetch,
1211
+ // every MCP), not just the addai-widgetstudio MCP that broadcasts
1212
+ // itself. Without this the chat shows only "thinking…" the entire
1213
+ // run and the final text drops in at session end.
1214
+ const isWidgetstudio = req.issued_via === 'widgetstudio';
1215
+ const widgetstudioEnv = req.mcp_env_overrides?.['addai-widgetstudio'] || {};
1216
+ const widgetstudioBroadcastUrl = isWidgetstudio
1217
+ ? (widgetstudioEnv.WIDGETSTUDIO_BROADCAST_URL || null)
1218
+ : null;
1219
+ const widgetstudioSessionId = isWidgetstudio
1220
+ ? (widgetstudioEnv.WIDGETSTUDIO_SESSION_ID || null)
1221
+ : null;
1222
+ const pendingTools = new Map();
1223
+ async function relayTool(payload) {
1224
+ if (!widgetstudioBroadcastUrl || !widgetstudioSessionId)
1225
+ return;
1226
+ try {
1227
+ await fetch(widgetstudioBroadcastUrl, {
1228
+ method: 'POST',
1229
+ headers: {
1230
+ 'content-type': 'application/json',
1231
+ 'x-vibecode-token': req.id,
1232
+ },
1233
+ body: JSON.stringify({
1234
+ session_id: widgetstudioSessionId,
1235
+ tool: payload.tool,
1236
+ args: payload.args,
1237
+ result: payload.result,
1238
+ error: payload.error,
1239
+ ok: payload.ok,
1240
+ duration_ms: payload.durationMs,
1241
+ }),
1242
+ signal: AbortSignal.timeout(5_000),
1243
+ });
1244
+ }
1245
+ catch (err) {
1246
+ // best effort — the user's chat history will be missing this
1247
+ // bubble but the run itself proceeds. Log at debug so an outage
1248
+ // of the broadcast endpoint is at least traceable.
1249
+ console.debug(`[session-runner] widgetstudio relay skipped [reqId=${req.id}, tool=${payload.tool}]: ${err.message}`);
1250
+ }
1251
+ }
1252
+ // Replay-suppression for resumed sessions. claude --resume re-emits
1253
+ // the entire prior transcript into the new JSONL stream BEFORE the
1254
+ // daemon types the next user prompt — those events look identical
1255
+ // to live ones, but their content is the OLD assistant response, not
1256
+ // the new one. We drop assistant_text / tool_use / tool_result
1257
+ // events until the prompt is typed; the submit() function flips
1258
+ // `promptSubmitted` true. Fresh sessions (no resume) submit() before
1259
+ // claude has produced any output, so the flag is true from the
1260
+ // moment any meaningful event could fire.
1261
+ const isResumedSession = !!req.resume_session_id;
1262
+ let promptSubmitted = !req.prompt; // no prompt → nothing to wait for
1263
+ let tail;
1264
+ tail = (0, jsonl_tail_1.startJsonlTail)(spawn.jsonlPath, line => {
1265
+ hang.touch(); // JSONL activity = agent is alive
1266
+ for (const ev of (0, events_1.normalizeJsonlLine)(line)) {
1267
+ // Drop replayed messages claude --resume re-emits before the
1268
+ // user's new prompt is typed. They carry the prior turn's
1269
+ // content and otherwise produce ghost "Hello!" responses in
1270
+ // entity studio chat plus cross-attribute the new reply onto
1271
+ // the prior request row.
1272
+ if (isResumedSession &&
1273
+ !promptSubmitted &&
1274
+ (ev.type === 'assistant_text' || ev.type === 'tool_use' || ev.type === 'tool_result')) {
1275
+ continue;
1276
+ }
1277
+ // We used to treat `claude:last-prompt` as the definitive
1278
+ // turn-end signal — that turned out to be wrong. claude TUI
1279
+ // emits last-prompt MULTIPLE times during a single agent turn
1280
+ // (between tool-call leaves in its conversation tree), so
1281
+ // firing turn_complete on the first one SIGHUPs the spawn
1282
+ // before subsequent tools run. Rely on idle detection only.
1283
+ //
1284
+ // Reset the idle timer on any mid-turn activity. assistant_text
1285
+ // and tool_use also mark the turn as "had activity" so the
1286
+ // turn_complete handler will actually fire when triggered.
1287
+ // tool_result + claude:* mid-turn events extend the window
1288
+ // without flipping the flag (claude was just emitting status,
1289
+ // not generating a new message yet).
1290
+ if (ev.type === 'assistant_text' ||
1291
+ ev.type === 'tool_use' ||
1292
+ ev.type === 'tool_result' ||
1293
+ ev.type.startsWith('claude:')) {
1294
+ if (ev.type === 'assistant_text' || ev.type === 'tool_use') {
1295
+ hadAssistantSinceLastTurn = true;
1296
+ }
1297
+ scheduleTurnComplete();
1298
+ }
1299
+ // Widget Studio live feed: pair tool_use with its tool_result
1300
+ // and POST to the broadcast endpoint. Pairing is by tool-use id
1301
+ // (claude includes a stable id on both halves of the cycle).
1302
+ //
1303
+ // Dedup with the MCP's own broadcasts: addai-widgetstudio MCP
1304
+ // (≥ 0.4.0) already POSTs each of its tool calls to the same
1305
+ // endpoint with nicer per-tool summaries. If we ALSO relay
1306
+ // those, the chat shows two messages per call. Skip them here
1307
+ // and only relay tools the MCP doesn't broadcast itself
1308
+ // (Bash, WebFetch, ToolSearch, other MCPs, etc.).
1309
+ if (isWidgetstudio) {
1310
+ if (ev.type === 'tool_use') {
1311
+ const skipRelay = ev.name.startsWith('mcp__addai-widgetstudio__');
1312
+ if (!skipRelay) {
1313
+ pendingTools.set(ev.id, { tool: ev.name, args: ev.input, startedAt: Date.now() });
1314
+ }
1315
+ }
1316
+ else if (ev.type === 'tool_result') {
1317
+ const pending = pendingTools.get(ev.id);
1318
+ if (pending) {
1319
+ pendingTools.delete(ev.id);
1320
+ const ok = !ev.isError;
1321
+ const extractError = (c) => {
1322
+ if (typeof c === 'string')
1323
+ return c.slice(0, 500);
1324
+ if (Array.isArray(c)) {
1325
+ for (const block of c) {
1326
+ const text = block?.text;
1327
+ if (typeof text === 'string')
1328
+ return text.slice(0, 500);
1329
+ }
1330
+ }
1331
+ return null;
1332
+ };
1333
+ void relayTool({
1334
+ tool: pending.tool,
1335
+ args: pending.args,
1336
+ result: ok ? ev.content : null,
1337
+ error: ok ? null : extractError(ev.content),
1338
+ ok,
1339
+ durationMs: Date.now() - pending.startedAt,
1340
+ });
1341
+ }
1342
+ }
1343
+ }
1344
+ void emit(req.id, ev.type, eventPayload(ev));
1345
+ }
1346
+ }, {
1347
+ timeoutMs: TRANSCRIPT_TIMEOUT_MS,
1348
+ onTimeout: () => {
1349
+ void emit(req.id, 'error', {
1350
+ code: 'transcript_slow',
1351
+ message: `Claude transcript not appearing after ${Math.round(TRANSCRIPT_TIMEOUT_MS / 1000)}s.`,
1352
+ });
1353
+ },
1354
+ // When resuming, the JSONL already contains the prior transcript.
1355
+ // Seek past it so we only emit events from THIS turn — otherwise
1356
+ // the prior `claude:last-prompt` fires turn_complete the moment
1357
+ // the spawn boots and we kill the session before it can act.
1358
+ seekToEnd: !!req.resume_session_id,
1359
+ });
1360
+ // Now that the tail handle exists, patch it into the active-spawns
1361
+ // entry so the next continuation turn disposes it before tearing
1362
+ // down the spawn.
1363
+ {
1364
+ const entry = activeClaudeTuiSpawns.get(spawn.sessionId);
1365
+ if (entry)
1366
+ entry.tail = tail;
1367
+ }
1368
+ let promptFilePath = null;
1369
+ if (req.prompt) {
1370
+ // Type the prompt into the TUI as keystrokes once it's drawn.
1371
+ // We wait for the TUI to settle (enough bytes streamed + a quiet
1372
+ // window) so the input box is actually focused and ready. Newlines
1373
+ // in the prompt become Shift+Enter (\e\r) inside claude — the TUI
1374
+ // treats \n as "new line within message", while a bare \r submits.
1375
+ let bytesSeen = 0;
1376
+ let settleTimer;
1377
+ let submitted = false;
1378
+ // Large prompts: write the full text to a file and type only a short
1379
+ // pointer (the agent reads it from disk). Typing a multi-KB prompt as
1380
+ // keystrokes overflows the PTY input under MCP load and drops bytes.
1381
+ // The file lives in the cwd so the agent can read it with a relative
1382
+ // path; it's removed when the spawn exits.
1383
+ let promptText = req.prompt;
1384
+ if (req.prompt.length > TUI_INLINE_PROMPT_MAX) {
1385
+ try {
1386
+ const fname = `.entity-task-${req.id}.md`;
1387
+ promptFilePath = path.join(cwd, fname);
1388
+ fs.writeFileSync(promptFilePath, req.prompt, 'utf8');
1389
+ promptText = `Your full instructions are in the file ./${fname} in the current working directory. Read that file now and follow its entire contents exactly as your task — do everything it specifies.`;
1390
+ void emit(req.id, 'prompt_via_file', { path: fname, bytes: req.prompt.length });
1391
+ }
1392
+ catch (err) {
1393
+ promptFilePath = null;
1394
+ console.error(`[session-runner] prompt-file write failed [reqId=${req.id}]: ${err.message}; typing inline instead`);
1395
+ }
1396
+ }
1397
+ const submit = () => {
1398
+ if (submitted)
1399
+ return;
1400
+ submitted = true;
1401
+ // Strip terminal control bytes from the prompt, then turn real
1402
+ // newlines into the in-message line-break chord (ESC+CR). A literal
1403
+ // ESC in the content would otherwise be parsed as an escape sequence
1404
+ // and swallow surrounding text, truncating the prompt. See
1405
+ // prompt-keystrokes.ts.
1406
+ const withSoftBreaks = (0, prompt_keystrokes_1.encodeTuiPromptKeystrokes)(promptText);
1407
+ spawn.write(withSoftBreaks);
1408
+ // Tiny pause so the TUI has time to render the typed text, then
1409
+ // press Enter to submit.
1410
+ setTimeout(() => {
1411
+ spawn.write('\r');
1412
+ // Flip the replay gate AFTER Enter so any tail replay events
1413
+ // still in flight (claude --resume can take up to ~100ms to
1414
+ // finish emitting the prior transcript) stay suppressed.
1415
+ // Everything after this is a response to the typed prompt.
1416
+ promptSubmitted = true;
1417
+ }, 80);
1418
+ };
1419
+ // Resumed sessions render fast (transcript already in memory) so we
1420
+ // use a tighter settle window than first-spawn.
1421
+ const settleMs = req.resume_session_id ? TUI_SETTLE_MS_RESUME : TUI_SETTLE_MS;
1422
+ // Settle detection: once we've seen enough initial output (TUI has
1423
+ // started rendering), wait `settleMs` of MAX wait — but DON'T reset
1424
+ // the timer on every chunk. Claude TUI renders continuous spinner /
1425
+ // MCP-startup status frames at ~100ms cadence; reset-on-chunk meant
1426
+ // the settle window never closed during the (often multi-second)
1427
+ // MCP boot and the hard fallback fired into a still-unfocused input
1428
+ // box → keystrokes dropped → no JSONL → transcript_slow.
1429
+ spawn.onData(chunk => {
1430
+ hang.touch(); // any PTY byte (boot frames, spinner, tokens) = liveness
1431
+ if (!submitted) {
1432
+ bytesSeen += chunk.length;
1433
+ if (bytesSeen >= INITIAL_OUTPUT_MIN_BYTES && !settleTimer) {
1434
+ settleTimer = setTimeout(submit, settleMs);
1435
+ }
1436
+ return;
1437
+ }
1438
+ // Post-submit: any byte from the PTY means claude is doing
1439
+ // SOMETHING (animating a "Brewing…" spinner, redrawing the
1440
+ // status line, streaming a token). Reset the idle timer so we
1441
+ // don't SIGHUP a thinking agent. Without this, complex prompts
1442
+ // (Three.js scene generation, big refactors) that have claude
1443
+ // think >60s between tool calls hit the TURN_IDLE_MS cap and get
1444
+ // killed mid-thought. Claude TUI does NOT emit spinner frames
1445
+ // when truly idle — it shows a static prompt and the cursor
1446
+ // blink is handled terminal-side, no bytes through the PTY — so
1447
+ // this is safe.
1448
+ if (hadAssistantSinceLastTurn)
1449
+ scheduleTurnComplete();
1450
+ });
1451
+ // Hard fallback: if for some reason the byte threshold is never hit,
1452
+ // still type+submit after a generous delay so the chat doesn't hang.
1453
+ setTimeout(() => { if (!submitted)
1454
+ submit(); }, settleMs * 6);
1455
+ }
1456
+ await new Promise(resolve => {
1457
+ spawn.onExit(code => {
1458
+ if (turnTimer)
1459
+ clearTimeout(turnTimer);
1460
+ hang.stop();
1461
+ nodeTimeout.clear();
1462
+ // Remove the large-prompt task file we dropped in the cwd (if any).
1463
+ if (promptFilePath) {
1464
+ try {
1465
+ fs.unlinkSync(promptFilePath);
1466
+ }
1467
+ catch { /* gone already */ }
1468
+ promptFilePath = null;
1469
+ }
1470
+ tail?.dispose();
1471
+ // Remove from active registry — only if WE are still the owner.
1472
+ // A continuation turn may have already replaced us via the prior-
1473
+ // spawn teardown above, in which case the entry now points to a
1474
+ // newer requestId and we leave it alone.
1475
+ const reg = activeClaudeTuiSpawns.get(spawn.sessionId);
1476
+ if (reg && reg.requestId === req.id) {
1477
+ activeClaudeTuiSpawns.delete(spawn.sessionId);
1478
+ }
1479
+ watcher.stop();
1480
+ // Flush any pending widgetstudio tool_use events that never got a
1481
+ // matching tool_result (claude crashed / SIGHUP'd mid-tool / network
1482
+ // hiccup). Without this, on long-lived chat sessions the Map grows
1483
+ // unbounded across turns. Also lets the UI render the failed bubble
1484
+ // instead of a silently dangling spinner.
1485
+ if (isWidgetstudio && pendingTools.size > 0) {
1486
+ for (const [, p] of pendingTools) {
1487
+ void relayTool({
1488
+ tool: p.tool,
1489
+ args: p.args,
1490
+ error: 'agent_exited_before_tool_result',
1491
+ ok: false,
1492
+ durationMs: Date.now() - p.startedAt,
1493
+ });
1494
+ }
1495
+ pendingTools.clear();
1496
+ }
1497
+ // If a continuation turn already pre-emitted a clean session_end
1498
+ // for us, skip the noisy one carrying SIGHUP's exitCode 129.
1499
+ const wasSuperseded = supersededByContinuation.delete(req.id);
1500
+ if (!wasSuperseded) {
1501
+ void emit(req.id, 'session_end', { reason: 'claude_exit', exitCode: code });
1502
+ }
1503
+ // If the user cancelled, keep status='canceled' (set by vault). Don't
1504
+ // overwrite to failed just because the kill produced a non-zero exit.
1505
+ // Keep the outer promise (and thus the request-pump liveness heartbeat)
1506
+ // alive until finalize settles. finalizeTerminal now runs guardrail
1507
+ // enforcement — which can take minutes — before writing terminal status.
1508
+ // Resolving synchronously here would let runRequest resolve while the row
1509
+ // is still non-terminal, reopening the server-side reclaim double-spawn
1510
+ // window. Resolve only after the chain settles, like the print runners
1511
+ // which `await finalizeTerminal`.
1512
+ const done = (0, supabase_client_1.rpc)('runtime_get_request_status', { p_token: token(), p_request_id: req.id })
1513
+ .then(currentStatus => {
1514
+ if (currentStatus === 'canceled')
1515
+ return;
1516
+ // 'completed' may already be set by the continuation-turn
1517
+ // teardown above (we pre-mark the prior request as completed
1518
+ // before SIGHUP'ing it, since the user already got a good
1519
+ // reply). Don't downgrade it back to 'failed' just because
1520
+ // the spawn exited 129.
1521
+ if (currentStatus === 'completed')
1522
+ return;
1523
+ // Auto-close intentionally signals the TUI to exit after the
1524
+ // assistant turn finishes. The non-zero exit code (typically
1525
+ // 129/SIGHUP) is OUR signal, not a real failure — the
1526
+ // assistant_text aggregator has already captured the response.
1527
+ const cleanExit = code === 0 || autoClosed;
1528
+ return finalizeTerminal(req, {
1529
+ clean: cleanExit,
1530
+ // Prefer the structured reason the hang-watchdog recorded (agent_hang,
1531
+ // transient → retried) over a bare SIGHUP exit code.
1532
+ errorCode: cleanExit ? undefined : (lastError.current?.code ?? `exit_${code}`),
1533
+ errorMessage: cleanExit ? undefined : lastError.current?.message,
1534
+ sessionId: spawn.sessionId ?? null, cwd, installed,
1535
+ settleMs: 750, // TUI emits are fire-and-forget; let the tail land
1536
+ });
1537
+ })
1538
+ .catch(() => { });
1539
+ void done.finally(() => resolve());
1540
+ });
1541
+ });
1542
+ }
1543
+ // ── claude --print ─────────────────────────────────────────────────────────
1544
+ async function runClaudePrint(req, cwd, installed) {
1545
+ if (!req.prompt) {
1546
+ await setStatus(req.id, { status: 'failed', errorCode: 'no_prompt', errorMessage: 'print mode requires a prompt' });
1547
+ return;
1548
+ }
1549
+ const permissionMode = req.agent === 'claude-bypass' ? 'bypassPermissions' : (req.permission_mode ?? undefined);
1550
+ let handle;
1551
+ try {
1552
+ handle = (0, claude_print_1.spawnClaudePrint)({
1553
+ workingDirectory: cwd,
1554
+ prompt: req.prompt,
1555
+ permissionMode,
1556
+ effortLevel: req.effort ?? undefined,
1557
+ model: (0, model_filter_1.modelForAgent)('claude', req.model),
1558
+ mcpConfigPath: installed.mcpConfigPath ?? undefined,
1559
+ resumeSessionId: req.resume_session_id ?? undefined,
1560
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
1561
+ allowedTools: req.allowed_tools ?? undefined,
1562
+ });
1563
+ }
1564
+ catch (err) {
1565
+ await failRequest(req, 'spawn_failed', err.message);
1566
+ return;
1567
+ }
1568
+ void emit(req.id, 'ready', { agent: req.agent, mode: 'print' });
1569
+ await setStatus(req.id, { status: 'running', workingDirectory: cwd });
1570
+ // Serialize event inserts. emit() writes fire-and-forget, so two rapid
1571
+ // deltas can land in the DB out of order and the reply renders scrambled
1572
+ // (observed live: "LAUDE-FINAL-OKC"). Chaining preserves stream order;
1573
+ // only the DB writes queue — the child keeps streaming.
1574
+ let emitChain = Promise.resolve();
1575
+ // Remember the last structured error the agent surfaced (429s, 5xx,
1576
+ // provider stderr) so a non-zero exit can report the real reason instead
1577
+ // of a bare exit code — and so retry classification has something to read.
1578
+ const lastError = { current: null };
1579
+ // A result-level error (claude exiting 0 while reporting is_error:true or an
1580
+ // error_* subtype) must NOT finalize clean — the parser tags these with a
1581
+ // `claude_result_*` code so we can tell them apart from transient stderr.
1582
+ const resultError = { current: false };
1583
+ // Silence watchdog: reset on every event; kills a wedged child after
1584
+ // HANG_WATCHDOG_IDLE_MS so it fails 'agent_hang' (transient → retried)
1585
+ // rather than dangling until the 1h node timeout.
1586
+ const hang = armHangWatchdog(req, lastError, () => handle.kill());
1587
+ handle.onEvent(ev => {
1588
+ hang.touch();
1589
+ if (ev.type === 'error') {
1590
+ lastError.current = { code: ev.code, message: ev.message };
1591
+ if (ev.code.startsWith('claude_result'))
1592
+ resultError.current = true;
1593
+ }
1594
+ emitChain = emitChain.then(() => emit(req.id, ev.type, eventPayload(ev))).catch(() => { });
1595
+ });
1596
+ const watcher = startCancelWatcher(req.id, () => {
1597
+ void emit(req.id, 'canceling', { reason: 'user_cancel' });
1598
+ handle.kill();
1599
+ });
1600
+ // Node-configured execution timeout (null/0 = unlimited). On timeout we
1601
+ // kill the agent; the completion below treats it as complete-with-partial.
1602
+ const nodeTimeout = armNodeTimeout(req, () => handle.kill());
1603
+ let exitCode;
1604
+ try {
1605
+ exitCode = await handle.done;
1606
+ await emitChain; // drain queued event inserts before terminal status
1607
+ }
1608
+ catch (err) {
1609
+ // spawn() resolves synchronously even for a missing/unrunnable binary;
1610
+ // ENOENT etc. arrive async on the child's 'error' event and reject
1611
+ // handle.done. Catch it here so one bad spawn fails just this request
1612
+ // instead of rejecting up through the daemon and killing every session.
1613
+ nodeTimeout.clear();
1614
+ watcher.stop();
1615
+ hang.stop();
1616
+ await failRequest(req, 'spawn_failed', err.message);
1617
+ return;
1618
+ }
1619
+ nodeTimeout.clear();
1620
+ watcher.stop();
1621
+ hang.stop();
1622
+ if (handle.sessionId)
1623
+ await setStatus(req.id, { spawnedSessionId: handle.sessionId });
1624
+ void emit(req.id, 'session_end', { reason: 'claude_exit', exitCode });
1625
+ const currentStatus = await (0, supabase_client_1.rpc)('runtime_get_request_status', { p_token: token(), p_request_id: req.id }).catch(() => null);
1626
+ if (currentStatus !== 'canceled') {
1627
+ // A timed-out run completes-with-partial: the node asked for a cap, not
1628
+ // a failure, so surface whatever the agent produced. A result-error exit-0
1629
+ // is NOT clean — surface the recorded reason instead of the banner text.
1630
+ const cleanExit = (exitCode === 0 && !resultError.current) || nodeTimeout.timedOut();
1631
+ await finalizeTerminal(req, {
1632
+ clean: cleanExit,
1633
+ errorCode: cleanExit ? undefined : (lastError.current?.code ?? `exit_${exitCode}`),
1634
+ errorMessage: cleanExit ? undefined : lastError.current?.message,
1635
+ sessionId: handle.sessionId ?? null, cwd, installed,
1636
+ });
1637
+ }
1638
+ }
1639
+ // ── codex ──────────────────────────────────────────────────────────────────
1640
+ async function runCodex(req, cwd, installed) {
1641
+ if (!req.prompt) {
1642
+ await setStatus(req.id, { status: 'failed', errorCode: 'no_prompt', errorMessage: 'codex requires a prompt' });
1643
+ return;
1644
+ }
1645
+ // codex has no --append-system-prompt; we prepend it to the prompt as
1646
+ // a "system instruction" preamble so entities still influence the run.
1647
+ const codexSystem = (0, time_context_1.appendTimeContext)(req.system_prompt);
1648
+ const finalPrompt = `System instructions:\n${codexSystem.trim()}\n\nUser:\n${req.prompt}`;
1649
+ // Opportunistic reclaim right before we allocate a new scratch CODEX_HOME,
1650
+ // so a burst of runs frees space proactively rather than waiting for the
1651
+ // next periodic sweep. No-op below the high-water mark.
1652
+ await (0, diskguard_1.getDiskGuard)().sweep('pre-spawn').catch(() => { });
1653
+ let handle;
1654
+ try {
1655
+ handle = (0, codex_spawn_1.spawnCodex)({
1656
+ workingDirectory: cwd,
1657
+ prompt: finalPrompt,
1658
+ model: (0, model_filter_1.modelForAgent)('codex', req.model),
1659
+ effortLevel: req.effort ?? undefined,
1660
+ bypassPermissions: req.permission_mode === 'bypassPermissions',
1661
+ skipGitRepoCheck: true,
1662
+ resumeThreadId: req.resume_session_id ?? undefined,
1663
+ mcpServers: installed.mcps.map(m => ({
1664
+ slug: m.slug,
1665
+ command: m.command,
1666
+ args: m.args ?? undefined,
1667
+ env: m.env ?? undefined,
1668
+ })),
1669
+ });
1670
+ }
1671
+ catch (err) {
1672
+ await failRequest(req, 'spawn_failed', err.message);
1673
+ return;
1674
+ }
1675
+ void emit(req.id, 'ready', { agent: 'codex', mode: req.mode });
1676
+ await setStatus(req.id, { status: 'running', workingDirectory: cwd });
1677
+ // Serialize event inserts — fire-and-forget emits can land out of order
1678
+ // in the DB and scramble the reply (see runClaudePrint).
1679
+ let emitChain = Promise.resolve();
1680
+ // Remember the last structured error the agent surfaced (429s, 5xx,
1681
+ // provider stderr) so a non-zero exit can report the real reason instead
1682
+ // of a bare exit code — and so retry classification has something to read.
1683
+ const lastError = { current: null };
1684
+ // Silence watchdog, codex flavour (same shape as grok). codex emits NO
1685
+ // per-tool progress during a `command_execution`, so a long silent tool
1686
+ // call looks identical to a hang. Give it grok's leash: standard window
1687
+ // before any output (a wedged spawn/resume trips fast), 3x after activity
1688
+ // has been seen (silent tool work is expected, not a hang). Raw stdout/
1689
+ // stderr bytes count as liveness via onActivity.
1690
+ const sawActivity = { current: false };
1691
+ const hang = armHangWatchdog(req, lastError, () => handle.kill(), {
1692
+ sawActivity: () => sawActivity.current,
1693
+ idleMsFn: () => (sawActivity.current ? config_1.HANG_WATCHDOG_IDLE_MS * 3 : config_1.HANG_WATCHDOG_IDLE_MS),
1694
+ });
1695
+ handle.onActivity(() => { sawActivity.current = true; hang.touch(); });
1696
+ handle.onEvent(ev => {
1697
+ sawActivity.current = true;
1698
+ hang.touch();
1699
+ if (ev.type === 'error')
1700
+ lastError.current = { code: ev.code, message: ev.message };
1701
+ emitChain = emitChain.then(() => emit(req.id, ev.type, eventPayload(ev))).catch(() => { });
1702
+ });
1703
+ const watcher = startCancelWatcher(req.id, () => {
1704
+ void emit(req.id, 'canceling', { reason: 'user_cancel' });
1705
+ handle.kill();
1706
+ });
1707
+ // Node-configured execution timeout (null/0 = unlimited). On timeout we
1708
+ // kill the agent; the completion below treats it as complete-with-partial.
1709
+ const nodeTimeout = armNodeTimeout(req, () => handle.kill());
1710
+ let exitCode;
1711
+ try {
1712
+ exitCode = await handle.done;
1713
+ await emitChain; // drain queued event inserts before terminal status
1714
+ }
1715
+ catch (err) {
1716
+ // spawn() resolves synchronously even for a missing/unrunnable binary;
1717
+ // ENOENT etc. arrive async on the child's 'error' event and reject
1718
+ // handle.done. Catch it here so one bad spawn fails just this request
1719
+ // instead of rejecting up through the daemon and killing every session.
1720
+ nodeTimeout.clear();
1721
+ watcher.stop();
1722
+ hang.stop();
1723
+ await failRequest(req, 'spawn_failed', err.message);
1724
+ return;
1725
+ }
1726
+ nodeTimeout.clear();
1727
+ watcher.stop();
1728
+ hang.stop();
1729
+ if (handle.threadId)
1730
+ await setStatus(req.id, { spawnedSessionId: handle.threadId });
1731
+ void emit(req.id, 'session_end', { reason: 'codex_exit', exitCode });
1732
+ const currentStatus = await (0, supabase_client_1.rpc)('runtime_get_request_status', { p_token: token(), p_request_id: req.id }).catch(() => null);
1733
+ if (currentStatus !== 'canceled') {
1734
+ // A timed-out run completes-with-partial: the node asked for a cap, not
1735
+ // a failure, so surface whatever the agent produced.
1736
+ const cleanExit = exitCode === 0 || nodeTimeout.timedOut();
1737
+ await finalizeTerminal(req, {
1738
+ clean: cleanExit,
1739
+ errorCode: cleanExit ? undefined : (lastError.current?.code ?? `exit_${exitCode}`),
1740
+ errorMessage: cleanExit ? undefined : lastError.current?.message,
1741
+ sessionId: handle.threadId ?? null, cwd, installed,
1742
+ });
1743
+ }
1744
+ }
1745
+ async function runKimi(req, cwd, installed) {
1746
+ if (!req.prompt) {
1747
+ await setStatus(req.id, { status: 'failed', errorCode: 'no_prompt', errorMessage: 'kimi requires a prompt' });
1748
+ return;
1749
+ }
1750
+ let handle;
1751
+ try {
1752
+ handle = (0, kimi_spawn_1.spawnKimi)({
1753
+ workingDirectory: cwd,
1754
+ prompt: req.prompt,
1755
+ model: (0, model_filter_1.modelForAgent)('kimi', req.model),
1756
+ effortLevel: req.effort ?? undefined,
1757
+ resumeSessionId: req.resume_session_id ?? undefined,
1758
+ // runKimi only runs for req.agent === 'kimi', so the '|| claude-bypass'
1759
+ // that lived here was dead copy-paste — removed.
1760
+ bypassPermissions: req.permission_mode === 'bypassPermissions',
1761
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
1762
+ mcpServers: installed.mcps.map(m => ({
1763
+ slug: m.slug,
1764
+ command: m.command,
1765
+ args: m.args ?? undefined,
1766
+ env: m.env ?? undefined,
1767
+ })),
1768
+ });
1769
+ }
1770
+ catch (err) {
1771
+ await failRequest(req, 'spawn_failed', err.message);
1772
+ return;
1773
+ }
1774
+ void emit(req.id, 'ready', { agent: 'kimi', mode: 'print' });
1775
+ await setStatus(req.id, { status: 'running', workingDirectory: cwd });
1776
+ // Serialize event inserts — fire-and-forget emits can land out of order
1777
+ // in the DB and scramble the reply (see runClaudePrint).
1778
+ let emitChain = Promise.resolve();
1779
+ // Remember the last structured error the agent surfaced (429s, 5xx,
1780
+ // provider stderr) so a non-zero exit can report the real reason instead
1781
+ // of a bare exit code — and so retry classification has something to read.
1782
+ const lastError = { current: null };
1783
+ // Silence watchdog, kimi flavour (same shape as grok/codex). kimi emits no
1784
+ // per-tool progress, so a long silent tool call looks like a hang. Standard
1785
+ // window before any output; 3x once activity has been seen. Raw stdout/
1786
+ // stderr bytes count as liveness via onActivity.
1787
+ const sawActivity = { current: false };
1788
+ const hang = armHangWatchdog(req, lastError, () => handle.kill(), {
1789
+ sawActivity: () => sawActivity.current,
1790
+ idleMsFn: () => (sawActivity.current ? config_1.HANG_WATCHDOG_IDLE_MS * 3 : config_1.HANG_WATCHDOG_IDLE_MS),
1791
+ });
1792
+ handle.onActivity(() => { sawActivity.current = true; hang.touch(); });
1793
+ handle.onEvent(ev => {
1794
+ sawActivity.current = true;
1795
+ hang.touch();
1796
+ if (ev.type === 'error')
1797
+ lastError.current = { code: ev.code, message: ev.message };
1798
+ emitChain = emitChain.then(() => emit(req.id, ev.type, eventPayload(ev))).catch(() => { });
1799
+ });
1800
+ const watcher = startCancelWatcher(req.id, () => {
1801
+ void emit(req.id, 'canceling', { reason: 'user_cancel' });
1802
+ handle.kill();
1803
+ });
1804
+ // Node-configured execution timeout (null/0 = unlimited). On timeout we
1805
+ // kill the agent; the completion below treats it as complete-with-partial.
1806
+ const nodeTimeout = armNodeTimeout(req, () => handle.kill());
1807
+ let exitCode;
1808
+ try {
1809
+ exitCode = await handle.done;
1810
+ await emitChain; // drain queued event inserts before terminal status
1811
+ }
1812
+ catch (err) {
1813
+ // spawn() resolves synchronously even for a missing/unrunnable binary;
1814
+ // ENOENT etc. arrive async on the child's 'error' event and reject
1815
+ // handle.done. Catch it here so one bad spawn fails just this request
1816
+ // instead of rejecting up through the daemon and killing every session.
1817
+ nodeTimeout.clear();
1818
+ watcher.stop();
1819
+ hang.stop();
1820
+ await failRequest(req, 'spawn_failed', err.message);
1821
+ return;
1822
+ }
1823
+ nodeTimeout.clear();
1824
+ watcher.stop();
1825
+ hang.stop();
1826
+ if (handle.sessionId)
1827
+ await setStatus(req.id, { spawnedSessionId: handle.sessionId });
1828
+ void emit(req.id, 'session_end', { reason: 'kimi_exit', exitCode });
1829
+ const currentStatus = await (0, supabase_client_1.rpc)('runtime_get_request_status', { p_token: token(), p_request_id: req.id }).catch(() => null);
1830
+ if (currentStatus !== 'canceled') {
1831
+ // A timed-out run completes-with-partial: the node asked for a cap, not
1832
+ // a failure, so surface whatever the agent produced.
1833
+ const cleanExit = exitCode === 0 || nodeTimeout.timedOut();
1834
+ await finalizeTerminal(req, {
1835
+ clean: cleanExit,
1836
+ errorCode: cleanExit ? undefined : (lastError.current?.code ?? `exit_${exitCode}`),
1837
+ errorMessage: cleanExit ? undefined : lastError.current?.message,
1838
+ sessionId: handle.sessionId ?? null, cwd, installed,
1839
+ });
1840
+ }
1841
+ }
1842
+ async function runGemini(req, cwd, installed) {
1843
+ if (!req.prompt) {
1844
+ await setStatus(req.id, { status: 'failed', errorCode: 'no_prompt', errorMessage: 'gemini requires a prompt' });
1845
+ return;
1846
+ }
1847
+ let handle;
1848
+ try {
1849
+ handle = (0, gemini_spawn_1.spawnGemini)({
1850
+ workingDirectory: cwd,
1851
+ prompt: req.prompt,
1852
+ model: (0, model_filter_1.modelForAgent)('gemini', req.model),
1853
+ effortLevel: req.effort ?? undefined,
1854
+ resumeSessionId: req.resume_session_id ?? undefined,
1855
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
1856
+ mcpServers: installed.mcps.map(m => ({
1857
+ slug: m.slug, command: m.command,
1858
+ args: m.args ?? undefined, env: m.env ?? undefined,
1859
+ })),
1860
+ });
1861
+ }
1862
+ catch (err) {
1863
+ await failRequest(req, 'spawn_failed', err.message);
1864
+ return;
1865
+ }
1866
+ void emit(req.id, 'ready', { agent: 'gemini', mode: 'print' });
1867
+ await setStatus(req.id, { status: 'running', workingDirectory: cwd });
1868
+ // Serialize event inserts — fire-and-forget emits can land out of order
1869
+ // in the DB and scramble the reply (see runClaudePrint).
1870
+ let emitChain = Promise.resolve();
1871
+ // Remember the last structured error the agent surfaced (429s, 5xx,
1872
+ // provider stderr) so a non-zero exit can report the real reason instead
1873
+ // of a bare exit code — and so retry classification has something to read.
1874
+ const lastError = { current: null };
1875
+ // Silence watchdog: reset on every event; kills a wedged child after
1876
+ // HANG_WATCHDOG_IDLE_MS so it fails 'agent_hang' (transient → retried)
1877
+ // rather than dangling until the 1h node timeout.
1878
+ const hang = armHangWatchdog(req, lastError, () => handle.kill());
1879
+ handle.onEvent(ev => {
1880
+ hang.touch();
1881
+ if (ev.type === 'error')
1882
+ lastError.current = { code: ev.code, message: ev.message };
1883
+ emitChain = emitChain.then(() => emit(req.id, ev.type, eventPayload(ev))).catch(() => { });
1884
+ });
1885
+ const watcher = startCancelWatcher(req.id, () => {
1886
+ void emit(req.id, 'canceling', { reason: 'user_cancel' });
1887
+ handle.kill();
1888
+ });
1889
+ // Node-configured execution timeout (null/0 = unlimited). On timeout we
1890
+ // kill the agent; the completion below treats it as complete-with-partial.
1891
+ const nodeTimeout = armNodeTimeout(req, () => handle.kill());
1892
+ let exitCode;
1893
+ try {
1894
+ exitCode = await handle.done;
1895
+ await emitChain; // drain queued event inserts before terminal status
1896
+ }
1897
+ catch (err) {
1898
+ // spawn() resolves synchronously even for a missing/unrunnable binary;
1899
+ // ENOENT etc. arrive async on the child's 'error' event and reject
1900
+ // handle.done. Catch it here so one bad spawn fails just this request
1901
+ // instead of rejecting up through the daemon and killing every session.
1902
+ nodeTimeout.clear();
1903
+ watcher.stop();
1904
+ hang.stop();
1905
+ await failRequest(req, 'spawn_failed', err.message);
1906
+ return;
1907
+ }
1908
+ nodeTimeout.clear();
1909
+ watcher.stop();
1910
+ hang.stop();
1911
+ if (handle.sessionId)
1912
+ await setStatus(req.id, { spawnedSessionId: handle.sessionId });
1913
+ void emit(req.id, 'session_end', { reason: 'gemini_exit', exitCode });
1914
+ const currentStatus = await (0, supabase_client_1.rpc)('runtime_get_request_status', { p_token: token(), p_request_id: req.id }).catch(() => null);
1915
+ if (currentStatus !== 'canceled') {
1916
+ // A timed-out run completes-with-partial: the node asked for a cap, not
1917
+ // a failure, so surface whatever the agent produced.
1918
+ const cleanExit = exitCode === 0 || nodeTimeout.timedOut();
1919
+ await finalizeTerminal(req, {
1920
+ clean: cleanExit,
1921
+ errorCode: cleanExit ? undefined : (lastError.current?.code ?? `exit_${exitCode}`),
1922
+ errorMessage: cleanExit ? undefined : lastError.current?.message,
1923
+ sessionId: handle.sessionId ?? null, cwd, installed,
1924
+ });
1925
+ }
1926
+ }
1927
+ async function runGrok(req, cwd, installed) {
1928
+ if (!req.prompt) {
1929
+ await setStatus(req.id, { status: 'failed', errorCode: 'no_prompt', errorMessage: 'grok requires a prompt' });
1930
+ return;
1931
+ }
1932
+ let handle;
1933
+ try {
1934
+ handle = (0, grok_spawn_1.spawnGrok)({
1935
+ workingDirectory: cwd,
1936
+ prompt: req.prompt,
1937
+ model: (0, model_filter_1.modelForAgent)('grok', req.model),
1938
+ effortLevel: req.effort ?? undefined,
1939
+ resumeSessionId: req.resume_session_id ?? undefined,
1940
+ bypassPermissions: req.permission_mode === 'bypassPermissions' || req.agent === 'claude-bypass',
1941
+ appendSystemPrompt: (0, time_context_1.appendTimeContext)(req.system_prompt),
1942
+ mcpServers: installed.mcps.map(m => ({
1943
+ slug: m.slug, command: m.command,
1944
+ args: m.args ?? undefined, env: m.env ?? undefined,
1945
+ })),
1946
+ });
1947
+ }
1948
+ catch (err) {
1949
+ await failRequest(req, 'spawn_failed', err.message);
1950
+ return;
1951
+ }
1952
+ void emit(req.id, 'ready', { agent: 'grok', mode: 'print' });
1953
+ await setStatus(req.id, { status: 'running', workingDirectory: cwd });
1954
+ // Serialize event inserts. emit() writes fire-and-forget, so concurrent
1955
+ // rapid events (grok streams fast) can land out of order and scramble the
1956
+ // reply. Chaining each insert after the previous guarantees stored order
1957
+ // matches stream order. The child keeps running; only the DB writes queue.
1958
+ let emitChain = Promise.resolve();
1959
+ // Remember the last structured error the agent surfaced (429s, 5xx,
1960
+ // provider stderr) so a non-zero exit can report the real reason instead
1961
+ // of a bare exit code — and so retry classification has something to read.
1962
+ const lastError = { current: null };
1963
+ // Silence watchdog, grok flavour. Grok emits NO per-tool events, so:
1964
+ // - before ANY output: standard window — a healthy spawn/resume produces
1965
+ // thoughts within seconds, so prolonged dead silence means the resume
1966
+ // itself is wedged (kill fast, clear session, retry fresh);
1967
+ // - after output has been seen: 3x window — long silent stretches are
1968
+ // normal tool execution we simply can't observe, not a hang.
1969
+ // Raw stdout/stderr bytes also count as liveness via onActivity below.
1970
+ const sawActivity = { current: false };
1971
+ const hang = armHangWatchdog(req, lastError, () => handle.kill(), {
1972
+ sawActivity: () => sawActivity.current,
1973
+ idleMsFn: () => (sawActivity.current ? config_1.HANG_WATCHDOG_IDLE_MS * 3 : config_1.HANG_WATCHDOG_IDLE_MS),
1974
+ });
1975
+ handle.onActivity(() => { sawActivity.current = true; hang.touch(); });
1976
+ handle.onEvent(ev => {
1977
+ sawActivity.current = true;
1978
+ hang.touch();
1979
+ if (ev.type === 'error')
1980
+ lastError.current = { code: ev.code, message: ev.message };
1981
+ emitChain = emitChain.then(() => emit(req.id, ev.type, eventPayload(ev))).catch(() => { });
1982
+ });
1983
+ const watcher = startCancelWatcher(req.id, () => {
1984
+ void emit(req.id, 'canceling', { reason: 'user_cancel' });
1985
+ handle.kill();
1986
+ });
1987
+ // Node-configured execution timeout (null/0 = unlimited). On timeout we
1988
+ // kill the agent; the completion below treats it as complete-with-partial.
1989
+ const nodeTimeout = armNodeTimeout(req, () => handle.kill());
1990
+ let exitCode;
1991
+ try {
1992
+ exitCode = await handle.done;
1993
+ await emitChain; // drain queued event inserts before we mark terminal
1994
+ }
1995
+ catch (err) {
1996
+ nodeTimeout.clear();
1997
+ watcher.stop();
1998
+ hang.stop();
1999
+ await failRequest(req, 'spawn_failed', err.message);
2000
+ return;
2001
+ }
2002
+ nodeTimeout.clear();
2003
+ watcher.stop();
2004
+ hang.stop();
2005
+ if (handle.sessionId)
2006
+ await setStatus(req.id, { spawnedSessionId: handle.sessionId });
2007
+ void emit(req.id, 'session_end', { reason: 'grok_exit', exitCode });
2008
+ const currentStatus = await (0, supabase_client_1.rpc)('runtime_get_request_status', { p_token: token(), p_request_id: req.id }).catch(() => null);
2009
+ if (currentStatus !== 'canceled') {
2010
+ const cleanExit = exitCode === 0 || nodeTimeout.timedOut();
2011
+ await finalizeTerminal(req, {
2012
+ clean: cleanExit,
2013
+ errorCode: cleanExit ? undefined : (lastError.current?.code ?? `exit_${exitCode}`),
2014
+ errorMessage: cleanExit ? undefined : lastError.current?.message,
2015
+ sessionId: handle.sessionId ?? null, cwd, installed,
2016
+ });
2017
+ }
2018
+ }
2019
+ async function fetchDriveList(apiKey, workspaceId, folderId, signal) {
2020
+ const url = `${config_1.SUPABASE_URL}/functions/v1/storage-list?workspaceId=${encodeURIComponent(workspaceId)}&folderId=${encodeURIComponent(folderId)}&pageSize=100`;
2021
+ const res = await fetch(url, { headers: { 'x-api-key': apiKey }, signal });
2022
+ if (!res.ok)
2023
+ return null;
2024
+ return (await res.json());
2025
+ }
2026
+ async function buildDriveManifest(installed) {
2027
+ const drive = installed.mcps.find((m) => m.slug === 'addai-drive');
2028
+ const env = drive?.env ?? null;
2029
+ const apiKey = env?.ADDAI_DRIVE_API_KEY;
2030
+ const workspaceId = env?.ADDAI_DRIVE_WORKSPACE_ID;
2031
+ const rootFolderId = env?.ADDAI_DRIVE_FOLDER_ID;
2032
+ if (!apiKey || !workspaceId || !rootFolderId)
2033
+ return null;
2034
+ const controller = new AbortController();
2035
+ const timer = setTimeout(() => controller.abort(), 4_000);
2036
+ try {
2037
+ const root = await fetchDriveList(apiKey, workspaceId, rootFolderId, controller.signal);
2038
+ if (!root)
2039
+ return null;
2040
+ const lines = [];
2041
+ const fmt = (f) => ` - ${f.name}${typeof f.size === 'number' ? ` (${Math.max(1, Math.round(f.size / 1024))} KB)` : ''}`;
2042
+ for (const f of (root.files ?? []).slice(0, 50))
2043
+ lines.push(fmt(f));
2044
+ // One level of subfolders so the entity sees its structure, capped to keep
2045
+ // the injection small and the listing fast.
2046
+ for (const sub of (root.folders ?? []).slice(0, 12)) {
2047
+ lines.push(` - ${sub.name}/`);
2048
+ const child = await fetchDriveList(apiKey, workspaceId, sub.id, controller.signal).catch(() => null);
2049
+ for (const f of (child?.files ?? []).slice(0, 30))
2050
+ lines.push(` - ${sub.name}/${f.name}`);
2051
+ }
2052
+ const body = lines.length ? lines.join('\n') : ' (empty — nothing saved yet)';
2053
+ return [
2054
+ '## Your +Ai Drive',
2055
+ "You have a private +Ai Drive — file storage for working files and artifacts (documents, exports, data you produce) that survives across runs. This is for FILES, not memory: to remember facts, preferences, decisions, or what you learned, use your DocFlows memory (see below), never the Drive and never a local file. Files currently in your Drive:",
2056
+ body,
2057
+ "Use your +Ai Drive tools (list_files, read_file, write_file, make_folder, …) to read, add, and organise files.",
2058
+ ].join('\n');
2059
+ }
2060
+ catch {
2061
+ return null;
2062
+ }
2063
+ finally {
2064
+ clearTimeout(timer);
2065
+ }
2066
+ }
2067
+ // ── Memory awareness (DocFlows) ──────────────────────────────────────────────
2068
+ // When the entity has the DocFlows MCP installed (it always does — that's its
2069
+ // long-term memory), list the titles it has saved so it starts each run already
2070
+ // knowing what it remembers and can load the relevant note. Best-effort, 4s cap.
2071
+ async function buildMemoryManifest(installed) {
2072
+ const mem = installed.mcps.find((m) => m.slug === 'docflows');
2073
+ const env = mem?.env ?? null;
2074
+ const apiKey = env?.DOCFLOWS_API_KEY;
2075
+ const workspaceId = env?.DOCFLOWS_WORKSPACE_ID;
2076
+ if (!apiKey || !workspaceId)
2077
+ return null;
2078
+ const controller = new AbortController();
2079
+ const timer = setTimeout(() => controller.abort(), 4_000);
2080
+ try {
2081
+ const url = `${config_1.SUPABASE_URL}/functions/v1/docflows-api/v1/documents?workspace_id=${encodeURIComponent(workspaceId)}`;
2082
+ const res = await fetch(url, { headers: { 'x-api-key': apiKey }, signal: controller.signal });
2083
+ if (!res.ok)
2084
+ return null;
2085
+ const body = (await res.json());
2086
+ const docs = body.data ?? [];
2087
+ const lines = docs.slice(0, 60).map((d) => ` - ${d.title}`);
2088
+ const list = lines.length ? lines.join('\n') : ' (empty — nothing saved yet)';
2089
+ // Hidden system directive: every entity has a long-term DocFlows memory and
2090
+ // MUST actively maintain it. Without an explicit instruction the model treats
2091
+ // the memory tools as optional and rarely writes — so spell out exactly when
2092
+ // to save, in imperative terms.
2093
+ return [
2094
+ '## Your memory — it lives in DocFlows, nowhere else',
2095
+ 'You have exactly ONE long-term memory: your DocFlows memory. It is the ONLY place anything you remember survives between runs. Your working directory, any local file, a MEMORY.md, scratch notes on disk — all of that is DELETED the moment this run ends and is NOT your memory. The ONLY way to remember something is to call your DocFlows memory tools. Keeping this memory up to date is your responsibility — it is not optional.',
2096
+ '',
2097
+ 'Notes you have saved so far:',
2098
+ list,
2099
+ '',
2100
+ 'RECALL first: before you act, load the relevant note(s) with your DocFlows tools (search_documents / get_document) so you use what you already know.',
2101
+ '',
2102
+ 'SAVE as you go — call your DocFlows tools WITHOUT being asked, the moment any of these happen:',
2103
+ '- You learn a durable fact about the user or their world (a preference, a name, how their data/process is set up).',
2104
+ '- You work out a method/approach that worked — or one that failed (record that too).',
2105
+ '- You make or are given a decision, rule, or instruction to follow going forward.',
2106
+ '- The user says "remember", "note", "save this", "keep in mind", or asks you to memorise something — call create_document or update_page IMMEDIATELY, in this same turn.',
2107
+ '- You finish a task — append what you did and what you learned.',
2108
+ '',
2109
+ 'How: create_document for a new memory (clear, descriptive title) or get_document + update_page to extend an existing one. These DocFlows tool calls ARE the act of remembering — writing to a file on disk is not, and does not count.',
2110
+ 'NEVER tell the user you saved, remembered, or noted something unless you actually called a DocFlows tool to do it in this same turn. Saying "saved ✅" without a DocFlows tool call loses the memory and is a failure.',
2111
+ ].join('\n');
2112
+ }
2113
+ catch {
2114
+ return null;
2115
+ }
2116
+ finally {
2117
+ clearTimeout(timer);
2118
+ }
2119
+ }
2120
+ // ── Dispatcher ─────────────────────────────────────────────────────────────
2121
+ async function runRequest(req) {
2122
+ // Auth pre-flight: cheap file/env check before we spawn so the user
2123
+ // gets a clean `auth_required` event instead of cryptic stderr from
2124
+ // the child (e.g. "Invalid API key", "401 unauthorized"). This also
2125
+ // saves us from leaking inflight slots on auth-failed spawns.
2126
+ const authCheck = (0, agent_auth_1.verifyAgentAuth)(req.agent);
2127
+ if (!authCheck.ok) {
2128
+ void emit(req.id, 'error', { code: authCheck.code, message: authCheck.message });
2129
+ await setStatus(req.id, {
2130
+ status: 'failed',
2131
+ errorCode: authCheck.code,
2132
+ errorMessage: authCheck.message,
2133
+ });
2134
+ return;
2135
+ }
2136
+ const resolved = await resolveCwdAndProject(req);
2137
+ if ('error' in resolved) {
2138
+ // ephemeral_clone_failed is often a network blip — classify + maybe retry.
2139
+ await failRequest(req, resolved.error.code, resolved.error.message);
2140
+ return;
2141
+ }
2142
+ const { cwd } = resolved;
2143
+ // The cwd this turn's session id was created in is gone and could not be
2144
+ // re-created, so the id cannot resolve — drop it here, once, rather than let
2145
+ // every agent's spawn path pass a --resume that is certain to fail. The
2146
+ // server-side self-heal still covers ids that die for other reasons.
2147
+ if (resolved.resumeSessionVoid && req.resume_session_id) {
2148
+ console.log(`[cwd] dropping resume session ${req.resume_session_id} — its cwd is gone (${req.working_directory}); starting fresh in ${cwd} [reqId=${req.id}]`);
2149
+ void emit(req.id, 'session_dropped_dead_cwd', {
2150
+ sessionId: req.resume_session_id,
2151
+ priorCwd: req.working_directory,
2152
+ cwd,
2153
+ });
2154
+ req.resume_session_id = null;
2155
+ }
2156
+ // Download chat attachments referenced in the prompt ([Image: url],
2157
+ // [File: name — url], bare image URLs) into the session cwd so any
2158
+ // agent/mode can Read them — replaces 0.2.36's unsupported --image flags.
2159
+ // Best-effort: on any failure the prompt passes through unchanged.
2160
+ if (req.prompt) {
2161
+ try {
2162
+ req.prompt = await (0, attachments_1.prepareAttachments)(req.prompt, cwd);
2163
+ }
2164
+ catch { /* keep original prompt */ }
2165
+ }
2166
+ // Resolve and install MCPs / skills / packages before spawn.
2167
+ const installSet = resolveInstallSet(req, resolved);
2168
+ const installed = await (0, install_1.install)(cwd, {
2169
+ mcpIds: installSet.mcpIds,
2170
+ skillIds: installSet.skillIds,
2171
+ packageIds: installSet.packageIds,
2172
+ }, undefined,
2173
+ // Per-session env overrides keyed by MCP slug — merged into each MCP's
2174
+ // env after credential resolution. Used by widgetstudio to inject the
2175
+ // dynamic streaming auth token (= the request row's id) into the MCP.
2176
+ req.mcp_env_overrides ?? undefined,
2177
+ // Per-skill variable values keyed by skill id — substituted into each
2178
+ // skill's {{var.<name>}} tokens before SKILL.md is written.
2179
+ req.skill_var_values ?? {}, req.id);
2180
+ if (installed.mcpConfigPath || installed.skillSlugs.length > 0) {
2181
+ void emit(req.id, 'claude:install_complete', {
2182
+ mcps_installed: installed.mcpConfigPath ? true : false,
2183
+ skills_installed: installed.skillSlugs,
2184
+ });
2185
+ }
2186
+ // Fold the entity's +Ai Drive contents into its system prompt (best-effort) so
2187
+ // it begins the run already aware of the files it has. All spawn paths read
2188
+ // req.system_prompt, so mutating it here reaches every agent variant.
2189
+ try {
2190
+ const driveManifest = await buildDriveManifest(installed);
2191
+ if (driveManifest) {
2192
+ req.system_prompt =
2193
+ req.system_prompt && req.system_prompt.trim().length > 0
2194
+ ? `${req.system_prompt}\n\n${driveManifest}`
2195
+ : driveManifest;
2196
+ }
2197
+ }
2198
+ catch {
2199
+ /* drive manifest is best-effort — never block a run */
2200
+ }
2201
+ // Same for the entity's long-term memory: try the +Ai Memory semantic pack
2202
+ // (T6's entity-memory-pack edge function) first — it replaces the flat
2203
+ // DocFlows title list with a curated recall block for memory_v2-flagged
2204
+ // entities. `block === ''` means the entity isn't on the pack (unflagged,
2205
+ // or genuinely nothing to recall) → fall back to the legacy DocFlows
2206
+ // manifest below, unchanged. A pack fetch failure is NOT swallowed like
2207
+ // the legacy manifest's best-effort try/catch: it fails LOUD — the
2208
+ // fallback marker is injected into the system prompt AND a
2209
+ // `memory_fallback` runtime event is emitted so the degradation is
2210
+ // observable, never silent.
2211
+ //
2212
+ // The gate/fetch/replace decision itself lives in memory-pack.ts's
2213
+ // applyMemoryPack (extracted so it's unit-testable independent of the
2214
+ // rest of this function's spawn/install machinery). It reads
2215
+ // req.entity_studio_entity_id first — the runtime "entity_id alias" fix:
2216
+ // runtime_pick_next_request returns the bare entity_runtime_requests
2217
+ // rowtype (no jsonb aliasing), so that is the real field name; req.entity_id
2218
+ // is kept only as a fallback (see resolvePackEntityId in memory-pack.ts).
2219
+ try {
2220
+ req.system_prompt = await (0, memory_pack_1.applyMemoryPack)(req, req.system_prompt ?? null, config_1.SUPABASE_URL, config_1.SUPABASE_ANON_KEY, {
2221
+ buildLegacyManifest: () => buildMemoryManifest(installed),
2222
+ onFallback: () => void emit(req.id, 'memory_fallback', { at: 'pack' }),
2223
+ // Phase 4 T7: PreCompact hook gate (see precompact-hook.ts). Reuses
2224
+ // this same pack fetch's `skipped:"flag"` signal instead of asking
2225
+ // the flag question a second time.
2226
+ onFlagged: (flagged) => { req.memoryV2Flagged = flagged; },
2227
+ });
2228
+ }
2229
+ catch (err) {
2230
+ /* memory manifest is best-effort — never block a run */
2231
+ console.error('[memory-pack] wrapper failed:', err?.message);
2232
+ }
2233
+ try {
2234
+ // Agent value now encodes the mode for claude variants:
2235
+ // 'claude' → claude --print (one-shot stream)
2236
+ // 'claude-tui' → claude TUI (long-lived spawn)
2237
+ // 'claude-bypass' (legacy) → claude TUI with bypass perms
2238
+ // 'codex' / 'kimi' / 'gemini' / 'grok' → those CLIs
2239
+ if (req.agent === 'codex') {
2240
+ await runCodex(req, cwd, installed);
2241
+ }
2242
+ else if (req.agent === 'kimi') {
2243
+ await runKimi(req, cwd, installed);
2244
+ }
2245
+ else if (req.agent === 'gemini') {
2246
+ await runGemini(req, cwd, installed);
2247
+ }
2248
+ else if (req.agent === 'grok') {
2249
+ await runGrok(req, cwd, installed);
2250
+ }
2251
+ else if (req.agent === 'claude-tui' || req.agent === 'claude-bypass') {
2252
+ await runClaudeTui(req, cwd, installed);
2253
+ }
2254
+ else if (req.mode === 'print') {
2255
+ await runClaudePrint(req, cwd, installed);
2256
+ }
2257
+ else {
2258
+ // Backwards compat: bare 'claude' with mode='tui' still works
2259
+ await runClaudeTui(req, cwd, installed);
2260
+ }
2261
+ }
2262
+ finally {
2263
+ // Best-effort cleanup of installed files. If the request crashed
2264
+ // mid-flight we may leak files; next install run for the same cwd
2265
+ // will overwrite them.
2266
+ try {
2267
+ await installed.dispose();
2268
+ }
2269
+ catch { }
2270
+ }
2271
+ }