@securecoms/agent-runtime 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +87 -7
  2. package/dist/bin.js +2890 -297
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -1,17 +1,1052 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync8 } from "node:fs";
4
+ import { readFileSync as readFileSync10 } from "node:fs";
5
5
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6
- import { dirname as dirname4, join as join8 } from "node:path";
6
+ import { dirname as dirname7, join as join8 } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
- import { accessSync, constants, existsSync as existsSync6, mkdirSync, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
8
+ import { accessSync, constants, existsSync as existsSync6, mkdirSync as mkdirSync4, realpathSync, rmSync, statSync as statSync3, writeFileSync as writeFileSync2 } from "node:fs";
9
9
  import { homedir as homedir4, userInfo } from "node:os";
10
10
 
11
11
  // src/config.ts
12
12
  import { homedir } from "node:os";
13
- import { dirname, join } from "node:path";
13
+ import { dirname as dirname3, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+
16
+ // src/service-log.ts
17
+ var LogLabel = class _LogLabel {
18
+ constructor(value) {
19
+ this.value = value;
20
+ }
21
+ static of(value) {
22
+ return new _LogLabel(LABEL.test(value) ? value : "invalid");
23
+ }
24
+ };
25
+ function logLabel(value) {
26
+ return LogLabel.of(value);
27
+ }
28
+ var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
29
+ var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
30
+ var LABEL = /^[A-Za-z0-9][A-Za-z0-9_.:/@+-]{0,127}$/;
31
+ function errorClass(error) {
32
+ if (error === null) return "NullThrown";
33
+ if (typeof error !== "object" && typeof error !== "function") {
34
+ const primitive = {
35
+ string: "StringThrown",
36
+ number: "NumberThrown",
37
+ boolean: "BooleanThrown",
38
+ bigint: "BigIntThrown",
39
+ symbol: "SymbolThrown",
40
+ undefined: "UndefinedThrown"
41
+ };
42
+ return primitive[typeof error] ?? "UnknownThrown";
43
+ }
44
+ try {
45
+ const name = Object.getPrototypeOf(error)?.constructor?.name;
46
+ return typeof name === "string" && CLASS.test(name) ? name : "Error";
47
+ } catch {
48
+ return "Error";
49
+ }
50
+ }
51
+ function formatServiceLog(level, event, fields = {}, error) {
52
+ const safeEvent = TOKEN.test(event) ? event : "invalid_event";
53
+ const parts = [`[runtime] ${level} ${safeEvent}`];
54
+ for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
55
+ if (!TOKEN.test(key)) continue;
56
+ if (typeof value === "boolean") parts.push(`${key}=${value}`);
57
+ else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
58
+ else if (value instanceof LogLabel) parts.push(`${key}=${LABEL.test(value.value) ? value.value : "invalid"}`);
59
+ }
60
+ if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
61
+ return parts.join(" ");
62
+ }
63
+ function failureEpisodeReporter(warn) {
64
+ let lastClass = null;
65
+ return {
66
+ failed(error) {
67
+ const cls = errorClass(error);
68
+ if (cls === lastClass) return;
69
+ lastClass = cls;
70
+ try {
71
+ warn(error);
72
+ } catch {
73
+ }
74
+ },
75
+ succeeded() {
76
+ lastClass = null;
77
+ }
78
+ };
79
+ }
80
+ function writeServiceLog(level, event, fields, error) {
81
+ const line = formatServiceLog(level, event, fields, error);
82
+ if (level === "error") console.error(line);
83
+ else if (level === "warn") console.warn(line);
84
+ else console.log(line);
85
+ }
86
+
87
+ // src/llm-dispatch.ts
88
+ var MAX_REPORTED_TOKENS_PER_CALL = 1e7;
89
+ function requestTokenBound(encodedBody, cap) {
90
+ return Math.min(Buffer.byteLength(encodedBody, "utf8") + cap.cap, MAX_REPORTED_TOKENS_PER_CALL);
91
+ }
92
+ function tokenCount(value, max) {
93
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return void 0;
94
+ return Math.min(Math.floor(value), max);
95
+ }
96
+ var NEVER_SENT_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"]);
97
+ function neverSent(error) {
98
+ const cause = error?.cause;
99
+ if (!cause || typeof cause !== "object") return false;
100
+ if (typeof cause.code === "string" && NEVER_SENT_CODES.has(cause.code)) return true;
101
+ const errors = cause.errors;
102
+ return Array.isArray(errors) && errors.length > 0 && errors.every((e) => typeof e?.code === "string" && NEVER_SENT_CODES.has(e.code));
103
+ }
104
+ var failedCallTokens = /* @__PURE__ */ new WeakMap();
105
+ function reportedTokensOf(error) {
106
+ return typeof error === "object" && error !== null ? failedCallTokens.get(error) : void 0;
107
+ }
108
+ var LLM_REQUEST_MAX_BYTES = 128 * 1024;
109
+ var LLM_RESPONSE_MAX_BYTES = 128 * 1024;
110
+ var LLM_REQUEST_TIMEOUT_MS = 18e4;
111
+ var DEFAULT_MAX_OUTPUT_TOKENS = 8192;
112
+ var CAP_RETRY_FALLBACK_TOKENS = 4096;
113
+ var MIN_PARSED_OUTPUT_LIMIT = 256;
114
+ var CAP_REJECTED_MAX = 256;
115
+ var capOverrides = /* @__PURE__ */ new Map();
116
+ function remember(key, cap) {
117
+ if (capOverrides.size >= CAP_REJECTED_MAX && !capOverrides.has(key)) {
118
+ const oldest = capOverrides.keys().next();
119
+ if (!oldest.done) capOverrides.delete(oldest.value);
120
+ }
121
+ capOverrides.set(key, cap);
122
+ }
123
+ var CAP_ERROR_SCAN_BYTES = 16 * 1024;
124
+ var CAP_FIELD_NAMED = /max_(?:completion_|output_)?tokens/i;
125
+ var CONTEXT_OVERFLOW_NAMED = /prompt|context|input[\s_-]*(?:length|tokens)/i;
126
+ async function readCapRejection(resp, sent, maxTokens) {
127
+ const none = { retry: null, tokens: void 0 };
128
+ const reader = resp.body?.getReader();
129
+ if (!reader) return none;
130
+ const chunks = [];
131
+ let total = 0;
132
+ try {
133
+ while (total < CAP_ERROR_SCAN_BYTES) {
134
+ const { done, value } = await reader.read();
135
+ if (done) break;
136
+ if (!value) continue;
137
+ chunks.push(value);
138
+ total += value.byteLength;
139
+ }
140
+ } catch {
141
+ return none;
142
+ } finally {
143
+ await reader.cancel().catch(() => {
144
+ });
145
+ reader.releaseLock();
146
+ }
147
+ const text = new TextDecoder().decode(Buffer.concat(chunks).subarray(0, CAP_ERROR_SCAN_BYTES));
148
+ let tokens;
149
+ try {
150
+ const parsed = JSON.parse(text);
151
+ tokens = tokenCount(parsed?.usage?.total_tokens, maxTokens);
152
+ } catch {
153
+ }
154
+ if (!CAP_FIELD_NAMED.test(text) || CONTEXT_OVERFLOW_NAMED.test(text)) return { retry: null, tokens };
155
+ let stated;
156
+ for (const match of text.matchAll(STATED_LIMIT)) {
157
+ const n = Number(match[1]);
158
+ if (n >= MIN_PARSED_OUTPUT_LIMIT && n < sent.cap && (stated === void 0 || n > stated)) stated = n;
159
+ }
160
+ const other = sent.field === "max_tokens" ? "max_completion_tokens" : "max_tokens";
161
+ const namesOther = other === "max_tokens" ? /(?<![a-z_])max_tokens/i.test(text) : /max_completion_tokens/i.test(text);
162
+ if (namesOther) return { retry: { field: other, cap: stated ?? sent.cap }, tokens };
163
+ const cap = stated ?? capFallback(sent.cap);
164
+ return { retry: cap >= 1 ? { field: sent.field, cap } : null, tokens };
165
+ }
166
+ var STATED_LIMIT = /(?:>|<=|≤|at most|up to|no more than|not exceed|less than or equal to|maximum(?: of| is|:)?|limit(?: of| is|:)?)\s*[`'"]?(\d{1,9})(?![\d.])/gi;
167
+ function capFallback(rejected) {
168
+ return Math.min(CAP_RETRY_FALLBACK_TOKENS, Math.floor(rejected / 2));
169
+ }
170
+ function outputCapField(endpoint) {
171
+ try {
172
+ return new URL(endpoint).hostname === "api.openai.com" ? "max_completion_tokens" : "max_tokens";
173
+ } catch {
174
+ return "max_tokens";
175
+ }
176
+ }
177
+ function normalizeEndpoint(endpoint) {
178
+ return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
179
+ }
180
+ var LLMDispatcher = class {
181
+ endpoint;
182
+ model;
183
+ apiKey;
184
+ chatPath;
185
+ chatAuth;
186
+ authHeaderName;
187
+ fetch;
188
+ timeoutMs;
189
+ requestMaxBytes;
190
+ responseMaxBytes;
191
+ maxOutputTokens;
192
+ log;
193
+ constructor(endpoint, model, opts = {}) {
194
+ this.endpoint = normalizeEndpoint(endpoint);
195
+ this.model = model;
196
+ this.apiKey = opts.apiKey;
197
+ this.chatPath = opts.chatPath ?? "/v1/chat/completions";
198
+ this.chatAuth = opts.chatAuth ?? "bearer";
199
+ this.authHeaderName = opts.authHeaderName ?? "x-api-key";
200
+ this.fetch = opts.fetch ?? fetch;
201
+ this.timeoutMs = opts.timeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
202
+ this.requestMaxBytes = opts.requestMaxBytes ?? LLM_REQUEST_MAX_BYTES;
203
+ this.responseMaxBytes = opts.responseMaxBytes ?? LLM_RESPONSE_MAX_BYTES;
204
+ this.maxOutputTokens = opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
205
+ this.log = opts.log ?? writeServiceLog;
206
+ }
207
+ async complete(messages, tools, options = {}) {
208
+ const body = {
209
+ model: this.model,
210
+ messages,
211
+ stream: false
212
+ };
213
+ const capKey = `${this.endpoint}\0${this.model}`;
214
+ if (tools && tools.length > 0) {
215
+ body.tools = tools;
216
+ }
217
+ const url = `${this.endpoint}${this.chatPath}`;
218
+ const headers = { "Content-Type": "application/json" };
219
+ if (this.apiKey && this.chatAuth === "bearer") {
220
+ headers.Authorization = `Bearer ${this.apiKey}`;
221
+ } else if (this.apiKey && this.chatAuth === "x-api-key") {
222
+ headers[this.authHeaderName] = this.apiKey;
223
+ }
224
+ const encode = (cap2) => {
225
+ const encoded = JSON.stringify({ ...body, [cap2.field]: cap2.cap });
226
+ if (Buffer.byteLength(encoded, "utf8") > this.requestMaxBytes) {
227
+ throw new Error("LLM request exceeded the size limit");
228
+ }
229
+ return encoded;
230
+ };
231
+ const override = capOverrides.get(capKey);
232
+ let cap = override ? { field: override.field, cap: Math.min(override.cap, this.maxOutputTokens) } : { field: outputCapField(this.endpoint), cap: this.maxOutputTokens };
233
+ let encodedBody = encode(cap);
234
+ let reportedTokens = 0;
235
+ const controller = new AbortController();
236
+ let timedOut = false;
237
+ const cancel = () => controller.abort(options.signal?.reason);
238
+ if (options.signal?.aborted) cancel();
239
+ else options.signal?.addEventListener("abort", cancel, { once: true });
240
+ const timer = setTimeout(() => {
241
+ timedOut = true;
242
+ controller.abort(new Error("LLM request deadline exceeded"));
243
+ }, this.timeoutMs);
244
+ let settle;
245
+ let abortedAtDispatch = false;
246
+ const settleWith = (outcome) => {
247
+ const s = settle;
248
+ settle = void 0;
249
+ s?.(outcome);
250
+ };
251
+ const usageOutcome = (tokens) => tokens !== void 0 ? { kind: "reported", tokens } : { kind: "unreported" };
252
+ try {
253
+ const post = () => {
254
+ settle = options.meter?.(requestTokenBound(encodedBody, cap));
255
+ abortedAtDispatch = controller.signal.aborted;
256
+ return this.fetch(url, {
257
+ method: "POST",
258
+ headers,
259
+ body: encodedBody,
260
+ signal: controller.signal,
261
+ redirect: "error"
262
+ });
263
+ };
264
+ let resp = await post();
265
+ if (resp.status === 400) {
266
+ const rejection = await readCapRejection(resp, cap, requestTokenBound(encodedBody, cap));
267
+ reportedTokens += rejection.tokens ?? 0;
268
+ settleWith(usageOutcome(rejection.tokens));
269
+ if (rejection.retry) {
270
+ const retry = rejection.retry;
271
+ this.log("warn", "llm.output_cap_rejected", {
272
+ retry_cap: retry.cap,
273
+ field_switched: retry.field !== cap.field
274
+ });
275
+ const rejected = cap;
276
+ cap = retry;
277
+ encodedBody = encode(cap);
278
+ options.beforeRetry?.();
279
+ resp = await post();
280
+ if (resp.ok) remember(capKey, retry);
281
+ else if (resp.status === 400) {
282
+ remember(capKey, { field: retry.field, cap: Math.min(retry.cap, capFallback(rejected.cap)) });
283
+ }
284
+ if (resp.status === 400) {
285
+ const second = (await readCapRejection(resp, cap, requestTokenBound(encodedBody, cap))).tokens;
286
+ reportedTokens += second ?? 0;
287
+ settleWith(usageOutcome(second));
288
+ throw new Error("LLM request failed (400)");
289
+ }
290
+ } else {
291
+ throw new Error("LLM request failed (400)");
292
+ }
293
+ }
294
+ if (!resp.ok) {
295
+ await resp.body?.cancel();
296
+ settleWith({ kind: "unreported" });
297
+ throw new Error(`LLM request failed (${resp.status})`);
298
+ }
299
+ const data = await readJsonCapped(resp, this.responseMaxBytes);
300
+ const maxTokens = requestTokenBound(encodedBody, cap);
301
+ const totalTokens = tokenCount(data?.usage?.total_tokens, maxTokens);
302
+ const promptTokens = tokenCount(data?.usage?.prompt_tokens, maxTokens);
303
+ const completionTokens = tokenCount(data?.usage?.completion_tokens, maxTokens);
304
+ if (totalTokens !== void 0) reportedTokens += totalTokens;
305
+ settleWith(usageOutcome(totalTokens));
306
+ const choice = data?.choices?.[0];
307
+ if (!choice?.message) throw new Error("LLM returned no choices");
308
+ return {
309
+ content: choice.message.content,
310
+ toolCalls: choice.message.tool_calls || [],
311
+ finishReason: choice.finish_reason,
312
+ ...reportedTokens > 0 || totalTokens !== void 0 ? {
313
+ usage: {
314
+ // Every attempt's reported tokens: a rejected first attempt
315
+ // was billed too.
316
+ totalTokens: reportedTokens,
317
+ ...promptTokens !== void 0 ? { promptTokens } : {},
318
+ ...completionTokens !== void 0 ? { completionTokens } : {}
319
+ }
320
+ } : {}
321
+ };
322
+ } catch (error) {
323
+ if (settle) {
324
+ const provablyNotSent = abortedAtDispatch || !timedOut && !options.signal?.aborted && neverSent(error);
325
+ settleWith(provablyNotSent ? { kind: "not_sent" } : { kind: "unreported" });
326
+ }
327
+ const failure = timedOut ? new Error(`LLM request timed out after ${this.timeoutMs}ms`) : options.signal?.aborted ? new Error("LLM request cancelled") : error;
328
+ if (reportedTokens > 0 && typeof failure === "object" && failure !== null) {
329
+ failedCallTokens.set(failure, reportedTokens);
330
+ }
331
+ throw failure;
332
+ } finally {
333
+ clearTimeout(timer);
334
+ options.signal?.removeEventListener("abort", cancel);
335
+ }
336
+ }
337
+ };
338
+ async function readJsonCapped(resp, maxBytes) {
339
+ const reader = resp.body?.getReader();
340
+ if (!reader) throw new Error("LLM returned an empty response");
341
+ const chunks = [];
342
+ let total = 0;
343
+ try {
344
+ while (true) {
345
+ const { done, value } = await reader.read();
346
+ if (done) break;
347
+ if (!value) continue;
348
+ total += value.byteLength;
349
+ if (total > maxBytes) {
350
+ await reader.cancel().catch(() => {
351
+ });
352
+ throw new Error("LLM response exceeded the size limit");
353
+ }
354
+ chunks.push(value);
355
+ }
356
+ } catch (error) {
357
+ await reader.cancel().catch(() => {
358
+ });
359
+ throw error;
360
+ } finally {
361
+ reader.releaseLock();
362
+ }
363
+ const joined = new Uint8Array(total);
364
+ let offset = 0;
365
+ for (const chunk of chunks) {
366
+ joined.set(chunk, offset);
367
+ offset += chunk.byteLength;
368
+ }
369
+ return JSON.parse(new TextDecoder().decode(joined));
370
+ }
371
+
372
+ // src/llm-budget.ts
373
+ import { closeSync as closeSync2, fsyncSync as fsyncSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync2, renameSync, writeSync as writeSync2 } from "node:fs";
374
+ import { basename, dirname as dirname2 } from "node:path";
375
+
376
+ // src/llm-budget-lock.ts
377
+ import { execFileSync } from "node:child_process";
378
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, utimesSync, writeSync } from "node:fs";
379
+ import { hostname, uptime } from "node:os";
380
+ import { dirname } from "node:path";
381
+ var LOCK_PARTIAL_WRITE_GRACE_MS = 1e4;
382
+ var LOCK_TAKEOVER_GUARD_STALE_MS = 3e4;
383
+ var BOOT_TOLERANCE_SEC = 120;
384
+ var LOCK_HEARTBEAT_MS = 6e4;
385
+ var LOCK_FOREIGN_STALE_MS = 10 * LOCK_HEARTBEAT_MS;
386
+ function errno(error) {
387
+ const code = error?.code;
388
+ return typeof code === "string" ? code : void 0;
389
+ }
390
+ function readTrimmed(path) {
391
+ try {
392
+ const v = readFileSync(path, "utf8").trim();
393
+ return v === "" ? void 0 : v;
394
+ } catch {
395
+ return void 0;
396
+ }
397
+ }
398
+ function procStartFromStat(stat) {
399
+ const close = stat.lastIndexOf(")");
400
+ if (close < 0) return void 0;
401
+ const rest = stat.slice(close + 1).trim().split(/\s+/);
402
+ const v = rest[19];
403
+ return v !== void 0 && /^\d+$/.test(v) ? v : void 0;
404
+ }
405
+ function pidAliveByKill(pid) {
406
+ try {
407
+ process.kill(pid, 0);
408
+ return true;
409
+ } catch (error) {
410
+ return errno(error) === "EPERM";
411
+ }
412
+ }
413
+ var bootIdCache;
414
+ function readBootId() {
415
+ if (bootIdCache) return bootIdCache.v;
416
+ let v = readTrimmed("/proc/sys/kernel/random/boot_id");
417
+ if (v === void 0 && process.platform === "darwin") {
418
+ try {
419
+ const out = execFileSync("/usr/sbin/sysctl", ["-n", "kern.bootsessionuuid"], {
420
+ encoding: "utf8",
421
+ timeout: 2e3,
422
+ stdio: ["ignore", "pipe", "ignore"]
423
+ }).trim();
424
+ v = /^[0-9A-Fa-f-]{16,64}$/.test(out) ? out : void 0;
425
+ } catch {
426
+ v = void 0;
427
+ }
428
+ }
429
+ bootIdCache = { v };
430
+ return v;
431
+ }
432
+ var realLockEnv = {
433
+ pid: process.pid,
434
+ hostname: () => hostname(),
435
+ bootId: readBootId,
436
+ bootEpochSec: () => Math.round(Date.now() / 1e3 - uptime()),
437
+ procStart: (pid) => {
438
+ const stat = readTrimmed(`/proc/${pid}/stat`);
439
+ return stat ? procStartFromStat(stat) : void 0;
440
+ },
441
+ pidAlive: pidAliveByKill
442
+ };
443
+ function parseBody(raw) {
444
+ try {
445
+ const b = JSON.parse(raw);
446
+ if (b && b.v === 2 && Number.isInteger(b.pid) && b.pid > 0 && typeof b.host === "string" && Number.isFinite(b.boot)) {
447
+ return b;
448
+ }
449
+ } catch {
450
+ }
451
+ return void 0;
452
+ }
453
+ var BudgetLock = class {
454
+ constructor(path, env = realLockEnv, log = () => {
455
+ }, heartbeatMs = LOCK_HEARTBEAT_MS) {
456
+ this.path = path;
457
+ this.env = env;
458
+ this.log = log;
459
+ this.heartbeatMs = heartbeatMs;
460
+ }
461
+ held = false;
462
+ /** The exact body this process wrote (#808 N1): release and the ownership
463
+ * check compare the file against THIS, never against live machine facts
464
+ * (a hostname can change under a running process). */
465
+ written;
466
+ heartbeat;
467
+ /** The error behind the last `unreadable` / `failed`, for the caller's one
468
+ * log line (the lock itself never logs a refusal: it is retried per call). */
469
+ lastError;
470
+ body() {
471
+ const bootId = this.env.bootId();
472
+ const start = this.env.procStart(this.env.pid);
473
+ const b = {
474
+ v: 2,
475
+ pid: this.env.pid,
476
+ host: this.env.hostname(),
477
+ ...bootId !== void 0 ? { boot_id: bootId } : {},
478
+ boot: this.env.bootEpochSec(),
479
+ ...start !== void 0 ? { start } : {}
480
+ };
481
+ return JSON.stringify(b);
482
+ }
483
+ /** Try to hold the lock. Never throws (#808 L-e: machine reads included). */
484
+ acquire() {
485
+ try {
486
+ return this.acquireUnguarded();
487
+ } catch (error) {
488
+ this.lastError = error;
489
+ return "failed";
490
+ }
491
+ }
492
+ /**
493
+ * True while the file is still exactly the body this process wrote. Read
494
+ * before every reserve (one small read), so a holder whose lock was taken
495
+ * while it was frozen stops before it spends (#808 N1). A lost lock is
496
+ * dropped: the caller then refuses, and later calls try to re-acquire.
497
+ */
498
+ stillHeld() {
499
+ if (!this.held) return false;
500
+ let current;
501
+ try {
502
+ current = readFileSync(this.path, "utf8");
503
+ } catch {
504
+ current = void 0;
505
+ }
506
+ if (current !== void 0 && current === this.written) return true;
507
+ this.drop();
508
+ this.log("warn", "llm.budget_lock_lost", {});
509
+ return false;
510
+ }
511
+ acquireUnguarded() {
512
+ if (this.held) return "acquired";
513
+ const created = this.create();
514
+ if (created === "created") return this.took();
515
+ if (created === "failed") return "failed";
516
+ let raw;
517
+ let ageMs;
518
+ this.env.beforeLockRead?.();
519
+ try {
520
+ raw = readFileSync(this.path, "utf8");
521
+ ageMs = Date.now() - statSync(this.path).mtimeMs;
522
+ } catch (error) {
523
+ if (errno(error) === "ENOENT") {
524
+ const again = this.create();
525
+ return again === "created" ? this.took() : again === "failed" ? "failed" : "busy";
526
+ }
527
+ this.lastError = error;
528
+ return "unreadable";
529
+ }
530
+ const b = parseBody(raw);
531
+ if (!b) {
532
+ if (ageMs < LOCK_PARTIAL_WRITE_GRACE_MS) return "busy";
533
+ return this.takeOver(raw, void 0);
534
+ }
535
+ if (b.host !== this.env.hostname()) {
536
+ const bootId = this.env.bootId();
537
+ const sameKernel = b.boot_id !== void 0 && bootId !== void 0 && b.boot_id === bootId;
538
+ if (sameKernel && ageMs > LOCK_FOREIGN_STALE_MS) return this.takeOver(raw, b.pid);
539
+ return "foreign_host";
540
+ }
541
+ if (!this.sameBoot(b)) return this.takeOver(raw, b.pid);
542
+ const liveStart = this.env.procStart(b.pid);
543
+ const reused = b.start !== void 0 && liveStart !== void 0 && liveStart !== b.start;
544
+ if (b.pid === this.env.pid) {
545
+ if (!reused) return this.took();
546
+ return this.takeOver(raw, b.pid);
547
+ }
548
+ if (this.env.pidAlive(b.pid) && !reused) return "held";
549
+ return this.takeOver(raw, b.pid);
550
+ }
551
+ /** Remove the lock only while it is still exactly the body this process
552
+ * wrote (#808 N1: not the live hostname, which can change under us). */
553
+ release() {
554
+ if (!this.held) return;
555
+ const written = this.written;
556
+ this.drop();
557
+ try {
558
+ if (written !== void 0 && readFileSync(this.path, "utf8") === written) unlinkSync(this.path);
559
+ } catch {
560
+ }
561
+ }
562
+ drop() {
563
+ this.held = false;
564
+ if (this.heartbeat) clearInterval(this.heartbeat);
565
+ this.heartbeat = void 0;
566
+ }
567
+ /** We hold it: remember what we wrote (the file as it is now, ours) and
568
+ * start the heartbeat. */
569
+ took() {
570
+ this.held = true;
571
+ try {
572
+ this.written = readFileSync(this.path, "utf8");
573
+ } catch {
574
+ this.written = void 0;
575
+ }
576
+ if (!this.heartbeat && this.heartbeatMs > 0) {
577
+ this.heartbeat = setInterval(() => this.beat(), this.heartbeatMs);
578
+ this.heartbeat.unref?.();
579
+ }
580
+ return "acquired";
581
+ }
582
+ /** Refresh the mtime while the file is still ours; stop if it is not. */
583
+ beat() {
584
+ if (!this.stillHeld()) return;
585
+ try {
586
+ const now = /* @__PURE__ */ new Date();
587
+ utimesSync(this.path, now, now);
588
+ } catch {
589
+ }
590
+ }
591
+ sameBoot(b) {
592
+ const bootId = this.env.bootId();
593
+ if (b.boot_id !== void 0 && bootId !== void 0) return b.boot_id === bootId;
594
+ return Math.abs(b.boot - this.env.bootEpochSec()) <= BOOT_TOLERANCE_SEC;
595
+ }
596
+ /** O_EXCL create with our body. A write failure removes the half-written
597
+ * file (it is ours: we created it) and reports `failed`, never throws. */
598
+ create() {
599
+ let fd;
600
+ try {
601
+ mkdirSync(dirname(this.path), { recursive: true });
602
+ fd = openSync(this.path, "wx", 384);
603
+ } catch (error) {
604
+ if (errno(error) === "EEXIST") return "exists";
605
+ this.lastError = error;
606
+ return "failed";
607
+ }
608
+ try {
609
+ writeSync(fd, this.body());
610
+ fsyncSync(fd);
611
+ } catch (error) {
612
+ try {
613
+ closeSync(fd);
614
+ } catch {
615
+ }
616
+ try {
617
+ unlinkSync(this.path);
618
+ } catch {
619
+ }
620
+ this.lastError = error;
621
+ return "failed";
622
+ }
623
+ closeSync(fd);
624
+ return "created";
625
+ }
626
+ /**
627
+ * Replace a stale lock. Only the holder of the O_EXCL takeover guard may
628
+ * remove a lock, and only after re-reading it UNCHANGED, so two runtimes
629
+ * that both saw the same stale lock cannot each delete the other's new one.
630
+ * A fresh guard means a takeover is in progress: busy this time.
631
+ */
632
+ takeOver(staleRaw, stalePid, retryGuard = true) {
633
+ const guard = `${this.path}.takeover`;
634
+ let fd;
635
+ try {
636
+ fd = openSync(guard, "wx", 384);
637
+ } catch (error) {
638
+ if (errno(error) !== "EEXIST") return "failed";
639
+ let crashed = false;
640
+ try {
641
+ crashed = Date.now() - statSync(guard).mtimeMs > LOCK_TAKEOVER_GUARD_STALE_MS;
642
+ if (crashed) unlinkSync(guard);
643
+ } catch {
644
+ crashed = false;
645
+ }
646
+ if (crashed && retryGuard) return this.takeOver(staleRaw, stalePid, false);
647
+ return "busy";
648
+ }
649
+ closeSync(fd);
650
+ try {
651
+ this.env.beforeTakeoverReread?.();
652
+ let current;
653
+ try {
654
+ current = readFileSync(this.path, "utf8");
655
+ } catch {
656
+ current = void 0;
657
+ }
658
+ if (current !== void 0 && current !== staleRaw) return "held";
659
+ if (current !== void 0) unlinkSync(this.path);
660
+ this.env.beforeTakeoverCreate?.();
661
+ const created = this.create();
662
+ if (created !== "created") return created === "failed" ? "failed" : "held";
663
+ this.log("warn", "llm.budget_lock_stale_taken_over", stalePid !== void 0 ? { stale_pid: stalePid } : {});
664
+ return this.took();
665
+ } catch (error) {
666
+ this.lastError = error;
667
+ return "failed";
668
+ } finally {
669
+ try {
670
+ unlinkSync(guard);
671
+ } catch {
672
+ }
673
+ }
674
+ }
675
+ };
676
+
677
+ // src/llm-budget.ts
678
+ var DEFAULT_DAILY_CALL_LIMIT = 2e3;
679
+ var DEFAULT_DAILY_TOKEN_LIMIT = 5e6;
680
+ var DailyLimitReachedError = class extends Error {
681
+ constructor(day) {
682
+ super("daily LLM budget reached");
683
+ this.day = day;
684
+ this.name = "DailyLimitReachedError";
685
+ }
686
+ };
687
+ var RequestExceedsDailyTokenCapError = class extends Error {
688
+ constructor(reserve, tokenLimit) {
689
+ super("one LLM request's worst case exceeds the daily token limit");
690
+ this.reserve = reserve;
691
+ this.tokenLimit = tokenLimit;
692
+ this.name = "RequestExceedsDailyTokenCapError";
693
+ }
694
+ };
695
+ var BudgetLockHeldError = class extends Error {
696
+ constructor(reason = "held") {
697
+ super("this runtime does not hold its identity's LLM budget lock");
698
+ this.reason = reason;
699
+ this.name = "BudgetLockHeldError";
700
+ }
701
+ };
702
+ function logLockRefusal(log, level, reason, lockPath, cause) {
703
+ const where = lockPath ? { lock_file: logLabel(basename(lockPath)), lock_path: logLabel(lockPath.replace(/^\/+/, "")) } : {};
704
+ switch (reason) {
705
+ case "held":
706
+ return log(level, "llm.budget_lock_held", where, cause);
707
+ case "foreign_host":
708
+ return log(level, "llm.budget_lock_foreign_host", {
709
+ ...where,
710
+ remedy: logLabel("remove_the_lock_file_if_no_other_host_uses_this_data_dir")
711
+ }, cause);
712
+ case "unreadable":
713
+ return log(level, "llm.budget_lock_unreadable", {
714
+ ...where,
715
+ remedy: logLabel("fix_the_lock_file_permissions_or_remove_it")
716
+ }, cause);
717
+ case "busy":
718
+ return log(level, "llm.budget_lock_busy", where, cause);
719
+ case "failed":
720
+ return log(level, "llm.budget_lock_failed", where, cause);
721
+ }
722
+ }
723
+ var BudgetStateReadError = class extends Error {
724
+ constructor() {
725
+ super("the LLM budget state could not be read");
726
+ this.name = "BudgetStateReadError";
727
+ }
728
+ };
729
+ var BudgetStateWriteError = class extends Error {
730
+ constructor() {
731
+ super("the LLM budget state could not be saved");
732
+ this.name = "BudgetStateWriteError";
733
+ }
734
+ };
735
+ function minimumDailyTokenLimit(maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS, requestMaxBytes = LLM_REQUEST_MAX_BYTES) {
736
+ return Math.min(requestMaxBytes + maxOutputTokens, MAX_REPORTED_TOKENS_PER_CALL);
737
+ }
738
+ function budgetBootRefusal(budget, tokenLimit, maxOutputTokens, log) {
739
+ const min = dailyTokenLimitTooSmall(tokenLimit, maxOutputTokens);
740
+ if (min !== null) {
741
+ log("error", "runtime.config_invalid.llm_daily_token_limit_below_request_bound", {
742
+ llm_daily_token_limit: tokenLimit,
743
+ minimum: min
744
+ });
745
+ return 2;
746
+ }
747
+ const reason = budget.lockRefusal();
748
+ if (reason !== null) {
749
+ logLockRefusal(log, "error", reason, budget.lockPath(), budget.lockError());
750
+ return 3;
751
+ }
752
+ return null;
753
+ }
754
+ function dailyTokenLimitTooSmall(tokenLimit, maxOutputTokens) {
755
+ const min = minimumDailyTokenLimit(maxOutputTokens);
756
+ return tokenLimit > 0 && tokenLimit < min ? min : null;
757
+ }
758
+ function utcDay(d) {
759
+ return d.toISOString().slice(0, 10);
760
+ }
761
+ function nextUtcDay(day) {
762
+ return utcDay(new Date(Date.parse(`${day}T00:00:00Z`) + 864e5));
763
+ }
764
+ function errnoOf(error) {
765
+ const code = error?.code;
766
+ return typeof code === "string" ? code : void 0;
767
+ }
768
+ var DailyLlmBudget = class {
769
+ callLimit;
770
+ tokenLimit;
771
+ statePath;
772
+ lock;
773
+ lockRefusalLogged = null;
774
+ stateReadError;
775
+ now;
776
+ log;
777
+ state;
778
+ exhaustionLoggedFor = null;
779
+ constructor(opts) {
780
+ this.callLimit = opts.callLimit;
781
+ this.tokenLimit = opts.tokenLimit;
782
+ this.statePath = opts.statePath;
783
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
784
+ this.log = opts.log ?? (() => {
785
+ });
786
+ if (opts.lockPath) {
787
+ this.lock = new BudgetLock(opts.lockPath, opts.lockEnv ?? realLockEnv, this.log);
788
+ this.lock.acquire();
789
+ }
790
+ this.state = this.load();
791
+ }
792
+ /** True when this runtime may call the provider as far as the lock goes
793
+ * (it holds the lock, or no lock is configured). index.ts checks it before
794
+ * starting the data plane (#808 M2). */
795
+ holdsLock() {
796
+ return !this.lock || this.lock.held;
797
+ }
798
+ /** The error behind the last `unreadable` / `failed` lock refusal. */
799
+ lockError() {
800
+ return this.lock?.lastError;
801
+ }
802
+ lockPath() {
803
+ return this.lock?.path;
804
+ }
805
+ /** Why the lock is not held (retrying once), or null when it is / none is
806
+ * configured. Never throws. */
807
+ lockRefusal() {
808
+ if (!this.lock || this.lock.held && this.lock.stillHeld()) return null;
809
+ const result = this.lock.acquire();
810
+ if (result === "acquired") {
811
+ this.state = this.load();
812
+ return null;
813
+ }
814
+ return result;
815
+ }
816
+ /**
817
+ * Refuse up front, without charging anything, when this runtime may not
818
+ * call the provider at all: another runtime holds the lock, or either daily
819
+ * limit is already spent. The provider is never reached.
820
+ */
821
+ assertOpen() {
822
+ this.assertLock();
823
+ this.assertStateReadable();
824
+ this.roll();
825
+ const { calls, tokens } = this.state;
826
+ const callsSpent = this.callLimit > 0 && calls >= this.callLimit;
827
+ const tokensSpent = this.tokenLimit > 0 && tokens >= this.tokenLimit;
828
+ if (callsSpent || tokensSpent) this.exhausted();
829
+ }
830
+ /**
831
+ * SEC-06 item 10: reserve ONE provider request — one call and its
832
+ * worst-case tokens (`reserve`, the dispatcher's request bound) — and save
833
+ * the charge before the request is sent. Throws, charging nothing, when the
834
+ * lock is held elsewhere ({@link BudgetLockHeldError}), when the reserve
835
+ * alone exceeds the whole token cap ({@link RequestExceedsDailyTokenCapError}),
836
+ * or when it does not fit what is left today ({@link DailyLimitReachedError}).
837
+ *
838
+ * Returns the request's settle function (idempotent): see {@link RequestOutcome}.
839
+ */
840
+ beginCall(reserve) {
841
+ if (typeof reserve !== "number" || !Number.isFinite(reserve) || reserve <= 0) {
842
+ throw new TypeError("LLM budget reserve must be a positive finite number");
843
+ }
844
+ this.assertLock();
845
+ this.assertStateReadable();
846
+ this.roll();
847
+ const bound = Math.ceil(reserve);
848
+ if (this.tokenLimit > 0 && bound > this.tokenLimit) {
849
+ this.log("warn", "llm.budget_request_exceeds_cap", { reserve: bound, token_limit: this.tokenLimit });
850
+ throw new RequestExceedsDailyTokenCapError(bound, this.tokenLimit);
851
+ }
852
+ const { calls, tokens } = this.state;
853
+ const callsSpent = this.callLimit > 0 && calls >= this.callLimit;
854
+ const tokensSpent = this.tokenLimit > 0 && tokens + bound > this.tokenLimit;
855
+ if (callsSpent || tokensSpent) this.exhausted();
856
+ this.state.calls++;
857
+ this.state.tokens += bound;
858
+ try {
859
+ this.persist();
860
+ } catch (error) {
861
+ this.state.calls--;
862
+ this.state.tokens -= bound;
863
+ this.log("warn", "llm.budget_state_write_failed", { refused: true }, error);
864
+ throw new BudgetStateWriteError();
865
+ }
866
+ const day = this.state.day;
867
+ let settled = false;
868
+ return (outcome) => {
869
+ if (settled) return;
870
+ settled = true;
871
+ this.roll();
872
+ if (this.state.day !== day) return;
873
+ if (outcome.kind === "not_sent") {
874
+ this.state.calls = Math.max(0, this.state.calls - 1);
875
+ this.state.tokens = Math.max(0, this.state.tokens - bound);
876
+ } else if (outcome.kind === "reported") {
877
+ const actual = Number.isFinite(outcome.tokens) && outcome.tokens > 0 ? Math.floor(outcome.tokens) : 0;
878
+ const charged = Math.min(actual, bound);
879
+ this.state.tokens = Math.max(0, this.state.tokens - (bound - charged));
880
+ } else {
881
+ return;
882
+ }
883
+ this.save();
884
+ };
885
+ }
886
+ /** Legacy, after the fact: charge one call a completer did not meter. */
887
+ chargeUnmeteredCall() {
888
+ this.roll();
889
+ this.state.calls++;
890
+ this.save();
891
+ }
892
+ /** Release the lock, if this runtime holds it and it is still ours. */
893
+ close() {
894
+ this.lock?.release();
895
+ }
896
+ exhausted() {
897
+ const { calls, tokens, day } = this.state;
898
+ if (this.exhaustionLoggedFor !== day) {
899
+ this.exhaustionLoggedFor = day;
900
+ this.log("warn", "llm.daily_budget_exhausted", {
901
+ calls,
902
+ tokens,
903
+ call_limit: this.callLimit,
904
+ token_limit: this.tokenLimit
905
+ });
906
+ }
907
+ throw new DailyLimitReachedError(day);
908
+ }
909
+ assertLock() {
910
+ if (!this.lock || this.lock.held && this.lock.stillHeld()) return;
911
+ const result = this.lock.acquire();
912
+ if (result === "acquired") {
913
+ this.state = this.load();
914
+ this.lockRefusalLogged = null;
915
+ return;
916
+ }
917
+ if (this.lockRefusalLogged !== result) {
918
+ this.lockRefusalLogged = result;
919
+ const cause = result === "unreadable" || result === "failed" ? this.lock.lastError : void 0;
920
+ logLockRefusal(this.log, "warn", result, this.lock.path, cause);
921
+ }
922
+ throw new BudgetLockHeldError(result);
923
+ }
924
+ /** #808 L-d: the state file exists but cannot be read. LLM calls are
925
+ * refused until it can: starting a fresh day over it would re-grant the
926
+ * whole cap, and the next save would overwrite the real counters. */
927
+ assertStateReadable() {
928
+ if (this.stateReadError === void 0) return;
929
+ this.state = this.load();
930
+ if (this.stateReadError === void 0) return;
931
+ throw new BudgetStateReadError();
932
+ }
933
+ /** Add provider-reported tokens to today's total. */
934
+ recordTokens(totalTokens) {
935
+ if (!Number.isFinite(totalTokens) || totalTokens <= 0) return;
936
+ this.roll();
937
+ this.state.tokens += Math.floor(totalTokens);
938
+ this.save();
939
+ }
940
+ snapshot() {
941
+ this.roll();
942
+ return { ...this.state };
943
+ }
944
+ /** Start a new day only when the clock moves FORWARD past the stored one:
945
+ * a clock stepped back across 00:00 UTC must not wipe today's spend
946
+ * (#808 L1). YYYY-MM-DD compares correctly as a string. */
947
+ roll() {
948
+ const today = utcDay(this.now());
949
+ if (today > this.state.day || this.state.day > nextUtcDay(today)) {
950
+ this.state = { day: today, calls: 0, tokens: 0 };
951
+ }
952
+ }
953
+ load() {
954
+ const fresh = { day: utcDay(this.now()), calls: 0, tokens: 0 };
955
+ if (!this.statePath) return fresh;
956
+ let raw;
957
+ try {
958
+ raw = readFileSync2(this.statePath, "utf8");
959
+ this.stateReadError = void 0;
960
+ } catch (error) {
961
+ if (errnoOf(error) === "ENOENT") {
962
+ this.stateReadError = void 0;
963
+ return fresh;
964
+ }
965
+ if (this.stateReadError === void 0) this.log("warn", "llm.budget_state_read_failed", {}, error);
966
+ this.stateReadError = error;
967
+ return fresh;
968
+ }
969
+ try {
970
+ const parsed = JSON.parse(raw);
971
+ if (typeof parsed.day === "string" && /^\d{4}-\d{2}-\d{2}$/.test(parsed.day) && parsed.day >= fresh.day && parsed.day <= nextUtcDay(fresh.day) && Number.isInteger(parsed.calls) && parsed.calls >= 0 && Number.isInteger(parsed.tokens) && parsed.tokens >= 0) {
972
+ return { day: parsed.day, calls: parsed.calls, tokens: parsed.tokens };
973
+ }
974
+ if (typeof parsed.day === "string" && parsed.day > nextUtcDay(fresh.day)) {
975
+ this.log("warn", "llm.budget_state_future_day", {});
976
+ }
977
+ return fresh;
978
+ } catch (error) {
979
+ this.log("warn", "llm.budget_state_unreadable", {}, error);
980
+ return fresh;
981
+ }
982
+ }
983
+ /** Write the counters durably: tmp file, fsync, rename (#808 L2). Throws. */
984
+ persist() {
985
+ if (!this.statePath) return;
986
+ mkdirSync2(dirname2(this.statePath), { recursive: true });
987
+ const tmp = `${this.statePath}.tmp`;
988
+ const fd = openSync2(tmp, "w", 384);
989
+ try {
990
+ writeSync2(fd, JSON.stringify(this.state));
991
+ fsyncSync2(fd);
992
+ } finally {
993
+ closeSync2(fd);
994
+ }
995
+ renameSync(tmp, this.statePath);
996
+ }
997
+ /** Best effort, for writes that only ever LOWER what is on disk relative to
998
+ * the truth (a settle's refund) or add after the fact: a failure leaves the
999
+ * file conservative, and the in-memory counters still bound this process. */
1000
+ save() {
1001
+ if (this.stateReadError !== void 0) return;
1002
+ try {
1003
+ this.persist();
1004
+ } catch (error) {
1005
+ this.log("warn", "llm.budget_state_write_failed", {}, error);
1006
+ }
1007
+ }
1008
+ };
1009
+ function withDailyBudget(llm, budget) {
1010
+ return {
1011
+ async complete(messages, tools, options) {
1012
+ budget.assertOpen();
1013
+ let metered = false;
1014
+ const meter = (bound) => {
1015
+ metered = true;
1016
+ const outer = options?.meter?.(bound);
1017
+ let settle;
1018
+ try {
1019
+ settle = budget.beginCall(bound);
1020
+ } catch (error) {
1021
+ outer?.({ kind: "not_sent" });
1022
+ throw error;
1023
+ }
1024
+ return (outcome) => {
1025
+ settle(outcome);
1026
+ outer?.(outcome);
1027
+ };
1028
+ };
1029
+ let result;
1030
+ try {
1031
+ result = await llm.complete(messages, tools, { ...options, meter });
1032
+ } catch (error) {
1033
+ if (!metered) {
1034
+ budget.chargeUnmeteredCall();
1035
+ const spent = reportedTokensOf(error);
1036
+ if (spent !== void 0) budget.recordTokens(spent);
1037
+ }
1038
+ throw error;
1039
+ }
1040
+ if (!metered) {
1041
+ budget.chargeUnmeteredCall();
1042
+ if (result.usage) budget.recordTokens(result.usage.totalTokens);
1043
+ }
1044
+ return result;
1045
+ }
1046
+ };
1047
+ }
1048
+
1049
+ // src/config.ts
15
1050
  var MISSING_IDENTITY_MESSAGE = "Missing agent identity: pass --identity <id> or set SECURECOMS_IDENTITY";
16
1051
  var LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
17
1052
  var TOOL_DEFAULTS = /* @__PURE__ */ new Set(["allow", "deny"]);
@@ -48,8 +1083,16 @@ function parseList(value) {
48
1083
  const items = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
49
1084
  return items.length > 0 ? items : void 0;
50
1085
  }
1086
+ function parseIntSetting(env, name, fallback, min) {
1087
+ const raw = env[name]?.trim();
1088
+ if (!raw) return fallback;
1089
+ if (!/^\d+$/.test(raw) || Number(raw) < min || !Number.isSafeInteger(Number(raw))) {
1090
+ throw new Error(`Invalid ${name} "${raw}": expected a whole number >= ${min}`);
1091
+ }
1092
+ return Number(raw);
1093
+ }
51
1094
  function defaultSkillsDir() {
52
- return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
1095
+ return join(dirname3(fileURLToPath(import.meta.url)), "..", "skills");
53
1096
  }
54
1097
  var DEFAULT_TOOL_ALLOW = [
55
1098
  "echo",
@@ -99,7 +1142,10 @@ function loadConfig(argv, env) {
99
1142
  toolGateEnv: {
100
1143
  ...parsedAllow !== void 0 ? { allow: parsedAllow } : {},
101
1144
  ...deny !== void 0 ? { deny } : {}
102
- }
1145
+ },
1146
+ llmMaxOutputTokens: parseIntSetting(env, "SECURECOMS_LLM_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS, 1),
1147
+ llmDailyCallLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_CALL_LIMIT", DEFAULT_DAILY_CALL_LIMIT, 0),
1148
+ llmDailyTokenLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_TOKEN_LIMIT", DEFAULT_DAILY_TOKEN_LIMIT, 0)
103
1149
  };
104
1150
  }
105
1151
 
@@ -154,9 +1200,23 @@ function parsePersonaVerb(argv) {
154
1200
  const sub = argv[3];
155
1201
  return sub === "init" || sub === "show" ? sub : null;
156
1202
  }
1203
+ function parseSideEffectsVerb(argv) {
1204
+ if (argv[2] !== "side-effects") return null;
1205
+ const sub = argv[3];
1206
+ return sub === "list" || sub === "resolve" ? sub : null;
1207
+ }
1208
+ function parseSideEffectResolveFlags(argv) {
1209
+ const outcome = flagOrEnv(argv, "--outcome", {});
1210
+ return {
1211
+ idempotencyKey: flagOrEnv(argv, "--idempotency-key", {}),
1212
+ operationSha256: flagOrEnv(argv, "--operation-sha256", {}),
1213
+ confirmationId: flagOrEnv(argv, "--confirmation-id", {}),
1214
+ outcome: outcome === "completed" || outcome === "not_applied" ? outcome : void 0
1215
+ };
1216
+ }
157
1217
 
158
1218
  // src/service/install.ts
159
- import { dirname as dirname2 } from "node:path";
1219
+ import { dirname as dirname4 } from "node:path";
160
1220
 
161
1221
  // src/service/config.ts
162
1222
  import { isAbsolute, resolve } from "node:path";
@@ -388,8 +1448,8 @@ function installService(input, deps) {
388
1448
  const plan = prepareInstall(input, deps.platform, deps);
389
1449
  const keyFile = plan.keyFile;
390
1450
  if (deps.platform === "darwin") {
391
- deps.mkdirp(dirname2(cfg.plistPath));
392
- deps.mkdirp(dirname2(cfg.logPath));
1451
+ deps.mkdirp(dirname4(cfg.plistPath));
1452
+ deps.mkdirp(dirname4(cfg.logPath));
393
1453
  deps.writeFile(cfg.plistPath, plan.contents);
394
1454
  deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
395
1455
  requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
@@ -397,7 +1457,7 @@ function installService(input, deps) {
397
1457
  return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, ...keyFile ? { keyFile } : {} };
398
1458
  }
399
1459
  if (deps.platform === "linux") {
400
- deps.mkdirp(dirname2(cfg.unitPath));
1460
+ deps.mkdirp(dirname4(cfg.unitPath));
401
1461
  deps.writeFile(cfg.unitPath, plan.contents);
402
1462
  deps.run("systemctl", ["--user", "daemon-reload"]);
403
1463
  deps.run("loginctl", ["enable-linger", deps.user]);
@@ -431,63 +1491,6 @@ function uninstallService(input, deps) {
431
1491
  throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
432
1492
  }
433
1493
 
434
- // src/llm-dispatch.ts
435
- function normalizeEndpoint(endpoint) {
436
- return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
437
- }
438
- var LLMDispatcher = class {
439
- endpoint;
440
- model;
441
- apiKey;
442
- chatPath;
443
- chatAuth;
444
- authHeaderName;
445
- constructor(endpoint, model, opts = {}) {
446
- this.endpoint = normalizeEndpoint(endpoint);
447
- this.model = model;
448
- this.apiKey = opts.apiKey;
449
- this.chatPath = opts.chatPath ?? "/v1/chat/completions";
450
- this.chatAuth = opts.chatAuth ?? "bearer";
451
- this.authHeaderName = opts.authHeaderName ?? "x-api-key";
452
- }
453
- async complete(messages, tools) {
454
- const body = {
455
- model: this.model,
456
- messages,
457
- stream: false
458
- };
459
- if (tools && tools.length > 0) {
460
- body.tools = tools;
461
- }
462
- const url = `${this.endpoint}${this.chatPath}`;
463
- const headers = { "Content-Type": "application/json" };
464
- if (this.apiKey && this.chatAuth === "bearer") {
465
- headers.Authorization = `Bearer ${this.apiKey}`;
466
- } else if (this.apiKey && this.chatAuth === "x-api-key") {
467
- headers[this.authHeaderName] = this.apiKey;
468
- }
469
- const resp = await fetch(url, {
470
- method: "POST",
471
- headers,
472
- body: JSON.stringify(body)
473
- });
474
- if (!resp.ok) {
475
- await resp.body?.cancel();
476
- throw new Error(`LLM request failed (${resp.status})`);
477
- }
478
- const data = await resp.json();
479
- const choice = data.choices?.[0];
480
- if (!choice) {
481
- throw new Error("LLM returned no choices");
482
- }
483
- return {
484
- content: choice.message.content,
485
- toolCalls: choice.message.tool_calls || [],
486
- finishReason: choice.finish_reason
487
- };
488
- }
489
- };
490
-
491
1494
  // src/llm-source.ts
492
1495
  import { join as join2 } from "node:path";
493
1496
 
@@ -661,34 +1664,21 @@ function resolveLlmSource(cfg, explicit, deps) {
661
1664
  }
662
1665
 
663
1666
  // src/llm-shim.ts
664
- function endpointHost(endpoint) {
665
- try {
666
- return new URL(endpoint).host;
667
- } catch {
668
- return "(unparseable endpoint)";
669
- }
670
- }
671
1667
  function buildLlmShim(cfg, explicit, deps) {
672
1668
  return {
673
- async complete(messages, tools) {
1669
+ async complete(messages, tools, options) {
674
1670
  const src = resolveLlmSource(cfg, explicit, deps);
675
1671
  const dispatcher = new LLMDispatcher(src.endpoint, src.model, {
676
1672
  apiKey: src.apiKey,
677
1673
  chatPath: src.chatPath,
678
- chatAuth: src.chatAuth
1674
+ chatAuth: src.chatAuth,
1675
+ maxOutputTokens: cfg.llmMaxOutputTokens
679
1676
  });
680
- return dispatcher.complete(messages, tools);
1677
+ const result = await dispatcher.complete(messages, tools, options);
1678
+ return { ...result, source: { provider: src.provider, model: src.model } };
681
1679
  }
682
1680
  };
683
1681
  }
684
- function bootLine(cfg, explicit, deps) {
685
- try {
686
- const src = resolveLlmSource(cfg, explicit, deps);
687
- return `securecoms-agent ${cfg.identityId.slice(0, 8)} \u2192 llm source=${src.origin} ${endpointHost(src.endpoint)} (${src.model})`;
688
- } catch (e) {
689
- return `llm: source unavailable \u2014 ${e instanceof Error ? e.message : String(e)}`;
690
- }
691
- }
692
1682
 
693
1683
  // src/memory.ts
694
1684
  var MAX_HISTORY = 50;
@@ -742,7 +1732,7 @@ ${scored.map((entry) => `- ${entry.content}`).join("\n")}`;
742
1732
  };
743
1733
 
744
1734
  // src/skill-invoker.ts
745
- import { readFileSync as readFileSync4, readdirSync, existsSync as existsSync3, statSync } from "node:fs";
1735
+ import { readFileSync as readFileSync6, readdirSync, existsSync as existsSync3, statSync as statSync2 } from "node:fs";
746
1736
  import { join as join4 } from "node:path";
747
1737
 
748
1738
  // src/skill-md.ts
@@ -866,16 +1856,35 @@ ${guidance}` : description,
866
1856
  }
867
1857
 
868
1858
  // src/requires.ts
869
- function hasReadableKeyFile(paths, read) {
1859
+ function hasReadableKeyFile(paths, read, onReadError) {
870
1860
  return paths.some((path) => {
871
1861
  if (!path) return false;
872
1862
  try {
873
1863
  return read(path).trim() !== "";
874
- } catch {
1864
+ } catch (error) {
1865
+ try {
1866
+ onReadError?.(path, error);
1867
+ } catch {
1868
+ }
875
1869
  return false;
876
1870
  }
877
1871
  });
878
1872
  }
1873
+ function searchKeyGateReporter(log, isBravePath) {
1874
+ const logged = /* @__PURE__ */ new Set();
1875
+ return (path, error) => {
1876
+ if (error?.code === "ENOENT") return;
1877
+ const signature = `${path}|${errorClass(error)}`;
1878
+ if (logged.has(signature)) return;
1879
+ logged.add(signature);
1880
+ log(
1881
+ "warn",
1882
+ "runtime.search_key_unreadable",
1883
+ { at_gate: true, provider_brave: isBravePath(path) },
1884
+ error
1885
+ );
1886
+ };
1887
+ }
879
1888
  function requiresEnvFrom(cfg, hasSearchKey) {
880
1889
  return {
881
1890
  hasSearchKey,
@@ -898,7 +1907,7 @@ function resolveRequires(req, env) {
898
1907
  }
899
1908
 
900
1909
  // src/skills-config.ts
901
- import { existsSync as existsSync2, readFileSync } from "node:fs";
1910
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
902
1911
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
903
1912
 
904
1913
  // src/config-dirs.ts
@@ -927,13 +1936,13 @@ function loadSkillsConfigFile(path, dataDir) {
927
1936
  if (!existsSync2(path)) return empty();
928
1937
  let raw;
929
1938
  try {
930
- raw = JSON.parse(readFileSync(path, "utf8"));
1939
+ raw = JSON.parse(readFileSync3(path, "utf8"));
931
1940
  } catch (e) {
932
- console.warn(`[skills-config] ignoring ${path}: ${e instanceof Error ? e.message : String(e)}`);
1941
+ writeServiceLog("warn", "skills_config.parse_failed", {}, e);
933
1942
  return empty();
934
1943
  }
935
1944
  if (typeof raw !== "object" || raw === null) {
936
- console.warn(`[skills-config] ignoring ${path}: expected a JSON object`);
1945
+ writeServiceLog("warn", "skills_config.root_invalid");
937
1946
  return empty();
938
1947
  }
939
1948
  const src = raw;
@@ -941,7 +1950,7 @@ function loadSkillsConfigFile(path, dataDir) {
941
1950
  for (const [name, v] of Object.entries(src.endpoints ?? {})) {
942
1951
  const e = v;
943
1952
  if (typeof e?.baseUrl !== "string" || e.baseUrl === "") {
944
- console.warn(`[skills-config] endpoint "${name}" in ${path} has no baseUrl; skipped`);
1953
+ writeServiceLog("warn", "skills_config.endpoint_invalid");
945
1954
  continue;
946
1955
  }
947
1956
  endpoints[name] = {
@@ -955,7 +1964,7 @@ function loadSkillsConfigFile(path, dataDir) {
955
1964
  if (typeof d?.sqlite === "string" && d.sqlite !== "") databases[name] = { sqlite: abs2(d.sqlite, dataDir) };
956
1965
  else if (typeof d?.postgresUrlFile === "string" && d.postgresUrlFile !== "") {
957
1966
  databases[name] = { postgresUrlFile: abs2(d.postgresUrlFile, dataDir) };
958
- } else console.warn(`[skills-config] database "${name}" in ${path} needs sqlite or postgresUrlFile; skipped`);
1967
+ } else writeServiceLog("warn", "skills_config.database_invalid");
959
1968
  }
960
1969
  const flags = Array.isArray(src.flags) ? src.flags.filter((f) => typeof f === "string") : [];
961
1970
  const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : void 0;
@@ -968,9 +1977,9 @@ function loadSkillsConfigFile(path, dataDir) {
968
1977
  const s = src.search;
969
1978
  if (s && typeof s === "object") {
970
1979
  if (s.provider !== "brave" && s.provider !== "tavily") {
971
- console.warn(`[skills-config] search in ${path} has an unknown provider; skipped`);
1980
+ writeServiceLog("warn", "skills_config.search_provider_invalid");
972
1981
  } else if (typeof s.keyFile !== "string" || s.keyFile === "") {
973
- console.warn(`[skills-config] search in ${path} has no keyFile; skipped`);
1982
+ writeServiceLog("warn", "skills_config.search_key_file_missing");
974
1983
  } else {
975
1984
  search = { provider: s.provider, keyFile: abs2(s.keyFile, dataDir) };
976
1985
  }
@@ -997,17 +2006,193 @@ var currentTime = async (args, ctx) => {
997
2006
  };
998
2007
 
999
2008
  // src/html-to-text.ts
2009
+ var HTML_TO_TEXT_MAX_INPUT = 2e6;
1000
2010
  function htmlToText(html) {
1001
- return html.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<[^>]*>/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&").replace(/\s+/g, " ").trim();
2011
+ const n = Math.min(html.length, HTML_TO_TEXT_MAX_INPUT);
2012
+ const b = new Uint16Array(n);
2013
+ for (let i = 0; i < n; i++) b[i] = html.charCodeAt(i);
2014
+ let len = stripRawTextElement(b, n, "script");
2015
+ len = stripRawTextElement(b, len, "style");
2016
+ len = stripComments(b, len);
2017
+ len = stripTags(b, len);
2018
+ len = decodeAndCollapse(b, len);
2019
+ return fromCodes(b, len);
2020
+ }
2021
+ var LT = 60;
2022
+ var GT = 62;
2023
+ var SLASH = 47;
2024
+ var BANG = 33;
2025
+ var DASH = 45;
2026
+ var AMP = 38;
2027
+ var SPACE = 32;
2028
+ function lower(c) {
2029
+ return c >= 65 && c <= 90 ? c | 32 : c;
2030
+ }
2031
+ function nameAt(b, len, at, name) {
2032
+ if (at + name.length > len) return false;
2033
+ for (let k = 0; k < name.length; k++) {
2034
+ if (lower(b[at + k]) !== name.charCodeAt(k)) return false;
2035
+ }
2036
+ return true;
2037
+ }
2038
+ function isWordChar(c) {
2039
+ return c >= 48 && c <= 57 || c >= 65 && c <= 90 || c >= 97 && c <= 122 || c === 95;
2040
+ }
2041
+ function isSpace(c) {
2042
+ if (c <= 32) return c === 32 || c >= 9 && c <= 13;
2043
+ if (c < 160) return false;
2044
+ return c === 160 || c === 5760 || c >= 8192 && c <= 8202 || c === 8232 || c === 8233 || c === 8239 || c === 8287 || c === 12288 || c === 65279;
2045
+ }
2046
+ function indexIn(b, len, c, from) {
2047
+ for (let k = from; k < len; k++) if (b[k] === c) return k;
2048
+ return -1;
2049
+ }
2050
+ function keep(b, w, from, to) {
2051
+ if (w === from) return to;
2052
+ if (to - from > 32) b.copyWithin(w, from, to);
2053
+ else for (let k = from; k < to; k++) b[w + k - from] = b[k];
2054
+ return w + (to - from);
2055
+ }
2056
+ function stripRawTextElement(b, len, name) {
2057
+ const first = name.charCodeAt(0);
2058
+ let w = 0;
2059
+ let copied = 0;
2060
+ for (let i = 0; i + 1 < len; i++) {
2061
+ if (b[i] !== LT || lower(b[i + 1]) !== first || !nameAt(b, len, i + 1, name)) continue;
2062
+ const afterName = i + 1 + name.length;
2063
+ if (afterName >= len || isWordChar(b[afterName])) continue;
2064
+ const gt = indexIn(b, len, GT, afterName);
2065
+ if (gt < 0) break;
2066
+ const end = findCloseTag(b, len, gt + 1, name);
2067
+ if (end < 0) break;
2068
+ w = keep(b, w, copied, i);
2069
+ b[w++] = SPACE;
2070
+ copied = end;
2071
+ i = end - 1;
2072
+ }
2073
+ return copied === 0 ? len : keep(b, w, copied, len);
2074
+ }
2075
+ function findCloseTag(b, len, from, name) {
2076
+ for (let i = from; i + 1 < len; i++) {
2077
+ if (b[i] !== LT || b[i + 1] !== SLASH || !nameAt(b, len, i + 2, name)) continue;
2078
+ let j = i + 2 + name.length;
2079
+ while (j < len && isSpace(b[j])) j++;
2080
+ if (j < len && b[j] === GT) return j + 1;
2081
+ i = j - 1;
2082
+ }
2083
+ return -1;
2084
+ }
2085
+ function stripComments(b, len) {
2086
+ let w = 0;
2087
+ let copied = 0;
2088
+ for (let i = 0; i + 3 < len; i++) {
2089
+ if (b[i] !== LT || b[i + 1] !== BANG || b[i + 2] !== DASH || b[i + 3] !== DASH) continue;
2090
+ let end = -1;
2091
+ for (let k = i + 4; k + 2 < len; k++) {
2092
+ if (b[k] === DASH && b[k + 1] === DASH && b[k + 2] === GT) {
2093
+ end = k + 3;
2094
+ break;
2095
+ }
2096
+ }
2097
+ if (end < 0) break;
2098
+ w = keep(b, w, copied, i);
2099
+ b[w++] = SPACE;
2100
+ copied = end;
2101
+ i = end - 1;
2102
+ }
2103
+ return copied === 0 ? len : keep(b, w, copied, len);
2104
+ }
2105
+ function stripTags(b, len) {
2106
+ let w = 0;
2107
+ let copied = 0;
2108
+ for (; ; ) {
2109
+ const lt = indexIn(b, len, LT, copied);
2110
+ if (lt < 0) break;
2111
+ const gt = indexIn(b, len, GT, lt + 1);
2112
+ if (gt < 0) break;
2113
+ w = keep(b, w, copied, lt);
2114
+ b[w++] = SPACE;
2115
+ copied = gt + 1;
2116
+ }
2117
+ return copied === 0 ? len : keep(b, w, copied, len);
2118
+ }
2119
+ function decodeAndCollapse(b, len) {
2120
+ let i = 0;
2121
+ while (i < len && isSpace(b[i])) i++;
2122
+ let end = len;
2123
+ while (end > i && isSpace(b[end - 1])) end--;
2124
+ let w = 0;
2125
+ while (i < end) {
2126
+ const c = b[i];
2127
+ if (c === AMP) {
2128
+ const ent = entityAt(b, end, i);
2129
+ if (ent !== 0) {
2130
+ b[w++] = ent >>> 8;
2131
+ i += ent & 255;
2132
+ continue;
2133
+ }
2134
+ } else if (isSpace(c)) {
2135
+ b[w++] = SPACE;
2136
+ i++;
2137
+ while (i < end && isSpace(b[i])) i++;
2138
+ continue;
2139
+ }
2140
+ b[w++] = c;
2141
+ i++;
2142
+ }
2143
+ return w;
2144
+ }
2145
+ var ENTITIES = [
2146
+ ["lt;", 60],
2147
+ ["gt;", 62],
2148
+ ["quot;", 34],
2149
+ ["#39;", 39],
2150
+ ["amp;", 38]
2151
+ ];
2152
+ function entityAt(b, end, i) {
2153
+ const c1 = b[i + 1];
2154
+ for (const [body, ch] of ENTITIES) {
2155
+ if (body.charCodeAt(0) !== c1 || i + 1 + body.length > end) continue;
2156
+ let k = 1;
2157
+ while (k < body.length && b[i + 1 + k] === body.charCodeAt(k)) k++;
2158
+ if (k === body.length) return ch << 8 | body.length + 1;
2159
+ }
2160
+ return 0;
2161
+ }
2162
+ function fromCodes(b, len) {
2163
+ const CHUNK = 8192;
2164
+ let out = "";
2165
+ for (let at = 0; at < len; at += CHUNK) {
2166
+ out += String.fromCharCode.apply(null, b.subarray(at, Math.min(at + CHUNK, len)));
2167
+ }
2168
+ return out;
1002
2169
  }
1003
2170
 
1004
2171
  // src/net-guard.ts
1005
- import { lookup } from "node:dns/promises";
2172
+ import { Resolver as DnsResolver } from "node:dns/promises";
1006
2173
  import { isIPv4, isIPv6 } from "node:net";
1007
2174
  var BlockedAddressError = class extends Error {
1008
2175
  };
1009
2176
  function systemResolver() {
1010
- return async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
2177
+ return async (host, signal) => {
2178
+ if (isIPv4(host) || isIPv6(host)) return [host];
2179
+ if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
2180
+ const resolver = new DnsResolver();
2181
+ const cancel = () => resolver.cancel();
2182
+ signal?.addEventListener("abort", cancel, { once: true });
2183
+ try {
2184
+ const results = await Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]);
2185
+ if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
2186
+ const addresses = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
2187
+ if (addresses.length === 0) {
2188
+ const rejected = results.find((result) => result.status === "rejected");
2189
+ throw rejected?.reason ?? new Error("DNS lookup returned no addresses");
2190
+ }
2191
+ return addresses;
2192
+ } finally {
2193
+ signal?.removeEventListener("abort", cancel);
2194
+ }
2195
+ };
1011
2196
  }
1012
2197
  function v4Parts(ip) {
1013
2198
  if (!isIPv4(ip)) return null;
@@ -1054,7 +2239,7 @@ function isPrivateAddress(ip) {
1054
2239
  }
1055
2240
  return (v6[0] & 65024) === 64512 || (v6[0] & 65472) === 65152;
1056
2241
  }
1057
- async function assertPublicHost(url, resolve3) {
2242
+ async function assertPublicHost(url, resolve3, signal) {
1058
2243
  if (url.protocol !== "http:" && url.protocol !== "https:") {
1059
2244
  throw new BlockedAddressError(`refused ${url.protocol}//: only http and https are allowed`);
1060
2245
  }
@@ -1064,7 +2249,9 @@ async function assertPublicHost(url, resolve3) {
1064
2249
  }
1065
2250
  let addresses;
1066
2251
  try {
1067
- addresses = await resolve3(host);
2252
+ if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
2253
+ addresses = await resolve3(host, signal);
2254
+ if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
1068
2255
  } catch (e) {
1069
2256
  throw new BlockedAddressError(`refused ${url.hostname}: cannot resolve (${e instanceof Error ? e.message : String(e)})`);
1070
2257
  }
@@ -1082,14 +2269,20 @@ async function safeFetch(url, opts) {
1082
2269
  const maxRedirects = opts.maxRedirects ?? 3;
1083
2270
  const maxBytes = opts.maxBytes ?? 2e6;
1084
2271
  const controller = new AbortController();
1085
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1e4);
2272
+ const cancel = () => controller.abort(opts.signal?.reason);
2273
+ if (opts.signal?.aborted) cancel();
2274
+ else opts.signal?.addEventListener("abort", cancel, { once: true });
2275
+ const timer = setTimeout(
2276
+ () => controller.abort(new Error("network request deadline exceeded")),
2277
+ opts.timeoutMs ?? 1e4
2278
+ );
1086
2279
  try {
1087
2280
  let current = new URL(url);
1088
2281
  let method = opts.method ?? "GET";
1089
2282
  let headers = opts.headers;
1090
2283
  let body = opts.body;
1091
2284
  for (let hop = 0; hop <= maxRedirects; hop++) {
1092
- await assertPublicHost(current, opts.resolve);
2285
+ await assertPublicHost(current, opts.resolve, controller.signal);
1093
2286
  const res = await opts.fetch(current.toString(), {
1094
2287
  method,
1095
2288
  headers,
@@ -1122,6 +2315,7 @@ async function safeFetch(url, opts) {
1122
2315
  throw new BlockedAddressError(`too many redirects (>${maxRedirects})`);
1123
2316
  } finally {
1124
2317
  clearTimeout(timer);
2318
+ opts.signal?.removeEventListener("abort", cancel);
1125
2319
  }
1126
2320
  }
1127
2321
  function headerMap(res) {
@@ -1171,7 +2365,8 @@ var fetchUrl = async (args, ctx) => {
1171
2365
  fetch: ctx.fetch,
1172
2366
  maxRedirects: 3,
1173
2367
  timeoutMs: 1e4,
1174
- maxBytes: 2e6
2368
+ maxBytes: 2e6,
2369
+ signal: ctx.signal
1175
2370
  });
1176
2371
  const type = res.headers["content-type"] ?? "";
1177
2372
  const text = /html|xml/i.test(type) ? htmlToText(res.text) : res.text.replace(/\s+/g, " ").trim();
@@ -1182,7 +2377,7 @@ var fetchUrl = async (args, ctx) => {
1182
2377
  };
1183
2378
 
1184
2379
  // src/builtins/http-request.ts
1185
- import { readFileSync as readFileSync2 } from "node:fs";
2380
+ import { readFileSync as readFileSync4 } from "node:fs";
1186
2381
  var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "DELETE"]);
1187
2382
  var httpRequest = async (args, ctx) => {
1188
2383
  const name = typeof args.name === "string" ? args.name : "";
@@ -1225,7 +2420,7 @@ var httpRequest = async (args, ctx) => {
1225
2420
  const headers = { accept: "application/json" };
1226
2421
  if (endpoint.headersFile) {
1227
2422
  try {
1228
- for (const [k, v] of Object.entries(JSON.parse(readFileSync2(endpoint.headersFile, "utf8")))) {
2423
+ for (const [k, v] of Object.entries(JSON.parse(readFileSync4(endpoint.headersFile, "utf8")))) {
1229
2424
  if (typeof v === "string") headers[k] = v;
1230
2425
  }
1231
2426
  } catch {
@@ -1242,7 +2437,8 @@ var httpRequest = async (args, ctx) => {
1242
2437
  headers,
1243
2438
  body,
1244
2439
  timeoutMs: 1e4,
1245
- maxBytes: 2e6
2440
+ maxBytes: 2e6,
2441
+ signal: ctx.signal
1246
2442
  });
1247
2443
  return JSON.stringify({ status: res.status, body: capTo(res.text, FETCH_TEXT_CAP) });
1248
2444
  } catch (e) {
@@ -1251,7 +2447,8 @@ var httpRequest = async (args, ctx) => {
1251
2447
  };
1252
2448
 
1253
2449
  // src/builtins/sql-query.ts
1254
- import { readFileSync as readFileSync3 } from "node:fs";
2450
+ import { readFileSync as readFileSync5 } from "node:fs";
2451
+ import { spawn } from "node:child_process";
1255
2452
 
1256
2453
  // src/sql-guard.ts
1257
2454
  var SqlNotReadOnlyError = class extends Error {
@@ -1280,12 +2477,12 @@ var FORBIDDEN = [
1280
2477
  "EXECUTE",
1281
2478
  "INTO"
1282
2479
  ];
1283
- function stripComments(sql) {
2480
+ function stripComments2(sql) {
1284
2481
  return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ");
1285
2482
  }
1286
2483
  function assertReadOnlySelect(sql) {
1287
2484
  const statement = sql.trim().replace(/;\s*$/, "").trim();
1288
- const body = stripComments(sql).trim().replace(/;\s*$/, "").trim();
2485
+ const body = stripComments2(sql).trim().replace(/;\s*$/, "").trim();
1289
2486
  if (!body) throw new SqlNotReadOnlyError("sql_query needs a SELECT statement");
1290
2487
  for (const text of [body, statement]) {
1291
2488
  if (text.includes(";")) {
@@ -1305,16 +2502,160 @@ function assertReadOnlySelect(sql) {
1305
2502
  }
1306
2503
 
1307
2504
  // src/builtins/sql-query.ts
2505
+ var SQLITE_PROCESS_SOURCE = String.raw`
2506
+ const { DatabaseSync } = require("node:sqlite");
2507
+
2508
+ let input = "";
2509
+ process.stdin.setEncoding("utf8");
2510
+ process.stdin.on("data", (chunk) => { input += chunk; });
2511
+ process.stdin.on("end", () => {
2512
+ let handle;
2513
+ try {
2514
+ const request = JSON.parse(input);
2515
+ handle = new DatabaseSync(request.path, {
2516
+ readOnly: true,
2517
+ timeout: request.busyTimeoutMs,
2518
+ });
2519
+ // Keep SQLite-owned allocations bounded too, not only V8's heap. Temp
2520
+ // work stays in the limited heap instead of consuming unbounded disk,
2521
+ // and mmap is disabled so reads cannot bypass the allocation ceiling.
2522
+ const heapLimit = handle.prepare(
2523
+ "PRAGMA hard_heap_limit = " + request.sqliteHeapLimitBytes
2524
+ ).get();
2525
+ const effectiveHeapLimit = Number(Object.values(heapLimit ?? {})[0]);
2526
+ if (!(effectiveHeapLimit > 0 && effectiveHeapLimit <= request.sqliteHeapLimitBytes)) {
2527
+ throw new Error("SQLite heap limit could not be enforced");
2528
+ }
2529
+ handle.exec("PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0");
2530
+ const statement = handle.prepare(request.sql);
2531
+ const rows = [];
2532
+ let columns = [];
2533
+ let truncated = false;
2534
+
2535
+ for (const record of statement.iterate()) {
2536
+ if (rows.length === request.rowCap) {
2537
+ truncated = true;
2538
+ break;
2539
+ }
2540
+ if (rows.length === 0) columns = Object.keys(record);
2541
+ rows.push(columns.map((column) => record[column]));
2542
+ }
2543
+
2544
+ process.stdout.write(JSON.stringify({
2545
+ ok: true,
2546
+ result: { columns, rows, truncated },
2547
+ }));
2548
+ } catch (error) {
2549
+ process.stdout.write(JSON.stringify({
2550
+ ok: false,
2551
+ error: error instanceof Error ? error.message : String(error),
2552
+ }));
2553
+ } finally {
2554
+ if (handle) handle.close();
2555
+ }
2556
+ });
2557
+ `;
2558
+ var SQLITE_IPC_CAP_BYTES = 1024 * 1024;
2559
+ var SQLITE_HEAP_CAP_BYTES = 32 * 1024 * 1024;
2560
+ function executeSqliteQuery(path, sql, options = {}) {
2561
+ const timeoutMs = options.timeoutMs ?? SQL_TIMEOUT_MS;
2562
+ if (options.signal?.aborted) return Promise.reject(new Error("SQLite query cancelled"));
2563
+ return new Promise((resolve3, reject) => {
2564
+ const child = spawn(process.execPath, [
2565
+ "--no-warnings",
2566
+ "--max-old-space-size=32",
2567
+ "--input-type=commonjs",
2568
+ "--eval",
2569
+ SQLITE_PROCESS_SOURCE
2570
+ ], {
2571
+ // The query process needs no credentials or runtime configuration.
2572
+ // In particular, do not let NODE_OPTIONS weaken its heap boundary.
2573
+ env: {},
2574
+ stdio: ["pipe", "pipe", "ignore"],
2575
+ windowsHide: true
2576
+ });
2577
+ let settled = false;
2578
+ let stopError;
2579
+ let stdout = "";
2580
+ let stdoutBytes = 0;
2581
+ const cleanup = () => {
2582
+ clearTimeout(timer);
2583
+ options.signal?.removeEventListener("abort", onAbort);
2584
+ };
2585
+ const stop = (error) => {
2586
+ if (settled || stopError) return;
2587
+ stopError = error;
2588
+ cleanup();
2589
+ child.kill("SIGKILL");
2590
+ };
2591
+ const onAbort = () => stop(new Error("SQLite query cancelled"));
2592
+ const timer = setTimeout(
2593
+ () => stop(new Error(`SQLite query timed out after ${timeoutMs}ms`)),
2594
+ timeoutMs
2595
+ );
2596
+ options.signal?.addEventListener("abort", onAbort, { once: true });
2597
+ child.stdout.setEncoding("utf8");
2598
+ child.stdout.on("data", (chunk) => {
2599
+ if (stopError) return;
2600
+ stdoutBytes += Buffer.byteLength(chunk);
2601
+ if (stdoutBytes > SQLITE_IPC_CAP_BYTES) {
2602
+ stop(new Error("SQLite query result exceeded the materialization limit"));
2603
+ return;
2604
+ }
2605
+ stdout += chunk;
2606
+ });
2607
+ child.once("error", (error) => {
2608
+ if (settled) return;
2609
+ settled = true;
2610
+ cleanup();
2611
+ reject(error);
2612
+ });
2613
+ child.once("close", (code) => {
2614
+ if (settled) return;
2615
+ settled = true;
2616
+ cleanup();
2617
+ if (stopError) {
2618
+ reject(stopError);
2619
+ return;
2620
+ }
2621
+ if (code !== 0) {
2622
+ reject(new Error(`SQLite query process exited before returning a result (code ${code})`));
2623
+ return;
2624
+ }
2625
+ try {
2626
+ const message = JSON.parse(stdout);
2627
+ if (message.ok && message.result) resolve3(message.result);
2628
+ else reject(new Error(message.error ?? "SQLite query process failed"));
2629
+ } catch {
2630
+ reject(new Error("SQLite query process returned an invalid result"));
2631
+ }
2632
+ });
2633
+ child.stdin.on("error", () => {
2634
+ });
2635
+ child.stdin.end(JSON.stringify({
2636
+ path,
2637
+ sql,
2638
+ rowCap: SQL_ROW_CAP,
2639
+ busyTimeoutMs: timeoutMs,
2640
+ sqliteHeapLimitBytes: SQLITE_HEAP_CAP_BYTES
2641
+ }));
2642
+ });
2643
+ }
1308
2644
  function envelope(columns, rows, truncated) {
1309
2645
  let kept = rows;
1310
2646
  let cut = truncated;
1311
- let out = JSON.stringify({ columns, rows: kept, truncated: cut });
1312
- while (out.length > FETCH_TEXT_CAP && kept.length > 0) {
1313
- kept = kept.slice(0, Math.floor(kept.length / 2));
1314
- cut = true;
1315
- out = JSON.stringify({ columns, rows: kept, truncated: cut });
2647
+ try {
2648
+ let out = JSON.stringify({ columns, rows: kept, truncated: cut });
2649
+ while (resultBytes(out) > RESULT_CAP && kept.length > 0) {
2650
+ kept = kept.slice(0, Math.floor(kept.length / 2));
2651
+ cut = true;
2652
+ out = JSON.stringify({ columns, rows: kept, truncated: cut });
2653
+ }
2654
+ if (resultBytes(out) <= RESULT_CAP) return out;
2655
+ } catch {
2656
+ return toolError("database result could not be serialized");
1316
2657
  }
1317
- return capResult(out);
2658
+ return toolError("database result metadata exceeded the response limit");
1318
2659
  }
1319
2660
  var sqlQuery = async (args, ctx) => {
1320
2661
  const name = typeof args.name === "string" ? args.name : "";
@@ -1332,16 +2673,8 @@ var sqlQuery = async (args, ctx) => {
1332
2673
  }
1333
2674
  try {
1334
2675
  if ("sqlite" in db) {
1335
- const { DatabaseSync } = await import("node:sqlite");
1336
- const handle = new DatabaseSync(db.sqlite, { readOnly: true, timeout: SQL_TIMEOUT_MS });
1337
- try {
1338
- const rows = handle.prepare(sql).all();
1339
- const capped = rows.slice(0, SQL_ROW_CAP);
1340
- const columns = capped.length ? Object.keys(capped[0]) : [];
1341
- return envelope(columns, capped.map((r) => columns.map((c) => r[c])), rows.length > SQL_ROW_CAP);
1342
- } finally {
1343
- handle.close();
1344
- }
2676
+ const result = await executeSqliteQuery(db.sqlite, sql, { signal: ctx.signal });
2677
+ return envelope(result.columns, result.rows, result.truncated);
1345
2678
  }
1346
2679
  let pg;
1347
2680
  try {
@@ -1352,19 +2685,32 @@ var sqlQuery = async (args, ctx) => {
1352
2685
  const Client = pg.Client ?? pg.default?.Client;
1353
2686
  if (!Client) return toolError(`the installed "pg" module does not export a Client`);
1354
2687
  const client = new Client({
1355
- connectionString: readFileSync3(db.postgresUrlFile, "utf8").trim(),
2688
+ connectionString: readFileSync5(db.postgresUrlFile, "utf8").trim(),
1356
2689
  // never logged (D4)
1357
- statement_timeout: SQL_TIMEOUT_MS
2690
+ statement_timeout: SQL_TIMEOUT_MS,
2691
+ connectionTimeoutMillis: SQL_TIMEOUT_MS,
2692
+ query_timeout: SQL_TIMEOUT_MS
1358
2693
  });
1359
- await client.connect();
2694
+ let cancelled = ctx.signal?.aborted ?? false;
2695
+ const cancel = () => {
2696
+ cancelled = true;
2697
+ void client.end().catch(() => {
2698
+ });
2699
+ };
2700
+ if (cancelled) throw new Error("Postgres query cancelled");
2701
+ ctx.signal?.addEventListener("abort", cancel, { once: true });
1360
2702
  try {
2703
+ await client.connect();
2704
+ if (cancelled) throw new Error("Postgres query cancelled");
1361
2705
  await client.query("BEGIN; SET TRANSACTION READ ONLY;");
1362
2706
  const res = await client.query({ text: sql, rowMode: "array" });
2707
+ if (cancelled) throw new Error("Postgres query cancelled");
1363
2708
  const columns = res.fields.map((f) => f.name);
1364
2709
  const rows = res.rows.slice(0, SQL_ROW_CAP);
1365
2710
  return envelope(columns, rows, res.rows.length > SQL_ROW_CAP);
1366
2711
  } finally {
1367
- await client.query("ROLLBACK").catch(() => {
2712
+ ctx.signal?.removeEventListener("abort", cancel);
2713
+ if (!cancelled) await client.query("ROLLBACK").catch(() => {
1368
2714
  });
1369
2715
  await client.end().catch(() => {
1370
2716
  });
@@ -1387,13 +2733,14 @@ var webSearch = async (args, ctx) => {
1387
2733
  try {
1388
2734
  let hits;
1389
2735
  if (brave) {
1390
- ctx.log("debug", `web_search via brave (${count} results)`);
2736
+ ctx.log("debug", "tool.web_search_requested", { count, provider_brave: true });
1391
2737
  const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
1392
2738
  const res = await safeFetch(url, {
1393
2739
  resolve: ctx.resolve,
1394
2740
  fetch: ctx.fetch,
1395
2741
  timeoutMs: 1e4,
1396
2742
  maxBytes: 2e6,
2743
+ signal: ctx.signal,
1397
2744
  headers: { accept: "application/json", "X-Subscription-Token": brave }
1398
2745
  });
1399
2746
  if (res.status !== 200) return toolError(`brave search returned HTTP ${res.status}`);
@@ -1404,12 +2751,13 @@ var webSearch = async (args, ctx) => {
1404
2751
  snippet: r.description ?? ""
1405
2752
  }));
1406
2753
  } else {
1407
- ctx.log("debug", `web_search via tavily (${count} results)`);
2754
+ ctx.log("debug", "tool.web_search_requested", { count, provider_brave: false });
1408
2755
  const res = await safeFetch("https://api.tavily.com/search", {
1409
2756
  resolve: ctx.resolve,
1410
2757
  fetch: ctx.fetch,
1411
2758
  timeoutMs: 1e4,
1412
2759
  maxBytes: 2e6,
2760
+ signal: ctx.signal,
1413
2761
  method: "POST",
1414
2762
  headers: { "content-type": "application/json" },
1415
2763
  body: JSON.stringify({ api_key: tavily, query, max_results: count })
@@ -1432,14 +2780,70 @@ var webSearch = async (args, ctx) => {
1432
2780
  var RESULT_CAP = 2e4;
1433
2781
  var TRUNCATION_MARKER = "\u2026[truncated]";
1434
2782
  var FETCH_TEXT_CAP = RESULT_CAP - 4096;
2783
+ function resultBytes(s) {
2784
+ return Buffer.byteLength(s, "utf8");
2785
+ }
2786
+ function utf8Prefix(s, maxBytes) {
2787
+ if (maxBytes <= 0) return "";
2788
+ const bytes = Buffer.from(s, "utf8");
2789
+ if (bytes.length <= maxBytes) return s;
2790
+ let end = maxBytes;
2791
+ while (end > 0) {
2792
+ const candidate = bytes.subarray(0, end).toString("utf8");
2793
+ if (!candidate.endsWith("\uFFFD") && resultBytes(candidate) <= maxBytes) return candidate;
2794
+ end--;
2795
+ }
2796
+ return "";
2797
+ }
2798
+ function fitJsonString(value, build, fits = (candidate) => resultBytes(candidate) <= RESULT_CAP) {
2799
+ const codePoints = Array.from(value);
2800
+ let low = 0;
2801
+ let high = codePoints.length;
2802
+ let best = build("");
2803
+ while (low <= high) {
2804
+ const middle = Math.floor((low + high) / 2);
2805
+ const candidate = build(codePoints.slice(0, middle).join(""));
2806
+ if (fits(candidate)) {
2807
+ best = candidate;
2808
+ low = middle + 1;
2809
+ } else {
2810
+ high = middle - 1;
2811
+ }
2812
+ }
2813
+ return best;
2814
+ }
1435
2815
  function capTo(s, limit) {
1436
- return s.length <= limit ? s : s.slice(0, limit) + TRUNCATION_MARKER;
2816
+ if (resultBytes(s) <= limit) return s;
2817
+ const markerBytes = resultBytes(TRUNCATION_MARKER);
2818
+ return utf8Prefix(s, Math.max(0, limit - markerBytes)) + TRUNCATION_MARKER;
1437
2819
  }
1438
2820
  function capResult(s) {
1439
2821
  return capTo(s, RESULT_CAP);
1440
2822
  }
2823
+ function boundToolResult(content) {
2824
+ if (resultBytes(content) <= RESULT_CAP) return content;
2825
+ try {
2826
+ const parsed = JSON.parse(content);
2827
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
2828
+ const record = parsed;
2829
+ if (typeof record.error === "string") {
2830
+ const message = typeof record.message === "string" ? record.message : record.error;
2831
+ return toolError(message);
2832
+ }
2833
+ }
2834
+ return fitJsonString(content, (result) => JSON.stringify({ result, truncated: true }));
2835
+ } catch {
2836
+ return capResult(content);
2837
+ }
2838
+ }
1441
2839
  function toolError(message) {
1442
- return JSON.stringify({ error: "tool_failed", message });
2840
+ const plain = JSON.stringify({ error: "tool_failed", message });
2841
+ if (resultBytes(plain) <= RESULT_CAP) return plain;
2842
+ return fitJsonString(message, (prefix) => JSON.stringify({
2843
+ error: "tool_failed",
2844
+ message: prefix,
2845
+ truncated: true
2846
+ }));
1443
2847
  }
1444
2848
  var NO_CONTEXT = "no runtime context: this built-in needs the runtime's network context";
1445
2849
  function offlineContext() {
@@ -1467,12 +2871,148 @@ var BUILTINS = {
1467
2871
  sql_query: sqlQuery
1468
2872
  };
1469
2873
 
2874
+ // src/side-effect-ledger.ts
2875
+ import { createHash } from "node:crypto";
2876
+ import { chmodSync, mkdirSync as mkdirSync3 } from "node:fs";
2877
+ import { dirname as dirname5 } from "node:path";
2878
+ import { DatabaseSync } from "node:sqlite";
2879
+ var DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES = 1e4;
2880
+ function canonicalJson(value) {
2881
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
2882
+ return JSON.stringify(value);
2883
+ }
2884
+ if (typeof value === "number") {
2885
+ if (!Number.isFinite(value)) throw new Error("operation arguments must be finite JSON values");
2886
+ return JSON.stringify(value);
2887
+ }
2888
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
2889
+ if (typeof value === "object") {
2890
+ const record = value;
2891
+ return `{${Object.keys(record).sort().map(
2892
+ (key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
2893
+ ).join(",")}}`;
2894
+ }
2895
+ throw new Error("operation arguments must be JSON values");
2896
+ }
2897
+ function operationSha256(tool, args) {
2898
+ const normalized = tool === "http_request" && typeof args.method === "string" ? { ...args, method: args.method.toUpperCase() } : args;
2899
+ return createHash("sha256").update(`${tool}
2900
+ ${canonicalJson(normalized)}`, "utf8").digest("hex");
2901
+ }
2902
+ var SideEffectLedger = class {
2903
+ constructor(path, maxEntries = DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES) {
2904
+ this.maxEntries = maxEntries;
2905
+ mkdirSync3(dirname5(path), { recursive: true, mode: 448 });
2906
+ this.db = new DatabaseSync(path);
2907
+ chmodSync(path, 384);
2908
+ this.db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA busy_timeout=1000;");
2909
+ this.db.exec(`
2910
+ CREATE TABLE IF NOT EXISTS side_effect_claims (
2911
+ idempotency_key TEXT PRIMARY KEY,
2912
+ operation_sha256 TEXT NOT NULL,
2913
+ confirmation_id TEXT NOT NULL UNIQUE,
2914
+ expires_at TEXT NOT NULL,
2915
+ state TEXT NOT NULL CHECK (state IN ('in_progress', 'completed')),
2916
+ created_at TEXT NOT NULL,
2917
+ completed_at TEXT
2918
+ ) STRICT
2919
+ `);
2920
+ }
2921
+ db;
2922
+ claim(grant, now) {
2923
+ this.db.exec("BEGIN IMMEDIATE");
2924
+ try {
2925
+ const prior = this.db.prepare(`
2926
+ SELECT idempotency_key, operation_sha256, confirmation_id, state
2927
+ FROM side_effect_claims WHERE idempotency_key = ? OR confirmation_id = ?
2928
+ `).get(grant.idempotency_key, grant.confirmation_id);
2929
+ if (prior) {
2930
+ this.db.exec("COMMIT");
2931
+ if (prior.operation_sha256 !== grant.operation_sha256 || prior.confirmation_id !== grant.confirmation_id || prior.idempotency_key !== grant.idempotency_key) return { kind: "conflict" };
2932
+ return { kind: prior.state === "completed" ? "completed" : "ambiguous" };
2933
+ }
2934
+ const count = this.db.prepare("SELECT COUNT(*) AS count FROM side_effect_claims").get();
2935
+ if (count.count >= this.maxEntries) {
2936
+ this.db.exec("COMMIT");
2937
+ return { kind: "full" };
2938
+ }
2939
+ this.db.prepare(`
2940
+ INSERT INTO side_effect_claims
2941
+ (idempotency_key, operation_sha256, confirmation_id, expires_at, state, created_at)
2942
+ VALUES (?, ?, ?, ?, 'in_progress', ?)
2943
+ `).run(
2944
+ grant.idempotency_key,
2945
+ grant.operation_sha256,
2946
+ grant.confirmation_id,
2947
+ grant.expires_at,
2948
+ now.toISOString()
2949
+ );
2950
+ this.db.exec("COMMIT");
2951
+ return { kind: "execute" };
2952
+ } catch (error) {
2953
+ try {
2954
+ this.db.exec("ROLLBACK");
2955
+ } catch {
2956
+ }
2957
+ throw error;
2958
+ }
2959
+ }
2960
+ complete(grant, now) {
2961
+ const result = this.db.prepare(`
2962
+ UPDATE side_effect_claims SET state = 'completed', completed_at = ?
2963
+ WHERE idempotency_key = ? AND operation_sha256 = ? AND confirmation_id = ?
2964
+ AND state = 'in_progress'
2965
+ `).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2966
+ if (result.changes !== 1) throw new Error("side-effect claim could not be completed");
2967
+ }
2968
+ /** Content-free inventory for an operator investigating an interrupted effect. */
2969
+ listAmbiguous() {
2970
+ return this.db.prepare(`
2971
+ SELECT idempotency_key, operation_sha256, confirmation_id, expires_at, created_at
2972
+ FROM side_effect_claims WHERE state='in_progress' ORDER BY created_at, idempotency_key
2973
+ `).all();
2974
+ }
2975
+ /**
2976
+ * Record the operator's external reconciliation result. `completed` prevents
2977
+ * replay; `not_applied` removes the pre-effect claim so the exact confirmed
2978
+ * operation may be retried. Every binding field must match the durable row.
2979
+ */
2980
+ reconcile(grant, outcome, now) {
2981
+ this.db.exec("BEGIN IMMEDIATE");
2982
+ try {
2983
+ const row = this.db.prepare(`
2984
+ SELECT state FROM side_effect_claims
2985
+ WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=?
2986
+ `).get(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2987
+ if (!row) throw new Error("ambiguous side-effect claim not found or binding mismatch");
2988
+ if (row.state !== "in_progress") throw new Error("side-effect claim is already completed");
2989
+ if (outcome === "completed") {
2990
+ this.db.prepare(`
2991
+ UPDATE side_effect_claims SET state='completed', completed_at=?
2992
+ WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
2993
+ `).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2994
+ } else {
2995
+ this.db.prepare(`
2996
+ DELETE FROM side_effect_claims
2997
+ WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
2998
+ `).run(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2999
+ }
3000
+ this.db.exec("COMMIT");
3001
+ } catch (error) {
3002
+ try {
3003
+ this.db.exec("ROLLBACK");
3004
+ } catch {
3005
+ }
3006
+ throw error;
3007
+ }
3008
+ }
3009
+ close() {
3010
+ this.db.close();
3011
+ }
3012
+ };
3013
+
1470
3014
  // src/skill-invoker.ts
1471
3015
  var SkillInvoker = class {
1472
- skills = /* @__PURE__ */ new Map();
1473
- sources = /* @__PURE__ */ new Map();
1474
- skillsDir;
1475
- ctx;
1476
3016
  /**
1477
3017
  * `ctx` carries everything a built-in may touch: the network, DNS, the clock
1478
3018
  * and the key files. A caller that has no runtime (the loader tests, and any
@@ -1480,10 +3020,15 @@ var SkillInvoker = class {
1480
3020
  * that need nothing still run and the ones that need the network fail as a
1481
3021
  * tool_failed result instead of throwing.
1482
3022
  */
1483
- constructor(skillsDir, ctx) {
3023
+ constructor(skillsDir, ctx, sideEffectLedger) {
3024
+ this.sideEffectLedger = sideEffectLedger;
1484
3025
  this.skillsDir = skillsDir;
1485
3026
  this.ctx = ctx ?? offlineContext();
1486
3027
  }
3028
+ skills = /* @__PURE__ */ new Map();
3029
+ sources = /* @__PURE__ */ new Map();
3030
+ skillsDir;
3031
+ ctx;
1487
3032
  /**
1488
3033
  * Load all skill manifests from the skills directory. Supports two
1489
3034
  * layouts: flat `<skillsDir>/*.json` files, and one level of nesting via
@@ -1491,7 +3036,7 @@ var SkillInvoker = class {
1491
3036
  */
1492
3037
  loadSkills() {
1493
3038
  if (!existsSync3(this.skillsDir)) {
1494
- console.warn(`[SkillInvoker] Skills directory not found: ${this.skillsDir}`);
3039
+ writeServiceLog("warn", "skills.directory_missing");
1495
3040
  return;
1496
3041
  }
1497
3042
  const flat = [], manifests = [], skillMds = [];
@@ -1500,47 +3045,45 @@ var SkillInvoker = class {
1500
3045
  flat.push(join4(this.skillsDir, entry.name));
1501
3046
  } else if (entry.isDirectory()) {
1502
3047
  const m = join4(this.skillsDir, entry.name, "manifest.json");
1503
- if (existsSync3(m) && statSync(m).isFile()) manifests.push(m);
3048
+ if (existsSync3(m) && statSync2(m).isFile()) manifests.push(m);
1504
3049
  const s = join4(this.skillsDir, entry.name, "SKILL.md");
1505
- if (existsSync3(s) && statSync(s).isFile()) skillMds.push(s);
3050
+ if (existsSync3(s) && statSync2(s).isFile()) skillMds.push(s);
1506
3051
  }
1507
3052
  }
1508
3053
  for (const p of [...flat.sort(), ...manifests.sort()]) this.loadManifestFile(p);
1509
3054
  for (const p of skillMds.sort()) this.loadSkillMdFile(p);
1510
- console.log(`[SkillInvoker] Loaded ${this.skills.size} skills`);
3055
+ writeServiceLog("info", "skills.loaded", { count: this.skills.size });
1511
3056
  }
1512
3057
  /** Later file wins, with a warning naming both paths (spec §3). */
1513
3058
  register(manifest, path) {
1514
3059
  const prior = this.sources.get(manifest.name);
1515
- if (prior) console.warn(`[SkillInvoker] Duplicate skill "${manifest.name}": ${path} overrides ${prior}`);
3060
+ if (prior) writeServiceLog("warn", "skills.duplicate_replaced");
1516
3061
  this.skills.set(manifest.name, manifest);
1517
3062
  this.sources.set(manifest.name, path);
1518
3063
  }
1519
3064
  loadSkillMdFile(path) {
1520
3065
  try {
1521
- this.register(parseSkillMd(readFileSync4(path, "utf-8"), path), path);
3066
+ this.register(parseSkillMd(readFileSync6(path, "utf-8"), path), path);
1522
3067
  } catch (err) {
1523
- console.warn(`[SkillInvoker] Skipping ${path}: ${err instanceof Error ? err.message : String(err)}`);
3068
+ writeServiceLog("warn", "skills.skill_md_invalid", {}, err);
1524
3069
  }
1525
3070
  }
1526
3071
  loadManifestFile(path) {
1527
3072
  try {
1528
- const raw = readFileSync4(path, "utf-8");
3073
+ const raw = readFileSync6(path, "utf-8");
1529
3074
  const manifest = JSON.parse(raw);
1530
3075
  if (typeof manifest.handler !== "string" || !(manifest.handler.startsWith("builtin:") || manifest.handler.startsWith("script:"))) {
1531
- console.warn(
1532
- `[SkillInvoker] Skipping manifest with invalid handler ${path}: expected "builtin:<name>" or "script:<path>"`
1533
- );
3076
+ writeServiceLog("warn", "skills.manifest_handler_invalid");
1534
3077
  return;
1535
3078
  }
1536
3079
  if (manifest.name && manifest.description) {
1537
3080
  this.register(manifest, path);
1538
3081
  } else {
1539
3082
  const missing = !manifest.name ? "name" : "description";
1540
- console.warn(`[SkillInvoker] Skipping manifest ${path}: missing "${missing}"`);
3083
+ writeServiceLog("warn", "skills.manifest_field_missing", { name_missing: missing === "name" });
1541
3084
  }
1542
3085
  } catch (err) {
1543
- console.warn(`[SkillInvoker] Failed to load skill ${path}:`, err);
3086
+ writeServiceLog("warn", "skills.manifest_load_failed", {}, err);
1544
3087
  }
1545
3088
  }
1546
3089
  /** Convert loaded skills to OpenAI tool definitions for LLM dispatch. */
@@ -1580,27 +3123,76 @@ var SkillInvoker = class {
1580
3123
  async invoke(name, args, context) {
1581
3124
  const skill = this.skills.get(name);
1582
3125
  if (!skill) {
1583
- return JSON.stringify({ error: `Unknown skill: ${name}` });
3126
+ return boundToolResult(JSON.stringify({ error: `Unknown skill: ${name}` }));
3127
+ }
3128
+ const execute = async () => {
3129
+ const handler = skill.handler;
3130
+ if (handler.startsWith("builtin:")) {
3131
+ return this.invokeBuiltin(handler.slice(8), args, context?.signal);
3132
+ }
3133
+ if (handler.startsWith("script:")) {
3134
+ return this.invokeScript(handler.slice(7), args, context?.signal);
3135
+ }
3136
+ return boundToolResult(JSON.stringify({ error: `Unknown handler type: ${handler}` }));
3137
+ };
3138
+ return context?.sideEffect ? this.invokeSideEffect(name, args, context.sideEffect, execute) : execute();
3139
+ }
3140
+ async invokeSideEffect(name, args, grant, execute) {
3141
+ const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
3142
+ if (name !== "http_request" || !(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
3143
+ return toolError("This tool has no supported side-effect replay contract");
3144
+ }
3145
+ if (grant.tool !== name || operationSha256(name, args) !== grant.operation_sha256) {
3146
+ return toolError("Confirmed operation digest does not match this tool call");
3147
+ }
3148
+ const now = this.ctx.now();
3149
+ const expiresAt = Date.parse(grant.expires_at);
3150
+ if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
3151
+ return toolError("Confirmed operation grant is expired or invalid");
3152
+ }
3153
+ if (!this.sideEffectLedger) {
3154
+ return toolError("Side-effect replay protection is unavailable");
3155
+ }
3156
+ let claim;
3157
+ try {
3158
+ claim = this.sideEffectLedger.claim(grant, now);
3159
+ } catch {
3160
+ return toolError("Side-effect replay claim could not be persisted");
3161
+ }
3162
+ if (claim.kind === "completed") return JSON.stringify({ status: "already_applied" });
3163
+ if (claim.kind === "ambiguous") {
3164
+ return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
3165
+ }
3166
+ if (claim.kind === "conflict") {
3167
+ return toolError("Idempotency key was already bound to a different confirmed operation");
3168
+ }
3169
+ if (claim.kind === "full") {
3170
+ return toolError("Side-effect replay ledger is full; reconcile it before running new operations");
1584
3171
  }
1585
- const handler = skill.handler;
1586
- if (handler.startsWith("builtin:")) {
1587
- return this.invokeBuiltin(handler.slice(8), args);
3172
+ let result;
3173
+ try {
3174
+ result = await execute();
3175
+ } catch {
3176
+ return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
1588
3177
  }
1589
- if (handler.startsWith("script:")) {
1590
- return this.invokeScript(handler.slice(7), args);
3178
+ if (isToolFailure(result)) return result;
3179
+ try {
3180
+ this.sideEffectLedger.complete(grant, this.ctx.now());
3181
+ } catch {
3182
+ return toolError("Side-effect completed but replay state is ambiguous; reconcile before retrying");
1591
3183
  }
1592
- return JSON.stringify({ error: `Unknown handler type: ${handler}` });
3184
+ return result;
1593
3185
  }
1594
- async invokeBuiltin(name, args) {
3186
+ async invokeBuiltin(name, args, signal) {
1595
3187
  const builtin = BUILTINS[name];
1596
3188
  if (!builtin) return JSON.stringify({ error: `Unknown builtin: ${name}` });
1597
3189
  try {
1598
- return capResult(await builtin(args, this.ctx));
3190
+ return boundToolResult(await builtin(args, { ...this.ctx, signal }));
1599
3191
  } catch (e) {
1600
3192
  return toolError(e instanceof Error ? e.message : String(e));
1601
3193
  }
1602
3194
  }
1603
- async invokeScript(scriptPath, args) {
3195
+ async invokeScript(scriptPath, args, signal) {
1604
3196
  const { resolve: resolve3, relative } = await import("node:path");
1605
3197
  const resolved = resolve3(this.skillsDir, scriptPath);
1606
3198
  const rel = relative(this.skillsDir, resolved);
@@ -1620,26 +3212,82 @@ var SkillInvoker = class {
1620
3212
  try {
1621
3213
  accessSync2(resolved, constants2.X_OK);
1622
3214
  } catch {
1623
- return JSON.stringify({ error: `Script not found or not executable: ${scriptPath}` });
3215
+ return toolError(`Script not found or not executable: ${scriptPath}`);
1624
3216
  }
1625
- const { execSync } = await import("node:child_process");
1626
- try {
1627
- const result = execSync(resolved, {
1628
- input: JSON.stringify(args),
1629
- timeout: 3e4,
1630
- maxBuffer: 1024 * 1024,
1631
- encoding: "utf-8",
3217
+ if (signal?.aborted) return toolError("Script execution cancelled");
3218
+ const { spawn: spawn2 } = await import("node:child_process");
3219
+ return new Promise((resolveResult) => {
3220
+ const child = spawn2(resolved, [], {
1632
3221
  cwd: this.skillsDir,
1633
- env: { PATH: "/usr/bin:/bin", HOME: "/tmp" }
3222
+ env: { PATH: "/usr/bin:/bin", HOME: "/tmp" },
3223
+ stdio: ["pipe", "pipe", "ignore"],
3224
+ windowsHide: true,
3225
+ // A script may launch children. On POSIX, make one process group so
3226
+ // cancellation kills the whole operation rather than orphaning them.
3227
+ detached: process.platform !== "win32"
1634
3228
  });
1635
- return result;
1636
- } catch (err) {
1637
- return JSON.stringify({
1638
- error: `Script execution failed: ${err instanceof Error ? err.message : String(err)}`
3229
+ let stdout = "";
3230
+ let stdoutBytes = 0;
3231
+ let stopMessage = null;
3232
+ let settled = false;
3233
+ const cleanup = () => {
3234
+ clearTimeout(timer);
3235
+ signal?.removeEventListener("abort", onAbort);
3236
+ };
3237
+ const stop = (message) => {
3238
+ if (settled || stopMessage) return;
3239
+ stopMessage = message;
3240
+ if (process.platform !== "win32" && child.pid) {
3241
+ try {
3242
+ process.kill(-child.pid, "SIGKILL");
3243
+ } catch {
3244
+ child.kill("SIGKILL");
3245
+ }
3246
+ } else {
3247
+ child.kill("SIGKILL");
3248
+ }
3249
+ };
3250
+ const onAbort = () => stop("Script execution cancelled");
3251
+ const timer = setTimeout(() => stop("Script execution timed out after 30000ms"), 3e4);
3252
+ signal?.addEventListener("abort", onAbort, { once: true });
3253
+ child.stdout.setEncoding("utf8");
3254
+ child.stdout.on("data", (chunk) => {
3255
+ if (stopMessage) return;
3256
+ stdoutBytes += Buffer.byteLength(chunk);
3257
+ if (stdoutBytes > 1024 * 1024) {
3258
+ stop("Script output exceeded the materialization limit");
3259
+ return;
3260
+ }
3261
+ stdout += chunk;
1639
3262
  });
1640
- }
3263
+ child.once("error", () => {
3264
+ if (settled) return;
3265
+ settled = true;
3266
+ cleanup();
3267
+ resolveResult(toolError("Script execution failed"));
3268
+ });
3269
+ child.once("close", (code) => {
3270
+ if (settled) return;
3271
+ settled = true;
3272
+ cleanup();
3273
+ if (stopMessage) resolveResult(toolError(stopMessage));
3274
+ else if (code !== 0) resolveResult(toolError(`Script execution failed (code ${code ?? "unknown"})`));
3275
+ else resolveResult(boundToolResult(stdout));
3276
+ });
3277
+ child.stdin.on("error", () => {
3278
+ });
3279
+ child.stdin.end(JSON.stringify(args));
3280
+ });
1641
3281
  }
1642
3282
  };
3283
+ function isToolFailure(result) {
3284
+ try {
3285
+ const parsed = JSON.parse(result);
3286
+ return parsed.error === "tool_failed";
3287
+ } catch {
3288
+ return false;
3289
+ }
3290
+ }
1643
3291
 
1644
3292
  // src/tool-gate.ts
1645
3293
  function createLocalToolGate(policy) {
@@ -1688,7 +3336,7 @@ function createDynamicToolGate(policyFor) {
1688
3336
  }
1689
3337
 
1690
3338
  // src/data-plane.ts
1691
- import { readFileSync as readFileSync5 } from "node:fs";
3339
+ import { readFileSync as readFileSync7 } from "node:fs";
1692
3340
  import { WebSocket } from "node:http";
1693
3341
  import { homedir as homedir3 } from "node:os";
1694
3342
  import { join as join5 } from "node:path";
@@ -1733,7 +3381,7 @@ var NO_TURN_AUTHORIZATION = Object.freeze({
1733
3381
  function parseTurnAuthorization(value) {
1734
3382
  if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_AUTHORIZATION;
1735
3383
  const raw = value;
1736
- if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants)) return NO_TURN_AUTHORIZATION;
3384
+ if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants) || raw.tool_grants.length > 100) return NO_TURN_AUTHORIZATION;
1737
3385
  const grants = [];
1738
3386
  for (const value2 of raw.tool_grants) {
1739
3387
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_AUTHORIZATION;
@@ -1743,7 +3391,7 @@ function parseTurnAuthorization(value) {
1743
3391
  grants.push(Object.freeze({ tool: grant.tool, mode: "read" }));
1744
3392
  continue;
1745
3393
  }
1746
- if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || typeof grant.expires_at !== "string" || !grant.expires_at) return NO_TURN_AUTHORIZATION;
3394
+ if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || grant.confirmation_id.length > 200 || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || grant.idempotency_key.length > 200 || typeof grant.expires_at !== "string" || !grant.expires_at || grant.expires_at.length > 40) return NO_TURN_AUTHORIZATION;
1747
3395
  grants.push(Object.freeze({
1748
3396
  tool: grant.tool,
1749
3397
  mode: "side_effect",
@@ -1761,9 +3409,8 @@ function parseTurnAuthorization(value) {
1761
3409
  });
1762
3410
  }
1763
3411
  var SEEN_MAX = 5e3;
1764
- function errText(e) {
1765
- return e instanceof Error ? e.message : String(e);
1766
- }
3412
+ var PENDING_REPLY_MAX = 1e3;
3413
+ var TERMINAL_REPLY_ERRORS = /* @__PURE__ */ new Set(["agent_paused", "send_forbidden"]);
1767
3414
  var SecureComsDataPlane = class {
1768
3415
  identityId;
1769
3416
  connectionPath;
@@ -1773,7 +3420,18 @@ var SecureComsDataPlane = class {
1773
3420
  log;
1774
3421
  sleep;
1775
3422
  random;
3423
+ healthTimeoutMs;
1776
3424
  onMessage;
3425
+ noticeReplies;
3426
+ /**
3427
+ * Final-wave I1(b): sockets whose daemon advertised the `nack` feature. On
3428
+ * those a failed turn is NACKED — only that message is retried, behind the
3429
+ * daemon's backoff, and the socket (with every other channel's turn) stays
3430
+ * up. Anywhere else a failed turn still closes the socket, as before.
3431
+ */
3432
+ nackCapable = /* @__PURE__ */ new WeakSet();
3433
+ /** Sockets whose hello is unanswered: a refusal there is expected, not an error. */
3434
+ helloPending = /* @__PURE__ */ new WeakSet();
1777
3435
  seen = /* @__PURE__ */ new Set();
1778
3436
  stopped = false;
1779
3437
  running = false;
@@ -1800,6 +3458,21 @@ var SecureComsDataPlane = class {
1800
3458
  * generation is current, so a restart can never end up with two live loops.
1801
3459
  */
1802
3460
  generation = 0;
3461
+ /** Turn controllers grouped by the socket whose result would receive them. */
3462
+ turns = /* @__PURE__ */ new Map();
3463
+ /**
3464
+ * Phase 3 F2: per-channel processing chains. A channel's messages are
3465
+ * handled strictly one after another in arrival order (the receipt lookup,
3466
+ * the turn and the reply), never concurrently — even when a daemon flushes a
3467
+ * whole backlog at once. Different channels still run alongside each other.
3468
+ */
3469
+ channelChains = /* @__PURE__ */ new Map();
3470
+ healthController = null;
3471
+ clock;
3472
+ /** Arrival counter behind InboundMessage.seq. */
3473
+ seq = 0;
3474
+ /** in_reply_to → timing of a reply handed to the daemon, until its `sent` frame. */
3475
+ pendingReplies = /* @__PURE__ */ new Map();
1803
3476
  constructor(cfg, onMessage) {
1804
3477
  this.identityId = cfg.identityId;
1805
3478
  const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
@@ -1807,18 +3480,28 @@ var SecureComsDataPlane = class {
1807
3480
  this.establishedMs = cfg.establishedMs ?? 5e3;
1808
3481
  this.initialDelayMs = cfg.initialDelayMs ?? 250;
1809
3482
  this.maxDelayMs = cfg.maxDelayMs ?? 3e4;
1810
- this.log = cfg.log ?? ((level, msg) => {
1811
- if (level === "info") console.log(msg);
1812
- else console.error(msg);
1813
- });
1814
- this.sleep = cfg.sleep ?? ((ms) => new Promise((resolve3) => {
3483
+ this.log = cfg.log ?? writeServiceLog;
3484
+ this.sleep = cfg.sleep ?? ((ms2) => new Promise((resolve3) => {
1815
3485
  this.sleepTimer = setTimeout(() => {
1816
3486
  this.sleepTimer = null;
1817
3487
  resolve3();
1818
- }, ms);
3488
+ }, ms2);
1819
3489
  }));
1820
3490
  this.random = cfg.random ?? Math.random;
3491
+ this.healthTimeoutMs = cfg.healthTimeoutMs ?? 2e3;
1821
3492
  this.onMessage = onMessage;
3493
+ this.noticeReplies = cfg.noticeReplies ?? /* @__PURE__ */ new Set();
3494
+ this.clock = cfg.clock ?? (() => performance.now());
3495
+ }
3496
+ elapsed(from, to = this.clock()) {
3497
+ return Math.max(0, Math.round(to - from));
3498
+ }
3499
+ trackReply(messageId, reply) {
3500
+ if (this.pendingReplies.size >= PENDING_REPLY_MAX) {
3501
+ const oldest = this.pendingReplies.keys().next();
3502
+ if (!oldest.done) this.pendingReplies.delete(oldest.value);
3503
+ }
3504
+ this.pendingReplies.set(messageId, reply);
1822
3505
  }
1823
3506
  /**
1824
3507
  * Resolves once the reconnect loop is running — NOT once attached. The loop
@@ -1837,13 +3520,19 @@ var SecureComsDataPlane = class {
1837
3520
  this.generation++;
1838
3521
  this.clearSettle();
1839
3522
  this.cancelSleep();
3523
+ this.healthController?.abort(new Error("transport stopped"));
3524
+ this.healthController = null;
3525
+ for (const controllers of this.turns.values()) {
3526
+ for (const controller of controllers) controller.abort(new Error("transport stopped"));
3527
+ }
3528
+ this.turns.clear();
1840
3529
  const ws = this.socket;
1841
3530
  this.socket = null;
1842
3531
  if (ws) {
1843
3532
  try {
1844
3533
  ws.close();
1845
3534
  } catch (e) {
1846
- this.log("warn", `error closing socket: ${errText(e)}`);
3535
+ this.log("warn", "transport.socket_close_failed", {}, e);
1847
3536
  }
1848
3537
  }
1849
3538
  }
@@ -1862,7 +3551,7 @@ var SecureComsDataPlane = class {
1862
3551
  for (const release of this.sleepWaiters.splice(0)) release();
1863
3552
  }
1864
3553
  /** The backoff sleep, made interruptible by stop(). */
1865
- wait(ms) {
3554
+ wait(ms2) {
1866
3555
  if (this.stopped) return Promise.resolve();
1867
3556
  return new Promise((resolve3) => {
1868
3557
  let settled = false;
@@ -1873,7 +3562,7 @@ var SecureComsDataPlane = class {
1873
3562
  resolve3();
1874
3563
  };
1875
3564
  this.sleepWaiters.push(finish);
1876
- void this.sleep(ms).then(finish, finish);
3565
+ void this.sleep(ms2).then(finish, finish);
1877
3566
  });
1878
3567
  }
1879
3568
  clearSettle() {
@@ -1897,7 +3586,7 @@ var SecureComsDataPlane = class {
1897
3586
  await this.attemptOnce();
1898
3587
  delay = this.initialDelayMs;
1899
3588
  } catch (e) {
1900
- this.log("error", errText(e));
3589
+ this.log("error", "transport.connection_attempt_failed", {}, e);
1901
3590
  }
1902
3591
  if (!live()) break;
1903
3592
  await this.wait(this.random() * delay);
@@ -1906,10 +3595,22 @@ var SecureComsDataPlane = class {
1906
3595
  if (this.generation === generation) this.running = false;
1907
3596
  }
1908
3597
  async attemptOnce() {
1909
- const conn = JSON.parse(readFileSync5(this.connectionPath, "utf8"));
3598
+ const conn = JSON.parse(readFileSync7(this.connectionPath, "utf8"));
1910
3599
  const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
1911
- const token = readFileSync5(conn.token_file, "utf8").trim();
1912
- const health = await (await fetch(`${httpBase}/healthz`)).json();
3600
+ const token = readFileSync7(conn.token_file, "utf8").trim();
3601
+ const healthController = new AbortController();
3602
+ this.healthController = healthController;
3603
+ const healthTimer = setTimeout(
3604
+ () => healthController.abort(new Error("health gate deadline exceeded")),
3605
+ this.healthTimeoutMs
3606
+ );
3607
+ let health;
3608
+ try {
3609
+ health = await (await fetch(`${httpBase}/healthz`, { signal: healthController.signal })).json();
3610
+ } finally {
3611
+ clearTimeout(healthTimer);
3612
+ if (this.healthController === healthController) this.healthController = null;
3613
+ }
1913
3614
  if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
1914
3615
  throw new Error(
1915
3616
  `gate refused: expected ${this.identityId}, connection file says ${conn.identity_id}, daemon on this port is ${health.identity_id}`
@@ -1923,7 +3624,7 @@ var SecureComsDataPlane = class {
1923
3624
  try {
1924
3625
  await new Promise((resolve3, reject) => {
1925
3626
  ws.addEventListener("error", (e) => {
1926
- reject(new Error(`socket error: ${errText(e?.message ?? e)}`));
3627
+ reject(Object.assign(new Error("socket_error"), { cause: e }));
1927
3628
  });
1928
3629
  ws.addEventListener("close", (e) => {
1929
3630
  if (established) resolve3();
@@ -1936,19 +3637,29 @@ var SecureComsDataPlane = class {
1936
3637
  }
1937
3638
  });
1938
3639
  ws.addEventListener("open", () => {
3640
+ this.helloPending.add(ws);
3641
+ try {
3642
+ ws.send(JSON.stringify({ type: "hello", features: ["nack"] }));
3643
+ } catch {
3644
+ }
1939
3645
  settle = setTimeout(() => {
1940
3646
  established = true;
1941
- this.log("info", `attached to ${conn.bridge_url}`);
3647
+ this.log("info", "transport.attached");
1942
3648
  }, this.establishedMs);
1943
3649
  this.settle = settle;
1944
3650
  });
1945
3651
  ws.addEventListener("message", (ev) => {
1946
- void this.handleFrame(ws, ev.data).catch((e) => {
1947
- this.log("error", `frame handling failed: ${errText(e)}`);
3652
+ void this.handleFrame(ws, ev.data, httpBase, token).catch((e) => {
3653
+ this.log("error", "transport.frame_handling_failed", {}, e);
1948
3654
  });
1949
3655
  });
1950
3656
  });
1951
3657
  } finally {
3658
+ const controllers = this.turns.get(ws);
3659
+ if (controllers) {
3660
+ for (const controller of controllers) controller.abort(new Error("transport disconnected"));
3661
+ this.turns.delete(ws);
3662
+ }
1952
3663
  if (settle !== null) clearTimeout(settle);
1953
3664
  if (this.settle === settle) this.settle = null;
1954
3665
  try {
@@ -1958,53 +3669,175 @@ var SecureComsDataPlane = class {
1958
3669
  if (this.socket === ws) this.socket = null;
1959
3670
  }
1960
3671
  }
1961
- async handleFrame(ws, data) {
3672
+ /**
3673
+ * Final-wave I1(b): fail ONE message. Against a nack-capable daemon, send a
3674
+ * `nack` — only this message is retried and the socket stays up for every
3675
+ * other channel's turn. Otherwise close the socket, the only failure
3676
+ * signal an older daemon understands (it re-buffers what was in flight).
3677
+ */
3678
+ failTurn(ws, messageId, reason) {
3679
+ if (this.nackCapable.has(ws) && ws.readyState === ws.OPEN) {
3680
+ try {
3681
+ ws.send(JSON.stringify({ type: "nack", message_id: messageId, reason }));
3682
+ return;
3683
+ } catch {
3684
+ }
3685
+ }
3686
+ try {
3687
+ ws.close();
3688
+ } catch {
3689
+ }
3690
+ }
3691
+ async handleFrame(ws, data, httpBase, token) {
1962
3692
  let frame;
1963
3693
  try {
1964
3694
  frame = JSON.parse(String(data));
1965
3695
  } catch (e) {
1966
- this.log("error", `ignoring unparseable frame: ${errText(e)}`);
3696
+ this.log("error", "transport.frame_parse_failed", {}, e);
3697
+ return;
3698
+ }
3699
+ if (frame?.type === "hello") {
3700
+ this.helloPending.delete(ws);
3701
+ if (Array.isArray(frame.features) && frame.features.includes("nack")) this.nackCapable.add(ws);
1967
3702
  return;
1968
3703
  }
1969
3704
  if (frame?.type === "error") {
1970
- this.log("error", `daemon error frame: ${String(frame.error)}: ${String(frame.message)}`);
3705
+ if (frame.in_reply_to === void 0 && this.helloPending.has(ws)) {
3706
+ this.helloPending.delete(ws);
3707
+ this.log("info", "transport.daemon_without_nack");
3708
+ return;
3709
+ }
3710
+ if (typeof frame.in_reply_to === "string") this.pendingReplies.delete(frame.in_reply_to);
3711
+ if (typeof frame.in_reply_to === "string" && TERMINAL_REPLY_ERRORS.has(String(frame.error))) {
3712
+ this.log("warn", "transport.reply_terminal", { paused: frame.error === "agent_paused" });
3713
+ this.remember(frame.in_reply_to);
3714
+ ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
3715
+ return;
3716
+ }
3717
+ this.log("error", "transport.daemon_error_frame");
3718
+ if (typeof frame.in_reply_to === "string") this.failTurn(ws, frame.in_reply_to, "reply_failed");
3719
+ return;
3720
+ }
3721
+ if (frame?.type === "sent" && typeof frame.in_reply_to === "string") {
3722
+ const pending = this.pendingReplies.get(frame.in_reply_to);
3723
+ if (pending) {
3724
+ this.pendingReplies.delete(frame.in_reply_to);
3725
+ const now = this.clock();
3726
+ this.log("info", "transport.reply_published", {
3727
+ seq: pending.seq,
3728
+ publish_ms: this.elapsed(pending.sentAt, now),
3729
+ total_ms: this.elapsed(pending.arrived, now)
3730
+ });
3731
+ }
3732
+ this.remember(frame.in_reply_to);
3733
+ ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
1971
3734
  return;
1972
3735
  }
1973
3736
  if (frame?.type !== "message") return;
1974
3737
  const messageId = frame.message_id;
1975
3738
  if (typeof messageId !== "string") {
1976
- this.log("error", "ignoring message frame with no message_id");
3739
+ this.log("error", "transport.message_id_missing");
1977
3740
  return;
1978
3741
  }
1979
3742
  const channelId = String(frame.channel_id);
3743
+ const arrival = { seq: ++this.seq, at: this.clock() };
3744
+ const previous = this.channelChains.get(channelId) ?? Promise.resolve();
3745
+ const run = () => this.handleMessage(ws, frame, messageId, channelId, httpBase, token, arrival);
3746
+ const mine = previous.then(run, run);
3747
+ this.channelChains.set(channelId, mine);
3748
+ try {
3749
+ await mine;
3750
+ } finally {
3751
+ if (this.channelChains.get(channelId) === mine) this.channelChains.delete(channelId);
3752
+ }
3753
+ }
3754
+ async handleMessage(ws, frame, messageId, channelId, httpBase, token, arrival) {
3755
+ if (ws.readyState !== ws.OPEN) return;
3756
+ let turnController = null;
3757
+ const startedAt = this.clock();
3758
+ let receiptMs = 0;
3759
+ let handlerMs = 0;
3760
+ let sendMs = 0;
3761
+ let outcome = "failed";
1980
3762
  try {
1981
3763
  if (!this.seen.has(messageId)) {
1982
- this.remember(messageId);
3764
+ const receiptStart = this.clock();
3765
+ const receipt = await fetch(
3766
+ `${httpBase}/v1/reply-receipt?in_reply_to=${encodeURIComponent(messageId)}`,
3767
+ {
3768
+ headers: { authorization: `Bearer ${token}` },
3769
+ signal: AbortSignal.timeout(this.healthTimeoutMs)
3770
+ }
3771
+ );
3772
+ if (!receipt.ok) throw new Error(`reply receipt lookup failed: HTTP ${receipt.status}`);
3773
+ const found = (await receipt.json()).found === true;
3774
+ receiptMs = this.elapsed(receiptStart);
3775
+ if (found) {
3776
+ outcome = "receipt_found";
3777
+ this.remember(messageId);
3778
+ ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
3779
+ return;
3780
+ }
3781
+ turnController = new AbortController();
3782
+ let socketTurns = this.turns.get(ws);
3783
+ if (!socketTurns) {
3784
+ socketTurns = /* @__PURE__ */ new Set();
3785
+ this.turns.set(ws, socketTurns);
3786
+ }
3787
+ socketTurns.add(turnController);
3788
+ const handlerStart = this.clock();
1983
3789
  const reply = await this.onMessage({
1984
3790
  text: String(frame.text ?? ""),
1985
3791
  senderId: String(frame.sender_id ?? ""),
1986
3792
  channelId,
1987
3793
  messageId,
1988
3794
  authorization: parseTurnAuthorization(frame.authorization),
1989
- memory: parseTurnMemory(frame.memory)
1990
- });
3795
+ memory: parseTurnMemory(frame.memory),
3796
+ seq: arrival.seq
3797
+ }, turnController.signal);
3798
+ handlerMs = this.elapsed(handlerStart);
1991
3799
  if (reply != null) {
3800
+ const text = String(reply);
3801
+ const sendStart = this.clock();
1992
3802
  ws.send(JSON.stringify({
1993
3803
  type: "send",
1994
3804
  channel_id: channelId,
1995
- text: String(reply),
1996
- in_reply_to: messageId
3805
+ text,
3806
+ in_reply_to: messageId,
3807
+ ...this.noticeReplies.has(text) ? { notice: true } : {}
1997
3808
  }));
3809
+ const sentAt = this.clock();
3810
+ sendMs = this.elapsed(sendStart, sentAt);
3811
+ outcome = "replied";
3812
+ this.trackReply(messageId, { seq: arrival.seq, arrived: arrival.at, sentAt });
3813
+ return;
1998
3814
  }
3815
+ outcome = "no_reply";
3816
+ this.remember(messageId);
3817
+ ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
3818
+ } else {
3819
+ outcome = "duplicate";
3820
+ ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
1999
3821
  }
2000
3822
  } catch (e) {
2001
- this.log("error", `handler threw: ${errText(e)}`);
3823
+ this.log("error", "transport.handler_failed", {}, e);
3824
+ this.failTurn(ws, messageId, "turn_failed");
2002
3825
  } finally {
2003
- try {
2004
- ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
2005
- } catch (e) {
2006
- this.log("error", `could not ack ${messageId}: ${errText(e)}`);
3826
+ if (turnController) {
3827
+ const socketTurns = this.turns.get(ws);
3828
+ socketTurns?.delete(turnController);
3829
+ if (socketTurns?.size === 0) this.turns.delete(ws);
2007
3830
  }
3831
+ const end = this.clock();
3832
+ this.log("info", "transport.message_handled", {
3833
+ seq: arrival.seq,
3834
+ chain_wait_ms: this.elapsed(arrival.at, startedAt),
3835
+ receipt_ms: receiptMs,
3836
+ handler_ms: handlerMs,
3837
+ send_ms: sendMs,
3838
+ total_ms: this.elapsed(arrival.at, end),
3839
+ outcome: logLabel(outcome)
3840
+ });
2008
3841
  }
2009
3842
  }
2010
3843
  };
@@ -2013,9 +3846,22 @@ function createSecureComsDataPlane(cfg, onMessage) {
2013
3846
  }
2014
3847
 
2015
3848
  // src/tool-runner.ts
3849
+ var ERROR_ENVELOPE = /^\{\s*"error"\s*:/;
2016
3850
  async function runToolCalls(toolCalls, skills, context) {
2017
3851
  const out = [];
3852
+ const clock = context?.clock ?? (() => performance.now());
2018
3853
  for (const call of toolCalls) {
3854
+ if (context?.signal?.aborted) {
3855
+ out.push({
3856
+ role: "tool",
3857
+ content: toolError("Tool execution cancelled"),
3858
+ tool_call_id: call.id
3859
+ });
3860
+ context.onTiming?.({ tool_call_id: call.id, ms: 0, ok: false, cancelled: true });
3861
+ continue;
3862
+ }
3863
+ const started = clock();
3864
+ let threw = false;
2019
3865
  let args;
2020
3866
  try {
2021
3867
  args = JSON.parse(call.function.arguments);
@@ -2024,13 +3870,22 @@ async function runToolCalls(toolCalls, skills, context) {
2024
3870
  }
2025
3871
  let content;
2026
3872
  try {
2027
- content = await skills.invoke(call.function.name, args, context);
3873
+ content = boundToolResult(await skills.invoke(call.function.name, args, {
3874
+ roomId: context?.roomId,
3875
+ groupId: context?.groupId,
3876
+ signal: context?.signal,
3877
+ sideEffect: context?.sideEffects?.get(call.id)
3878
+ }));
2028
3879
  } catch (err) {
2029
- content = JSON.stringify({
2030
- error: "tool_failed",
2031
- message: String(err instanceof Error ? err.message : err)
2032
- });
3880
+ threw = true;
3881
+ content = toolError(String(err instanceof Error ? err.message : err));
2033
3882
  }
3883
+ context?.onTiming?.({
3884
+ tool_call_id: call.id,
3885
+ ms: clock() - started,
3886
+ ok: !threw && !ERROR_ENVELOPE.test(content),
3887
+ cancelled: false
3888
+ });
2034
3889
  out.push({ role: "tool", content, tool_call_id: call.id });
2035
3890
  }
2036
3891
  return out;
@@ -2039,14 +3894,56 @@ async function runToolCalls(toolCalls, skills, context) {
2039
3894
  // src/turn-loop.ts
2040
3895
  var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
2041
3896
  var DEFAULT_MAX_ITERATIONS = 5;
3897
+ var DEFAULT_MAX_CONCURRENT_TURNS = 4;
3898
+ var DEFAULT_TURN_TIMEOUT_MS = 3e5;
3899
+ var DAILY_LIMIT_RESPONSE = "This agent has reached its daily usage limit and will respond again after 00:00 UTC.";
3900
+ var BUDGET_UNAVAILABLE_RESPONSE = "This agent cannot use its language model right now because of a problem on its host. Its owner can find the details in the runtime log.";
3901
+ function isBudgetUnavailable(error) {
3902
+ return error instanceof RequestExceedsDailyTokenCapError || error instanceof BudgetLockHeldError || error instanceof BudgetStateWriteError || error instanceof BudgetStateReadError;
3903
+ }
3904
+ var MESSAGE_TOO_LARGE_RESPONSE = "This message is too large for this agent to process. Please send a shorter message.";
3905
+ var TURN_TOO_LARGE_RESPONSE = "This agent could not finish this request within its size limits.";
3906
+ var MAX_TOOL_CALLS_PER_RESPONSE = 8;
3907
+ var MAX_TOOL_CALLS_PER_TURN = 16;
3908
+ var MAX_TOOL_RESULT_BYTES_PER_TURN = 64 * 1024;
3909
+ var TOOL_RESULT_HEADROOM_BYTES = 12 * 1024;
3910
+ var DEFAULT_TURN_ABANDON_GRACE_MS = 1e4;
3911
+ var REQUEST_ENVELOPE_RESERVE_BYTES = 1024;
3912
+ var MIN_TRUNCATED_TOOL_RESULT_BYTES = 1024;
3913
+ var RESULT_BUDGET_EXCEEDED = JSON.stringify({ error: "tool_result_budget_exceeded" });
3914
+ function jsonBytes(value) {
3915
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
3916
+ }
3917
+ function messageBytes(message) {
3918
+ return jsonBytes(message) + 1;
3919
+ }
3920
+ var PromptTooLargeError = class extends Error {
3921
+ constructor(initial, sizes = {}) {
3922
+ super("prompt exceeds the request size budget");
3923
+ this.initial = initial;
3924
+ this.sizes = sizes;
3925
+ }
3926
+ };
3927
+ var TurnAbandonedError = class extends Error {
3928
+ constructor() {
3929
+ super("Agent turn abandoned");
3930
+ }
3931
+ };
2042
3932
  var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["echo", "web_search", "fetch_url", "sql_query", "current_time"]);
2043
3933
  function hasReadGrant(auth, toolName) {
2044
3934
  return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "read");
2045
3935
  }
3936
+ function hasSideEffectGrant(auth, toolName) {
3937
+ return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "side_effect");
3938
+ }
2046
3939
  function toolIsOffered(auth, toolName) {
2047
- return hasReadGrant(auth, toolName) && (READ_ONLY_TOOLS.has(toolName) || toolName === "http_request");
3940
+ if (READ_ONLY_TOOLS.has(toolName)) return hasReadGrant(auth, toolName);
3941
+ if (toolName === "http_request") {
3942
+ return hasReadGrant(auth, toolName) || hasSideEffectGrant(auth, toolName);
3943
+ }
3944
+ return false;
2048
3945
  }
2049
- function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
3946
+ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames, now = /* @__PURE__ */ new Date()) {
2050
3947
  if (auth.sender_membership !== "active") {
2051
3948
  return { allowed: false, reason: "Sender membership is not active" };
2052
3949
  }
@@ -2057,8 +3954,26 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
2057
3954
  try {
2058
3955
  const args = JSON.parse(argumentsJson);
2059
3956
  const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
2060
- if (method !== "GET") {
2061
- return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
3957
+ if (method === "GET") {
3958
+ if (!hasReadGrant(auth, toolName)) {
3959
+ return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
3960
+ }
3961
+ } else {
3962
+ if (!(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
3963
+ return { allowed: false, reason: `HTTP method "${method}" is not supported` };
3964
+ }
3965
+ const digest = operationSha256(toolName, args);
3966
+ const grant = auth.tool_grants.find(
3967
+ (candidate) => candidate.mode === "side_effect" && candidate.tool === toolName && candidate.operation_sha256 === digest
3968
+ );
3969
+ if (!grant) {
3970
+ return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
3971
+ }
3972
+ const expiresAt = Date.parse(grant.expires_at);
3973
+ if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
3974
+ return { allowed: false, reason: "The confirmed operation grant is expired or invalid" };
3975
+ }
3976
+ return { allowed: true, reason: `Tool "${toolName}" has an exact confirmed operation grant`, sideEffect: grant };
2062
3977
  }
2063
3978
  } catch {
2064
3979
  return { allowed: false, reason: "Tool arguments are not valid JSON" };
@@ -2066,99 +3981,672 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
2066
3981
  }
2067
3982
  return { allowed: true, reason: `Tool "${toolName}" has an active channel read grant` };
2068
3983
  }
3984
+ var FINISH_REASONS = /* @__PURE__ */ new Set([
3985
+ "stop",
3986
+ "tool_calls",
3987
+ "function_call",
3988
+ "length",
3989
+ "content_filter",
3990
+ "end_turn",
3991
+ "tool_use",
3992
+ "max_tokens",
3993
+ "stop_sequence"
3994
+ ]);
3995
+ function finishReasonLabel(reason) {
3996
+ if (typeof reason !== "string" || reason === "") return logLabel("none");
3997
+ return logLabel(FINISH_REASONS.has(reason) ? reason : "other");
3998
+ }
3999
+ function ms(from, to) {
4000
+ return Math.max(0, Math.round(to - from));
4001
+ }
2069
4002
  function buildHandler(deps) {
2070
4003
  const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
4004
+ const maxConcurrentTurns = deps.maxConcurrentTurns ?? DEFAULT_MAX_CONCURRENT_TURNS;
4005
+ const turnTimeoutMs = deps.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
4006
+ const abandonGraceMs = deps.turnAbandonGraceMs ?? DEFAULT_TURN_ABANDON_GRACE_MS;
4007
+ const requestMaxBytes = deps.requestMaxBytes ?? LLM_REQUEST_MAX_BYTES;
2071
4008
  const log = deps.log ?? (() => {
2072
4009
  });
4010
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
4011
+ const clock = deps.clock ?? (() => performance.now());
2073
4012
  const { llm, memoryFor, skills, gate } = deps;
2074
- return async (m) => {
4013
+ let localSeq = 0;
4014
+ const channelTails = /* @__PURE__ */ new Map();
4015
+ const slotWaiters = [];
4016
+ let activeTurns = 0;
4017
+ let abandonedUnsettled = 0;
4018
+ let wedged = false;
4019
+ let retiring = null;
4020
+ let onIdle = null;
4021
+ const limitNotified = /* @__PURE__ */ new Map();
4022
+ function cancelledError() {
4023
+ return new Error("Agent turn cancelled");
4024
+ }
4025
+ function wedgedError() {
4026
+ return new Error("Agent runtime wedged: too many abandoned turns are still running");
4027
+ }
4028
+ function waitUnlessAborted(gate2, signal) {
4029
+ if (!signal) return gate2;
4030
+ if (signal.aborted) return Promise.reject(cancelledError());
4031
+ return new Promise((resolve3, reject) => {
4032
+ const onAbort = () => reject(cancelledError());
4033
+ signal.addEventListener("abort", onAbort, { once: true });
4034
+ void gate2.then(() => {
4035
+ signal.removeEventListener("abort", onAbort);
4036
+ resolve3();
4037
+ });
4038
+ });
4039
+ }
4040
+ async function lockChannel(channelId, signal) {
4041
+ const previous = channelTails.get(channelId) ?? Promise.resolve();
4042
+ let release;
4043
+ const mine = new Promise((resolve3) => {
4044
+ release = resolve3;
4045
+ });
4046
+ const tail = previous.then(() => mine);
4047
+ channelTails.set(channelId, tail);
4048
+ let released = false;
4049
+ const done = () => {
4050
+ if (released) return;
4051
+ released = true;
4052
+ release();
4053
+ if (channelTails.get(channelId) === tail) channelTails.delete(channelId);
4054
+ };
4055
+ try {
4056
+ await waitUnlessAborted(previous, signal);
4057
+ } catch (error) {
4058
+ done();
4059
+ throw error;
4060
+ }
4061
+ return done;
4062
+ }
4063
+ function acquireSlot(signal) {
4064
+ let released = false;
4065
+ const release = () => {
4066
+ if (released) return;
4067
+ released = true;
4068
+ activeTurns--;
4069
+ slotWaiters.shift()?.();
4070
+ if (activeTurns === 0) onIdle?.();
4071
+ };
4072
+ if (activeTurns < maxConcurrentTurns) {
4073
+ activeTurns++;
4074
+ return Promise.resolve(release);
4075
+ }
4076
+ if (signal?.aborted) return Promise.reject(cancelledError());
4077
+ return new Promise((resolve3, reject) => {
4078
+ const grant = () => {
4079
+ signal?.removeEventListener("abort", onAbort);
4080
+ activeTurns++;
4081
+ resolve3(release);
4082
+ };
4083
+ const onAbort = () => {
4084
+ const i = slotWaiters.indexOf(grant);
4085
+ if (i >= 0) slotWaiters.splice(i, 1);
4086
+ reject(cancelledError());
4087
+ };
4088
+ signal?.addEventListener("abort", onAbort, { once: true });
4089
+ slotWaiters.push(grant);
4090
+ });
4091
+ }
4092
+ const unavailableNotified = /* @__PURE__ */ new Map();
4093
+ function budgetUnavailableReply(channelId, error) {
4094
+ const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
4095
+ log("warn", "turn.budget_unavailable", {
4096
+ config: error instanceof RequestExceedsDailyTokenCapError,
4097
+ lock: error instanceof BudgetLockHeldError,
4098
+ state_write: error instanceof BudgetStateWriteError,
4099
+ state_read: error instanceof BudgetStateReadError
4100
+ });
4101
+ if (unavailableNotified.get(channelId) === day) return null;
4102
+ for (const [ch, d] of unavailableNotified) if (d !== day) unavailableNotified.delete(ch);
4103
+ unavailableNotified.set(channelId, day);
4104
+ return BUDGET_UNAVAILABLE_RESPONSE;
4105
+ }
4106
+ function dailyLimitReply(channelId, day) {
4107
+ if (limitNotified.get(channelId) === day) return null;
4108
+ for (const [ch, d] of limitNotified) if (d !== day) limitNotified.delete(ch);
4109
+ limitNotified.set(channelId, day);
4110
+ log("warn", "turn.daily_limit_reply");
4111
+ return DAILY_LIMIT_RESPONSE;
4112
+ }
4113
+ function logCompleted(timing) {
4114
+ const end = clock();
4115
+ log("info", "turn.completed", {
4116
+ seq: timing.seq,
4117
+ total_ms: ms(timing.received, end),
4118
+ queue_ms: ms(timing.received, timing.started ?? end),
4119
+ run_ms: timing.started === void 0 ? 0 : ms(timing.started, end),
4120
+ context_ms: timing.contextMs,
4121
+ iterations: timing.iterations,
4122
+ model_ms: timing.modelMs,
4123
+ tool_ms: timing.toolMs,
4124
+ tool_calls: timing.toolCalls,
4125
+ tools_denied: timing.toolsDenied,
4126
+ outcome: logLabel(timing.outcome)
4127
+ });
4128
+ }
4129
+ return async (m, upstreamSignal) => {
2075
4130
  const text = m.text?.trim() ? m.text : "";
2076
4131
  if (!text) return null;
2077
- log(
2078
- "info",
2079
- `message received (sender=${m.senderId.slice(0, 8)}, channel=${m.channelId.slice(0, 8)}, chars=${text.length})`
2080
- );
4132
+ const timing = {
4133
+ seq: typeof m.seq === "number" && Number.isFinite(m.seq) ? m.seq : ++localSeq,
4134
+ received: clock(),
4135
+ contextMs: 0,
4136
+ iterations: 0,
4137
+ modelMs: 0,
4138
+ toolMs: 0,
4139
+ toolCalls: 0,
4140
+ toolsDenied: 0,
4141
+ // Until the turn holds both locks, the only way out is cancellation.
4142
+ outcome: "cancelled"
4143
+ };
4144
+ try {
4145
+ return await queuedTurn(m, upstreamSignal, timing);
4146
+ } finally {
4147
+ logCompleted(timing);
4148
+ }
4149
+ };
4150
+ async function queuedTurn(m, upstreamSignal, timing) {
4151
+ if (retiring) {
4152
+ await retiring;
4153
+ timing.outcome = "error";
4154
+ throw wedgedError();
4155
+ }
4156
+ if (wedged) {
4157
+ timing.outcome = "error";
4158
+ throw wedgedError();
4159
+ }
4160
+ const sameChannel = channelTails.has(m.channelId);
4161
+ const mustWait = sameChannel || activeTurns >= maxConcurrentTurns;
4162
+ if (mustWait) {
4163
+ log("info", "turn.queued", {
4164
+ same_channel: channelTails.has(m.channelId),
4165
+ active_turns: activeTurns
4166
+ });
4167
+ }
4168
+ const releaseChannel = await lockChannel(m.channelId, upstreamSignal);
4169
+ const channelReady = clock();
4170
+ let releaseSlot;
4171
+ try {
4172
+ releaseSlot = await acquireSlot(upstreamSignal);
4173
+ } catch (error) {
4174
+ releaseChannel();
4175
+ throw error;
4176
+ }
4177
+ if (retiring) {
4178
+ releaseSlot();
4179
+ releaseChannel();
4180
+ await retiring;
4181
+ timing.outcome = "error";
4182
+ throw wedgedError();
4183
+ }
4184
+ timing.started = clock();
4185
+ timing.outcome = "error";
4186
+ log("info", "turn.started", {
4187
+ seq: timing.seq,
4188
+ queue_ms: ms(timing.received, timing.started),
4189
+ channel_wait_ms: ms(timing.received, channelReady),
4190
+ slot_wait_ms: ms(channelReady, timing.started),
4191
+ queued: mustWait,
4192
+ same_channel: sameChannel
4193
+ });
4194
+ const controller = new AbortController();
4195
+ let timedOut = false;
4196
+ const cancel = () => controller.abort(upstreamSignal?.reason);
4197
+ if (upstreamSignal?.aborted) cancel();
4198
+ else upstreamSignal?.addEventListener("abort", cancel, { once: true });
4199
+ const timer = setTimeout(() => {
4200
+ timedOut = true;
4201
+ controller.abort(new Error("turn deadline exceeded"));
4202
+ }, turnTimeoutMs);
4203
+ const token = { abandoned: false };
4204
+ let abandonTimer;
4205
+ let turn;
4206
+ const abandonment = new Promise((_resolve, reject) => {
4207
+ const armGrace = () => {
4208
+ abandonTimer = setTimeout(() => {
4209
+ token.abandoned = true;
4210
+ abandonedUnsettled++;
4211
+ const abandonedAt = clock();
4212
+ log("warn", "turn.abandoned", {
4213
+ seq: timing.seq,
4214
+ deadline_ms: turnTimeoutMs,
4215
+ grace_ms: abandonGraceMs,
4216
+ timed_out: timedOut,
4217
+ abandoned_unsettled: abandonedUnsettled
4218
+ });
4219
+ if (abandonedUnsettled > maxConcurrentTurns && !wedged) {
4220
+ wedged = true;
4221
+ log("error", "turn.runtime_wedged", { abandoned_unsettled: abandonedUnsettled });
4222
+ if (deps.onWedged && !retiring) {
4223
+ retiring = new Promise((resolve3) => {
4224
+ onIdle = resolve3;
4225
+ });
4226
+ deps.onWedged(retiring);
4227
+ }
4228
+ }
4229
+ const settled = () => {
4230
+ abandonedUnsettled--;
4231
+ if (wedged && abandonedUnsettled <= maxConcurrentTurns) wedged = false;
4232
+ log("info", "turn.abandoned_settled", {
4233
+ seq: timing.seq,
4234
+ settled_after_ms: ms(abandonedAt, clock()),
4235
+ abandoned_unsettled: abandonedUnsettled
4236
+ });
4237
+ };
4238
+ void turn?.then(settled, settled);
4239
+ reject(new TurnAbandonedError());
4240
+ }, abandonGraceMs);
4241
+ };
4242
+ if (controller.signal.aborted) armGrace();
4243
+ else controller.signal.addEventListener("abort", armGrace, { once: true });
4244
+ });
4245
+ abandonment.catch(() => {
4246
+ });
4247
+ try {
4248
+ turn = runTurn(m, controller.signal, timing, token);
4249
+ turn.catch(() => {
4250
+ });
4251
+ const reply = await Promise.race([turn, abandonment]);
4252
+ if (token.abandoned) throw new TurnAbandonedError();
4253
+ timing.outcome = reply === null ? "no_final_answer" : "answered";
4254
+ unavailableNotified.delete(m.channelId);
4255
+ return reply;
4256
+ } catch (error) {
4257
+ if (error instanceof DailyLimitReachedError) {
4258
+ timing.outcome = "daily_limit";
4259
+ return dailyLimitReply(m.channelId, error.day);
4260
+ }
4261
+ if (isBudgetUnavailable(error)) {
4262
+ timing.outcome = "budget_unavailable";
4263
+ return budgetUnavailableReply(m.channelId, error);
4264
+ }
4265
+ if (error instanceof PromptTooLargeError) {
4266
+ timing.outcome = "too_large";
4267
+ if (error.initial) {
4268
+ log("warn", "turn.message_too_large", { seq: timing.seq, ...error.sizes });
4269
+ return MESSAGE_TOO_LARGE_RESPONSE;
4270
+ }
4271
+ log("warn", "turn.turn_too_large", { seq: timing.seq });
4272
+ return TURN_TOO_LARGE_RESPONSE;
4273
+ }
4274
+ if (timedOut) {
4275
+ timing.outcome = "timeout";
4276
+ throw new Error(`Agent turn timed out after ${turnTimeoutMs}ms`);
4277
+ }
4278
+ if (upstreamSignal?.aborted) {
4279
+ timing.outcome = "cancelled";
4280
+ throw cancelledError();
4281
+ }
4282
+ throw error;
4283
+ } finally {
4284
+ clearTimeout(timer);
4285
+ clearTimeout(abandonTimer);
4286
+ upstreamSignal?.removeEventListener("abort", cancel);
4287
+ releaseSlot();
4288
+ releaseChannel();
4289
+ }
4290
+ }
4291
+ function logModelCall(timing, iteration, elapsed, result) {
4292
+ const usage = result?.usage;
4293
+ log("info", "turn.model_call", {
4294
+ seq: timing.seq,
4295
+ iteration,
4296
+ ms: elapsed,
4297
+ ok: result !== void 0,
4298
+ tool_calls: result?.toolCalls.length ?? 0,
4299
+ ...result?.source ? { provider: logLabel(result.source.provider), model: logLabel(result.source.model) } : {},
4300
+ ...result ? { finish_reason: finishReasonLabel(result.finishReason) } : {},
4301
+ ...usage ? { total_tokens: usage.totalTokens } : {},
4302
+ ...usage?.promptTokens !== void 0 ? { prompt_tokens: usage.promptTokens } : {},
4303
+ ...usage?.completionTokens !== void 0 ? { completion_tokens: usage.completionTokens } : {}
4304
+ });
4305
+ }
4306
+ async function runTurn(m, signal, timing, token) {
4307
+ const text = m.text;
4308
+ const live = () => {
4309
+ if (token.abandoned) throw new TurnAbandonedError();
4310
+ };
4311
+ log("info", "turn.message_received", { chars: text.length });
4312
+ const contextStart = clock();
2081
4313
  const memory = memoryFor(m.channelId);
2082
4314
  memory.replace(m.memory);
2083
- const messages = [{ role: "system", content: deps.systemPromptFor() }];
2084
- const recalled = await memory.recall(text);
2085
- if (recalled) messages.push({ role: "system", content: recalled });
2086
- messages.push(...memory.getHistory());
2087
- messages.push({ role: "user", content: text });
2088
- memory.addMessage({ role: "user", content: text });
4315
+ const promptStart = clock();
4316
+ const system = { role: "system", content: deps.systemPromptFor() };
4317
+ const recallStart = clock();
4318
+ const recalledText = await memory.recall(text);
4319
+ live();
4320
+ const recalled = recalledText ? { role: "system", content: recalledText } : null;
4321
+ const history = memory.getHistory();
4322
+ const user = { role: "user", content: text };
4323
+ const toolsStart = clock();
2089
4324
  const tools = deps.tools().filter((tool) => toolIsOffered(m.authorization, tool.function.name));
2090
4325
  const offeredNames = new Set(tools.map((tool) => tool.function.name));
4326
+ const toolsBytes = tools.length > 0 ? jsonBytes(tools) + 1 : 0;
4327
+ const budgetBytes = requestMaxBytes - REQUEST_ENVELOPE_RESERVE_BYTES - toolsBytes;
4328
+ const historyBytes = history.map(messageBytes);
4329
+ const recalledBytes = recalled ? messageBytes(recalled) : 0;
4330
+ const requiredBytes = messageBytes(system) + messageBytes(user);
4331
+ const headroomBytes = tools.length > 0 ? TOOL_RESULT_HEADROOM_BYTES : 0;
4332
+ if (requiredBytes + headroomBytes > budgetBytes) {
4333
+ throw new PromptTooLargeError(true, {
4334
+ system_bytes: messageBytes(system),
4335
+ tools_bytes: toolsBytes,
4336
+ message_bytes: messageBytes(user),
4337
+ budget_bytes: budgetBytes
4338
+ });
4339
+ }
4340
+ let room = budgetBytes - requiredBytes - headroomBytes;
4341
+ let keepRecalled = recalled !== null && recalledBytes <= room;
4342
+ if (keepRecalled) room -= recalledBytes;
4343
+ let historyStart = history.length;
4344
+ let keptHistoryBytes = 0;
4345
+ while (historyStart > 0 && keptHistoryBytes + historyBytes[historyStart - 1] <= room) {
4346
+ historyStart--;
4347
+ keptHistoryBytes += historyBytes[historyStart];
4348
+ }
4349
+ const dropLeadingAssistant = () => {
4350
+ while (historyStart > 0 && historyStart < history.length && history[historyStart].role === "assistant") {
4351
+ keptHistoryBytes -= historyBytes[historyStart];
4352
+ historyStart++;
4353
+ }
4354
+ };
4355
+ dropLeadingAssistant();
4356
+ const turnMessages = [];
4357
+ let turnMessageBytes = 0;
4358
+ const promptBytes = () => requiredBytes + (keepRecalled ? recalledBytes : 0) + keptHistoryBytes + turnMessageBytes;
4359
+ const logTrim = (iteration) => {
4360
+ log("info", "turn.context_trimmed", {
4361
+ seq: timing.seq,
4362
+ iteration,
4363
+ history_kept: history.length - historyStart,
4364
+ history_dropped: historyStart,
4365
+ recalled_dropped: recalled !== null && !keepRecalled,
4366
+ prompt_bytes: promptBytes(),
4367
+ budget_bytes: budgetBytes
4368
+ });
4369
+ };
4370
+ if (historyStart > 0 || recalled !== null && !keepRecalled) logTrim(0);
4371
+ const refit = (iteration) => {
4372
+ let trimmed = false;
4373
+ while (promptBytes() > budgetBytes) {
4374
+ if (historyStart < history.length) {
4375
+ keptHistoryBytes -= historyBytes[historyStart];
4376
+ historyStart++;
4377
+ } else if (keepRecalled) {
4378
+ keepRecalled = false;
4379
+ } else {
4380
+ throw new PromptTooLargeError(false);
4381
+ }
4382
+ trimmed = true;
4383
+ }
4384
+ if (trimmed) {
4385
+ dropLeadingAssistant();
4386
+ logTrim(iteration);
4387
+ }
4388
+ };
4389
+ const buildMessages = () => [
4390
+ system,
4391
+ ...keepRecalled && recalled ? [recalled] : [],
4392
+ ...history.slice(historyStart),
4393
+ user,
4394
+ ...turnMessages
4395
+ ];
4396
+ const pushTurnMessage = (message) => {
4397
+ turnMessages.push(message);
4398
+ turnMessageBytes += messageBytes(message);
4399
+ };
4400
+ memory.addMessage({ role: "user", content: text });
4401
+ const contextEnd = clock();
4402
+ timing.contextMs = ms(contextStart, contextEnd);
4403
+ log("info", "turn.context_built", {
4404
+ seq: timing.seq,
4405
+ ms: timing.contextMs,
4406
+ memory_ms: ms(contextStart, promptStart),
4407
+ prompt_ms: ms(promptStart, recallStart),
4408
+ recall_ms: ms(recallStart, toolsStart),
4409
+ tools_ms: ms(toolsStart, contextEnd),
4410
+ history_messages: history.length - historyStart,
4411
+ history_dropped: historyStart,
4412
+ recalled: keepRecalled,
4413
+ tools_offered: tools.length,
4414
+ prompt_bytes: promptBytes()
4415
+ });
4416
+ let toolResultBytes = 0;
4417
+ let turnToolCalls = 0;
4418
+ const callModel = async (n, offer) => {
4419
+ const started = clock();
4420
+ let result;
4421
+ try {
4422
+ result = await llm.complete(buildMessages(), offer, { signal });
4423
+ } finally {
4424
+ const modelMs = ms(started, clock());
4425
+ timing.iterations = n;
4426
+ timing.modelMs += modelMs;
4427
+ logModelCall(timing, n, modelMs, result);
4428
+ }
4429
+ live();
4430
+ return result;
4431
+ };
2091
4432
  let response = null;
2092
4433
  for (let iteration = 0; iteration < maxIterations; iteration++) {
2093
- const result = await llm.complete(messages, tools.length > 0 ? tools : void 0);
4434
+ if (iteration > 0) refit(iteration + 1);
4435
+ const iterationStart = clock();
4436
+ const result = await callModel(iteration + 1, tools.length > 0 ? tools : void 0);
2094
4437
  if (result.toolCalls.length === 0) {
2095
4438
  response = result.content;
4439
+ log("info", "turn.iteration", {
4440
+ seq: timing.seq,
4441
+ iteration: iteration + 1,
4442
+ ms: ms(iterationStart, clock()),
4443
+ tool_calls: 0,
4444
+ tools_denied: 0,
4445
+ tool_ms: 0,
4446
+ final: true
4447
+ });
2096
4448
  break;
2097
4449
  }
2098
- messages.push({
4450
+ pushTurnMessage({
2099
4451
  role: "assistant",
2100
4452
  content: result.content ?? "",
2101
4453
  tool_calls: result.toolCalls
2102
4454
  });
2103
- const verdicts = result.toolCalls.map((call) => {
4455
+ if (result.toolCalls.length > MAX_TOOL_CALLS_PER_RESPONSE) {
4456
+ log("warn", "turn.tool_calls_capped", {
4457
+ seq: timing.seq,
4458
+ iteration: iteration + 1,
4459
+ requested: result.toolCalls.length,
4460
+ over_cap: result.toolCalls.length - MAX_TOOL_CALLS_PER_RESPONSE
4461
+ });
4462
+ }
4463
+ const resultBudgetSpent = toolResultBytes >= MAX_TOOL_RESULT_BYTES_PER_TURN;
4464
+ let turnCapDenied = 0;
4465
+ const verdicts = result.toolCalls.map((call, index) => {
4466
+ if (index >= MAX_TOOL_CALLS_PER_RESPONSE) {
4467
+ return { call, verdict: { allowed: false, reason: "too_many_calls: this response asked for more tool calls than one response may run" } };
4468
+ }
4469
+ if (turnToolCalls >= MAX_TOOL_CALLS_PER_TURN) {
4470
+ turnCapDenied++;
4471
+ return { call, verdict: { allowed: false, reason: "too_many_calls: this turn has run as many tool calls as one turn may" } };
4472
+ }
4473
+ if (resultBudgetSpent) {
4474
+ return { call, verdict: { allowed: false, reason: "tool_result_budget_exhausted: this turn's tool-result budget is spent" } };
4475
+ }
2104
4476
  const channelVerdict = authorizeToolExecution(
2105
4477
  m.authorization,
2106
4478
  call.function.name,
2107
4479
  call.function.arguments,
2108
- offeredNames
4480
+ offeredNames,
4481
+ now()
2109
4482
  );
4483
+ const localVerdict = channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict;
4484
+ if (localVerdict.allowed) turnToolCalls++;
2110
4485
  return {
2111
4486
  call,
2112
- verdict: channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict
4487
+ verdict: {
4488
+ ...localVerdict,
4489
+ ...localVerdict.allowed && channelVerdict.sideEffect ? { sideEffect: channelVerdict.sideEffect } : {}
4490
+ }
2113
4491
  };
2114
4492
  });
2115
4493
  const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
4494
+ const sideEffects = new Map(
4495
+ verdicts.flatMap(({ call, verdict }) => verdict.allowed && verdict.sideEffect ? [[call.id, verdict.sideEffect]] : [])
4496
+ );
4497
+ let denied = 0;
2116
4498
  for (const v of verdicts) {
2117
4499
  if (!v.verdict.allowed) {
2118
- log("warn", `tool call denied: ${v.call.function.name} \u2014 ${v.verdict.reason}`);
4500
+ denied++;
4501
+ log("warn", "turn.tool_call_denied");
4502
+ }
4503
+ }
4504
+ if (turnCapDenied > 0) {
4505
+ log("warn", "turn.turn_tool_calls_capped", {
4506
+ seq: timing.seq,
4507
+ iteration: iteration + 1,
4508
+ denied: turnCapDenied
4509
+ });
4510
+ }
4511
+ const deniedContent = (reason) => JSON.stringify({ error: "tool_call_denied", reason });
4512
+ const minimalResultBytes = verdicts.map(({ call, verdict }) => messageBytes({
4513
+ role: "tool",
4514
+ content: verdict.allowed ? RESULT_BUDGET_EXCEEDED : deniedContent(verdict.reason),
4515
+ tool_call_id: call.id
4516
+ }));
4517
+ const minimalTotal = minimalResultBytes.reduce((a, b) => a + b, 0);
4518
+ if (requiredBytes + turnMessageBytes + minimalTotal > budgetBytes) {
4519
+ if (iteration === 0) throw new PromptTooLargeError(false);
4520
+ const unanswerable = turnMessages.pop();
4521
+ turnMessageBytes -= messageBytes(unanswerable);
4522
+ log("warn", "turn.final_without_tools", { seq: timing.seq, iteration: iteration + 1 });
4523
+ let final;
4524
+ try {
4525
+ final = await callModel(iteration + 2, void 0);
4526
+ } catch (error) {
4527
+ if (error instanceof DailyLimitReachedError || isBudgetUnavailable(error) || error instanceof TurnAbandonedError || signal.aborted) throw error;
4528
+ throw new PromptTooLargeError(false);
2119
4529
  }
4530
+ if (!final.content) throw new PromptTooLargeError(false);
4531
+ response = final.content;
4532
+ break;
4533
+ }
4534
+ const toolTimings = [];
4535
+ const toolsStartedAt = clock();
4536
+ const executed = await runToolCalls(allowed, skills, {
4537
+ roomId: m.channelId,
4538
+ signal,
4539
+ sideEffects,
4540
+ clock,
4541
+ onTiming: (t) => toolTimings.push(t)
4542
+ });
4543
+ live();
4544
+ const toolsMs = ms(toolsStartedAt, clock());
4545
+ const nameById = new Map(allowed.map((call) => [call.id, call.function.name]));
4546
+ const indexById = new Map(result.toolCalls.map((call, index) => [call.id, index + 1]));
4547
+ for (const t of toolTimings) {
4548
+ const name = nameById.get(t.tool_call_id);
4549
+ log("info", "turn.tool_call", {
4550
+ seq: timing.seq,
4551
+ iteration: iteration + 1,
4552
+ index: indexById.get(t.tool_call_id) ?? 0,
4553
+ tool: logLabel(name !== void 0 && offeredNames.has(name) ? name : "unoffered"),
4554
+ ms: Math.max(0, Math.round(t.ms)),
4555
+ ok: t.ok,
4556
+ cancelled: t.cancelled
4557
+ });
2120
4558
  }
2121
- const executed = await runToolCalls(allowed, skills, { roomId: m.channelId });
4559
+ timing.toolMs += toolsMs;
4560
+ timing.toolCalls += allowed.length;
4561
+ timing.toolsDenied += denied;
2122
4562
  const byId = new Map(executed.map((r) => [r.tool_call_id, r]));
2123
- for (const { call, verdict } of verdicts) {
4563
+ let resultsCut = 0;
4564
+ let reserveAfter = minimalTotal;
4565
+ const toolMessage = (id, content) => ({ role: "tool", content, tool_call_id: id });
4566
+ const charged = (id, content) => {
4567
+ const available = Math.min(
4568
+ MAX_TOOL_RESULT_BYTES_PER_TURN - toolResultBytes,
4569
+ budgetBytes - (requiredBytes + turnMessageBytes) - reserveAfter
4570
+ );
4571
+ const whole = toolMessage(id, content);
4572
+ if (messageBytes(whole) <= available) {
4573
+ toolResultBytes += messageBytes(whole);
4574
+ return whole;
4575
+ }
4576
+ resultsCut++;
4577
+ if (available >= MIN_TRUNCATED_TOOL_RESULT_BYTES) {
4578
+ const cut = toolMessage(id, fitJsonString(
4579
+ content,
4580
+ (prefix) => JSON.stringify({ result: prefix, truncated: true }),
4581
+ (candidate) => messageBytes(toolMessage(id, candidate)) <= available
4582
+ ));
4583
+ if (messageBytes(cut) <= available) {
4584
+ toolResultBytes += messageBytes(cut);
4585
+ return cut;
4586
+ }
4587
+ }
4588
+ toolResultBytes = MAX_TOOL_RESULT_BYTES_PER_TURN;
4589
+ return toolMessage(id, RESULT_BUDGET_EXCEEDED);
4590
+ };
4591
+ for (const [index, { call, verdict }] of verdicts.entries()) {
4592
+ reserveAfter -= minimalResultBytes[index];
2124
4593
  if (verdict.allowed) {
2125
4594
  const done = byId.get(call.id);
2126
4595
  if (done) {
2127
- messages.push({ role: "tool", content: capResult(done.content), tool_call_id: done.tool_call_id });
4596
+ pushTurnMessage(charged(done.tool_call_id, done.content));
2128
4597
  continue;
2129
4598
  }
2130
- messages.push({
4599
+ pushTurnMessage({
2131
4600
  role: "tool",
2132
4601
  content: JSON.stringify({ error: "tool_call_no_result" }),
2133
4602
  tool_call_id: call.id
2134
4603
  });
2135
4604
  continue;
2136
4605
  }
2137
- messages.push({
4606
+ pushTurnMessage({
2138
4607
  role: "tool",
2139
- content: JSON.stringify({ error: "tool_call_denied", reason: verdict.reason }),
4608
+ content: deniedContent(verdict.reason),
2140
4609
  tool_call_id: call.id
2141
4610
  });
2142
4611
  }
4612
+ if (resultsCut > 0) {
4613
+ log("warn", "turn.tool_results_capped", {
4614
+ seq: timing.seq,
4615
+ iteration: iteration + 1,
4616
+ results_cut: resultsCut,
4617
+ result_bytes: toolResultBytes,
4618
+ budget_bytes: MAX_TOOL_RESULT_BYTES_PER_TURN
4619
+ });
4620
+ }
4621
+ log("info", "turn.iteration", {
4622
+ seq: timing.seq,
4623
+ iteration: iteration + 1,
4624
+ ms: ms(iterationStart, clock()),
4625
+ tool_calls: allowed.length,
4626
+ tools_denied: denied,
4627
+ tool_ms: toolsMs,
4628
+ final: false
4629
+ });
2143
4630
  }
4631
+ live();
2144
4632
  if (!response) {
2145
- log("warn", `no final answer after ${maxIterations} iterations; dropping the turn`);
4633
+ log("warn", "turn.no_final_answer", { iterations: maxIterations });
2146
4634
  return null;
2147
4635
  }
2148
4636
  memory.addMessage({ role: "assistant", content: response });
2149
- log("info", `response ready (chars=${response.length})`);
4637
+ log("info", "turn.response_ready", { chars: response.length });
2150
4638
  return response;
2151
- };
4639
+ }
2152
4640
  }
2153
4641
 
2154
4642
  // src/persona.ts
2155
- import { existsSync as existsSync5, readFileSync as readFileSync7, renameSync as renameSync2, unlinkSync, writeFileSync } from "node:fs";
2156
- import { dirname as dirname3, join as join7 } from "node:path";
4643
+ import { existsSync as existsSync5, readFileSync as readFileSync9, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync } from "node:fs";
4644
+ import { dirname as dirname6, join as join7 } from "node:path";
2157
4645
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2158
4646
 
2159
4647
  // src/long-term-memory.ts
2160
- import { createHash } from "node:crypto";
2161
- import { chmodSync, existsSync as existsSync4, lstatSync, readFileSync as readFileSync6, readdirSync as readdirSync2, renameSync } from "node:fs";
4648
+ import { createHash as createHash2 } from "node:crypto";
4649
+ import { chmodSync as chmodSync2, existsSync as existsSync4, lstatSync, readFileSync as readFileSync8, readdirSync as readdirSync2, renameSync as renameSync2 } from "node:fs";
2162
4650
  import { join as join6 } from "node:path";
2163
4651
  var MEMORY_FILE = "MEMORY.md";
2164
4652
  var QUARANTINE_MEMORY_FILE = "MEMORY.quarantined.md";
@@ -2173,14 +4661,14 @@ function quarantineOrder(a, b) {
2173
4661
  return index(a) - index(b) || a.localeCompare(b);
2174
4662
  }
2175
4663
  function sourceId(kind, channelId, path, bytes) {
2176
- return createHash("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
4664
+ return createHash2("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
2177
4665
  }
2178
4666
  function readLegacySource(path, kind, contentBudget, channelId) {
2179
4667
  try {
2180
4668
  let st = lstatSync(path);
2181
4669
  if (st.isSymbolicLink() || !st.isFile()) return null;
2182
4670
  if ((st.mode & 511) !== 384) {
2183
- chmodSync(path, 384);
4671
+ chmodSync2(path, 384);
2184
4672
  st = lstatSync(path);
2185
4673
  }
2186
4674
  if (st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES) {
@@ -2196,7 +4684,7 @@ function readLegacySource(path, kind, contentBudget, channelId) {
2196
4684
  };
2197
4685
  }
2198
4686
  if (st.size > contentBudget) return null;
2199
- const bytes = readFileSync6(path);
4687
+ const bytes = readFileSync8(path);
2200
4688
  if (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget) return null;
2201
4689
  return {
2202
4690
  id: sourceId(kind, channelId, path, bytes),
@@ -2281,7 +4769,7 @@ function legacyMemoryFingerprint(managedDir, dataDir) {
2281
4769
  }
2282
4770
  } catch {
2283
4771
  }
2284
- return createHash("sha256").update(metadata.join("\n")).digest("hex");
4772
+ return createHash2("sha256").update(metadata.join("\n")).digest("hex");
2285
4773
  }
2286
4774
  function nextQuarantinePath(dir) {
2287
4775
  const first = join6(dir, QUARANTINE_MEMORY_FILE);
@@ -2300,8 +4788,8 @@ function quarantineLegacyMemory(managedDir, dataDir) {
2300
4788
  throw new Error(`refusing to quarantine symlinked legacy memory: ${source}`);
2301
4789
  }
2302
4790
  const target = nextQuarantinePath(dir);
2303
- renameSync(source, target);
2304
- chmodSync(target, 384);
4791
+ renameSync2(source, target);
4792
+ chmodSync2(target, 384);
2305
4793
  moved.push(target);
2306
4794
  }
2307
4795
  return moved;
@@ -2322,7 +4810,7 @@ var SAFETY_PREAMBLE = [
2322
4810
  "- Only use the tools you were given; do not invent capabilities."
2323
4811
  ].join("\n");
2324
4812
  function defaultPersonaDir() {
2325
- return join7(dirname3(fileURLToPath2(import.meta.url)), "..", "persona");
4813
+ return join7(dirname6(fileURLToPath2(import.meta.url)), "..", "persona");
2326
4814
  }
2327
4815
  function assemblePrompt(parts) {
2328
4816
  return parts.map((p) => `${p.heading}
@@ -2335,13 +4823,13 @@ function loadPersona(dirs) {
2335
4823
  if (!path) continue;
2336
4824
  let body;
2337
4825
  try {
2338
- body = readFileSync7(path, "utf8");
2339
- } catch {
2340
- console.warn(`[persona] cannot read ${path}; ignoring it`);
4826
+ body = readFileSync9(path, "utf8");
4827
+ } catch (error) {
4828
+ writeServiceLog("warn", "persona.read_failed", {}, error);
2341
4829
  continue;
2342
4830
  }
2343
4831
  if (Buffer.byteLength(body, "utf8") > PERSONA_MAX_BYTES) {
2344
- console.warn(`[persona] ${path} is larger than ${PERSONA_MAX_BYTES} bytes; truncating`);
4832
+ writeServiceLog("warn", "persona.truncated", { max_bytes: PERSONA_MAX_BYTES });
2345
4833
  body = body.slice(0, PERSONA_MAX_BYTES);
2346
4834
  }
2347
4835
  const trimmed = body.trim();
@@ -2361,7 +4849,7 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
2361
4849
  const path2 = join7(dataDir, file);
2362
4850
  if (!existsSync5(path2)) continue;
2363
4851
  try {
2364
- persona[file] = readFileSync7(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
4852
+ persona[file] = readFileSync9(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
2365
4853
  } catch {
2366
4854
  }
2367
4855
  }
@@ -2380,10 +4868,10 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
2380
4868
  try {
2381
4869
  writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
2382
4870
  `, { mode: 384 });
2383
- renameSync2(tmp, path);
4871
+ renameSync3(tmp, path);
2384
4872
  } catch (e) {
2385
4873
  try {
2386
- unlinkSync(tmp);
4874
+ unlinkSync2(tmp);
2387
4875
  } catch {
2388
4876
  }
2389
4877
  throw e;
@@ -2399,7 +4887,7 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
2399
4887
  skipped.push(target);
2400
4888
  continue;
2401
4889
  }
2402
- writeFile(target, readFileSync7(join7(defaultsDir, file), "utf8"));
4890
+ writeFile(target, readFileSync9(join7(defaultsDir, file), "utf8"));
2403
4891
  written.push(target);
2404
4892
  }
2405
4893
  return { written, skipped };
@@ -2431,32 +4919,33 @@ function installWarningFilter(proc = process) {
2431
4919
  proc.removeListener("warning", listener);
2432
4920
  }
2433
4921
  proc.on("warning", (w) => {
2434
- if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
2435
- console.error(w.stack ?? `${w.name}: ${w.message}`);
4922
+ try {
4923
+ if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
4924
+ } catch {
4925
+ }
4926
+ writeServiceLog("warn", "runtime.node_warning", {}, w);
2436
4927
  });
2437
4928
  }
2438
4929
 
2439
4930
  // src/index.ts
2440
4931
  if (process.argv.includes("--version")) {
2441
- const pkg = JSON.parse(readFileSync8(join8(dirname4(dirname4(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
4932
+ const pkg = JSON.parse(readFileSync10(join8(dirname7(dirname7(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
2442
4933
  console.log(pkg.version);
2443
4934
  process.exit(0);
2444
4935
  }
2445
4936
  var LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
2446
4937
  function makeLogger(cfg) {
2447
4938
  const floor = LEVEL_RANK[cfg.logLevel];
2448
- return (level, msg) => {
4939
+ return (level, event, fields, error) => {
2449
4940
  if (LEVEL_RANK[level] < floor) return;
2450
- const line = `[runtime] ${level}: ${msg}`;
2451
- if (level === "warn" || level === "error") console.error(line);
2452
- else console.log(line);
4941
+ writeServiceLog(level, event, fields, error);
2453
4942
  };
2454
4943
  }
2455
4944
  function realLlmSourceDeps() {
2456
4945
  return {
2457
4946
  home: homedir4(),
2458
- readFile: (p) => readFileSync8(p, "utf8"),
2459
- statFile: (p) => ({ mode: statSync2(p).mode }),
4947
+ readFile: (p) => readFileSync10(p, "utf8"),
4948
+ statFile: (p) => ({ mode: statSync3(p).mode }),
2460
4949
  exists: (p) => existsSync6(p)
2461
4950
  };
2462
4951
  }
@@ -2469,7 +4958,7 @@ function realServiceDeps() {
2469
4958
  },
2470
4959
  writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
2471
4960
  removeFile: (p) => rmSync(p, { force: true }),
2472
- mkdirp: (p) => mkdirSync(p, { recursive: true }),
4961
+ mkdirp: (p) => mkdirSync4(p, { recursive: true }),
2473
4962
  fileExists: (p) => existsSync6(p),
2474
4963
  canRead: (p) => {
2475
4964
  try {
@@ -2566,6 +5055,46 @@ async function main() {
2566
5055
  installWarningFilter();
2567
5056
  const verb = parseVerb(process.argv);
2568
5057
  if (verb) runServiceVerb(verb);
5058
+ const sideEffectsVerb = parseSideEffectsVerb(process.argv);
5059
+ if (process.argv[2] === "side-effects" && !sideEffectsVerb) {
5060
+ console.error("securecoms-agent: side-effects requires list or resolve");
5061
+ process.exit(2);
5062
+ }
5063
+ if (sideEffectsVerb) {
5064
+ let cfgForLedger;
5065
+ try {
5066
+ cfgForLedger = loadConfig(process.argv.slice(2), process.env);
5067
+ } catch (e) {
5068
+ console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
5069
+ process.exit(2);
5070
+ return;
5071
+ }
5072
+ const ledger = new SideEffectLedger(join8(cfgForLedger.dataDir, "side-effect-ledger.sqlite"));
5073
+ try {
5074
+ if (sideEffectsVerb === "list") {
5075
+ console.log(JSON.stringify({ ambiguous: ledger.listAmbiguous() }, null, 2));
5076
+ } else {
5077
+ const flags = parseSideEffectResolveFlags(process.argv);
5078
+ if (!flags.idempotencyKey || !flags.operationSha256 || !/^[0-9a-f]{64}$/.test(flags.operationSha256) || !flags.confirmationId || !flags.outcome) {
5079
+ throw new Error(
5080
+ "resolve requires --idempotency-key, --operation-sha256, --confirmation-id, and --outcome completed|not_applied"
5081
+ );
5082
+ }
5083
+ ledger.reconcile({
5084
+ idempotency_key: flags.idempotencyKey,
5085
+ operation_sha256: flags.operationSha256,
5086
+ confirmation_id: flags.confirmationId
5087
+ }, flags.outcome, /* @__PURE__ */ new Date());
5088
+ console.log(JSON.stringify({ reconciled: true, outcome: flags.outcome }));
5089
+ }
5090
+ } catch (e) {
5091
+ console.error(`securecoms-agent: side-effects ${sideEffectsVerb} failed: ${e instanceof Error ? e.message : String(e)}`);
5092
+ process.exitCode = 1;
5093
+ } finally {
5094
+ ledger.close();
5095
+ }
5096
+ process.exit(process.exitCode ?? 0);
5097
+ }
2569
5098
  const personaVerb = parsePersonaVerb(process.argv);
2570
5099
  if (personaVerb) {
2571
5100
  let cfgForPersona;
@@ -2577,7 +5106,7 @@ async function main() {
2577
5106
  return;
2578
5107
  }
2579
5108
  if (personaVerb === "init") {
2580
- mkdirSync(cfgForPersona.dataDir, { recursive: true });
5109
+ mkdirSync4(cfgForPersona.dataDir, { recursive: true });
2581
5110
  const r = personaInit(
2582
5111
  cfgForPersona.dataDir,
2583
5112
  defaultPersonaDir(),
@@ -2600,12 +5129,36 @@ async function main() {
2600
5129
  try {
2601
5130
  cfg = loadConfig(process.argv.slice(2), process.env);
2602
5131
  } catch (e) {
2603
- console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
5132
+ const argv = process.argv.slice(2);
5133
+ const hasIdentityArg = argv.some(
5134
+ (arg, index) => arg === "--identity" && !!argv[index + 1] || arg.startsWith("--identity=") && arg.length > 11
5135
+ );
5136
+ writeServiceLog(
5137
+ "error",
5138
+ !hasIdentityArg && !process.env.SECURECOMS_IDENTITY ? "runtime.identity_required.use_--identity" : "runtime.config_invalid",
5139
+ {},
5140
+ e
5141
+ );
2604
5142
  process.exit(2);
2605
5143
  return;
2606
5144
  }
2607
5145
  const log = makeLogger(cfg);
2608
- const llm = buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
5146
+ const budgetBase = join8(cfg.dataDir, `llm-budget-${cfg.identityId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
5147
+ const budgetOpts = {
5148
+ callLimit: cfg.llmDailyCallLimit ?? DEFAULT_DAILY_CALL_LIMIT,
5149
+ tokenLimit: cfg.llmDailyTokenLimit ?? DEFAULT_DAILY_TOKEN_LIMIT,
5150
+ statePath: `${budgetBase}.json`,
5151
+ lockPath: `${budgetBase}.lock`,
5152
+ log
5153
+ };
5154
+ const budget = new DailyLlmBudget(budgetOpts);
5155
+ process.once("exit", () => budget.close());
5156
+ const refusal = budgetBootRefusal(budget, budgetOpts.tokenLimit, cfg.llmMaxOutputTokens, writeServiceLog);
5157
+ if (refusal !== null) {
5158
+ process.exit(refusal);
5159
+ return;
5160
+ }
5161
+ const llm = withDailyBudget(buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()), budget);
2609
5162
  const memories = /* @__PURE__ */ new Map();
2610
5163
  const memoryFor = (channelId) => {
2611
5164
  let mem = memories.get(channelId);
@@ -2629,18 +5182,29 @@ async function main() {
2629
5182
  const path = keyFilePath(which);
2630
5183
  if (!path) return null;
2631
5184
  try {
2632
- return readFileSync8(path, "utf8").trim() || null;
2633
- } catch {
2634
- log("warn", `cannot read the ${which} key file ${path}`);
5185
+ return readFileSync10(path, "utf8").trim() || null;
5186
+ } catch (e) {
5187
+ log("warn", "runtime.search_key_unreadable", { provider_brave: which === "brave" }, e);
2635
5188
  return null;
2636
5189
  }
2637
5190
  };
2638
5191
  const configDirs = configSearchPath(cfg.identityId, cfg.dataDir);
2639
- const hasSearchKey = () => hasReadableKeyFile([keyFilePath("brave"), keyFilePath("tavily")], (p) => readFileSync8(p, "utf8"));
5192
+ const reportSearchKeyGate = searchKeyGateReporter(log, (p) => p === keyFilePath("brave"));
5193
+ const hasSearchKey = () => hasReadableKeyFile(
5194
+ [keyFilePath("brave"), keyFilePath("tavily")],
5195
+ (p) => readFileSync10(p, "utf8"),
5196
+ reportSearchKeyGate
5197
+ );
5198
+ const reportLlmSourceUnresolved = failureEpisodeReporter(
5199
+ (e) => log("warn", "runtime.llm_source_unresolved", {}, e)
5200
+ );
2640
5201
  const hasLlmKey = () => {
2641
5202
  try {
2642
- return hasLlmKeyFor(resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()));
2643
- } catch {
5203
+ const source = resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
5204
+ reportLlmSourceUnresolved.succeeded();
5205
+ return hasLlmKeyFor(source);
5206
+ } catch (e) {
5207
+ reportLlmSourceUnresolved.failed(e);
2644
5208
  return false;
2645
5209
  }
2646
5210
  };
@@ -2659,7 +5223,8 @@ async function main() {
2659
5223
  now: () => /* @__PURE__ */ new Date(),
2660
5224
  log
2661
5225
  };
2662
- const skills = new SkillInvoker(cfg.skillsDir, builtinCtx);
5226
+ const sideEffectLedger = new SideEffectLedger(join8(cfg.dataDir, "side-effect-ledger.sqlite"));
5227
+ const skills = new SkillInvoker(cfg.skillsDir, builtinCtx, sideEffectLedger);
2663
5228
  skills.loadSkills();
2664
5229
  const policyFor = () => resolveToolPolicy(cfg.toolGateEnv, currentSkillsConfig().tools, {
2665
5230
  default: cfg.toolGate.default,
@@ -2673,11 +5238,16 @@ async function main() {
2673
5238
  reportLocalConfig({ hasSearchKey: searchKey, hasLlmKey: hasLlmKey() });
2674
5239
  return skills.available(requiresEnvFrom(sc, searchKey)).filter((t) => g.gate(t.function.name).allowed);
2675
5240
  };
2676
- for (const held of skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey()))) {
2677
- console.log(`unavailable: ${held.name} (missing ${held.missing.join(", ")})`);
2678
- }
5241
+ const unavailableCount = skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey())).length;
5242
+ if (unavailableCount > 0) log("info", "runtime.skills_unavailable", { count: unavailableCount });
2679
5243
  const plane = createSecureComsDataPlane(
2680
- { identityId: cfg.identityId, log: (level, msg) => log(level, msg) },
5244
+ // Final-wave I2: the daily-limit reply is a notice, not an agent turn.
5245
+ // SEC-06 #9: so are the fixed size-limit replies.
5246
+ {
5247
+ identityId: cfg.identityId,
5248
+ log,
5249
+ noticeReplies: /* @__PURE__ */ new Set([DAILY_LIMIT_RESPONSE, MESSAGE_TOO_LARGE_RESPONSE, TURN_TOO_LARGE_RESPONSE])
5250
+ },
2681
5251
  buildHandler({
2682
5252
  llm,
2683
5253
  memoryFor,
@@ -2688,19 +5258,42 @@ async function main() {
2688
5258
  // or configuration saved in the web app takes effect on the next message,
2689
5259
  // with no restart.
2690
5260
  systemPromptFor: () => systemPromptFor(configDirs, DEFAULT_SYSTEM_PROMPT),
2691
- tools: installationEligibleTools
5261
+ tools: installationEligibleTools,
5262
+ // SEC-06 #6: more abandoned turns still running than there are slots
5263
+ // means something inside a turn ignores cancellation. Exit, so launchd
5264
+ // (KeepAlive) / systemd (Restart=always) starts a clean process; the
5265
+ // daemon redelivers what was in flight.
5266
+ // #694 F2: first let the turns still working on other channels finish
5267
+ // and send their replies (the handler starts nothing new meanwhile),
5268
+ // then shut down as SIGTERM does.
5269
+ onWedged: (drained) => {
5270
+ log("error", "runtime.wedged_draining");
5271
+ void drained.then(() => {
5272
+ setImmediate(() => {
5273
+ log("error", "runtime.wedged_exit");
5274
+ plane.stop();
5275
+ sideEffectLedger.close();
5276
+ process.exit(1);
5277
+ });
5278
+ });
5279
+ }
2692
5280
  })
2693
5281
  );
2694
- console.log(
2695
- `${bootLine(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps())}, ${installationEligibleTools().length} configured skills (channel grant required), tool gate default=${cfg.toolGate.default}`
2696
- );
5282
+ log("info", "runtime.started", {
5283
+ configured_skills: installationEligibleTools().length,
5284
+ tool_gate_default_deny: cfg.toolGate.default === "deny"
5285
+ });
2697
5286
  await plane.start();
2698
5287
  const shutdown = () => {
2699
- log("info", "shutting down");
5288
+ log("info", "runtime.shutting_down");
2700
5289
  plane.stop();
5290
+ sideEffectLedger.close();
2701
5291
  process.exit(0);
2702
5292
  };
2703
5293
  process.on("SIGINT", shutdown);
2704
5294
  process.on("SIGTERM", shutdown);
2705
5295
  }
2706
- void main();
5296
+ void main().catch((error) => {
5297
+ writeServiceLog("error", "runtime.fatal", {}, error);
5298
+ process.exit(1);
5299
+ });