@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.
- package/README.md +96 -0
- package/dist/agent-auth.d.ts +9 -0
- package/dist/agent-auth.js +96 -0
- package/dist/attachments.d.ts +20 -0
- package/dist/attachments.js +164 -0
- package/dist/capabilities.d.ts +64 -0
- package/dist/capabilities.js +406 -0
- package/dist/claude-binary.d.ts +16 -0
- package/dist/claude-binary.js +145 -0
- package/dist/claude-config.d.ts +11 -0
- package/dist/claude-config.js +144 -0
- package/dist/claude-print.d.ts +35 -0
- package/dist/claude-print.js +312 -0
- package/dist/claude-spawn.d.ts +40 -0
- package/dist/claude-spawn.js +220 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/codex-binary.d.ts +4 -0
- package/dist/codex-binary.js +70 -0
- package/dist/codex-spawn.d.ts +47 -0
- package/dist/codex-spawn.js +320 -0
- package/dist/command-runner.d.ts +9 -0
- package/dist/command-runner.js +592 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +38 -0
- package/dist/control-server.d.ts +5 -0
- package/dist/control-server.js +292 -0
- package/dist/diskguard.d.ts +96 -0
- package/dist/diskguard.js +435 -0
- package/dist/error-classify.d.ts +20 -0
- package/dist/error-classify.js +159 -0
- package/dist/events.d.ts +26 -0
- package/dist/events.js +97 -0
- package/dist/gemini-binary.d.ts +4 -0
- package/dist/gemini-binary.js +64 -0
- package/dist/gemini-spawn.d.ts +52 -0
- package/dist/gemini-spawn.js +369 -0
- package/dist/git-identity.d.ts +18 -0
- package/dist/git-identity.js +104 -0
- package/dist/grok-binary.d.ts +4 -0
- package/dist/grok-binary.js +70 -0
- package/dist/grok-spawn.d.ts +41 -0
- package/dist/grok-spawn.js +495 -0
- package/dist/guardrails.d.ts +32 -0
- package/dist/guardrails.js +125 -0
- package/dist/hang-watchdog.d.ts +17 -0
- package/dist/hang-watchdog.js +48 -0
- package/dist/harness-registry.d.ts +55 -0
- package/dist/harness-registry.js +180 -0
- package/dist/heartbeat.d.ts +13 -0
- package/dist/heartbeat.js +95 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +440 -0
- package/dist/install.d.ts +63 -0
- package/dist/install.js +467 -0
- package/dist/jsonl-tail.d.ts +20 -0
- package/dist/jsonl-tail.js +192 -0
- package/dist/kimi-binary.d.ts +4 -0
- package/dist/kimi-binary.js +68 -0
- package/dist/kimi-spawn.d.ts +47 -0
- package/dist/kimi-spawn.js +319 -0
- package/dist/lockfile.d.ts +30 -0
- package/dist/lockfile.js +169 -0
- package/dist/mcp-config.d.ts +23 -0
- package/dist/mcp-config.js +133 -0
- package/dist/memory-capture.d.ts +20 -0
- package/dist/memory-capture.js +45 -0
- package/dist/memory-pack.d.ts +34 -0
- package/dist/memory-pack.js +171 -0
- package/dist/model-filter.d.ts +4 -0
- package/dist/model-filter.js +34 -0
- package/dist/node-health.d.ts +18 -0
- package/dist/node-health.js +74 -0
- package/dist/pairing.d.ts +20 -0
- package/dist/pairing.js +129 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +48 -0
- package/dist/precompact-hook.d.ts +27 -0
- package/dist/precompact-hook.js +136 -0
- package/dist/projects.d.ts +25 -0
- package/dist/projects.js +255 -0
- package/dist/prompt-keystrokes.d.ts +3 -0
- package/dist/prompt-keystrokes.js +56 -0
- package/dist/pty-helper.d.ts +26 -0
- package/dist/pty-helper.js +156 -0
- package/dist/request-pump.d.ts +12 -0
- package/dist/request-pump.js +249 -0
- package/dist/run-retry.d.ts +28 -0
- package/dist/run-retry.js +81 -0
- package/dist/self-update.d.ts +75 -0
- package/dist/self-update.js +192 -0
- package/dist/session-runner.d.ts +130 -0
- package/dist/session-runner.js +2271 -0
- package/dist/sleep-detector.d.ts +11 -0
- package/dist/sleep-detector.js +32 -0
- package/dist/state-migrate.d.ts +8 -0
- package/dist/state-migrate.js +91 -0
- package/dist/store.d.ts +5 -0
- package/dist/store.js +82 -0
- package/dist/supabase-client.d.ts +15 -0
- package/dist/supabase-client.js +119 -0
- package/dist/time-context.d.ts +10 -0
- package/dist/time-context.js +71 -0
- package/dist/tokens.d.ts +4 -0
- package/dist/tokens.js +33 -0
- package/dist/tui/app.d.ts +35 -0
- package/dist/tui/app.js +82 -0
- package/dist/tui/console-capture.d.ts +13 -0
- package/dist/tui/console-capture.js +62 -0
- package/dist/tui/dashboard.d.ts +42 -0
- package/dist/tui/dashboard.js +205 -0
- package/dist/tui/data.d.ts +57 -0
- package/dist/tui/data.js +62 -0
- package/dist/tui/harnesses.d.ts +16 -0
- package/dist/tui/harnesses.js +354 -0
- package/dist/tui/render.d.ts +21 -0
- package/dist/tui/render.js +116 -0
- package/dist/tui/requests.d.ts +18 -0
- package/dist/tui/requests.js +127 -0
- package/dist/tui/run.d.ts +13 -0
- package/dist/tui/run.js +178 -0
- package/dist/tui/transcript.d.ts +39 -0
- package/dist/tui/transcript.js +234 -0
- package/dist/tui.d.ts +1 -0
- package/dist/tui.js +314 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +3 -0
- package/dist/win.d.ts +67 -0
- package/dist/win.js +317 -0
- package/package.json +60 -0
- package/scripts/fix-pty-helper.js +28 -0
- package/scripts/precompact-capture.js +292 -0
- 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
|
+
}
|