@securecoms/agent-runtime 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +74 -7
  2. package/dist/bin.js +1454 -275
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -1,17 +1,375 @@
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 readFileSync9 } 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 dirname6, 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 mkdirSync3, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync3 } 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 dirname2, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+
16
+ // src/service-log.ts
17
+ var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
18
+ var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
19
+ function errorClass(error) {
20
+ if (error === null) return "NullThrown";
21
+ if (typeof error !== "object" && typeof error !== "function") {
22
+ const primitive = {
23
+ string: "StringThrown",
24
+ number: "NumberThrown",
25
+ boolean: "BooleanThrown",
26
+ bigint: "BigIntThrown",
27
+ symbol: "SymbolThrown",
28
+ undefined: "UndefinedThrown"
29
+ };
30
+ return primitive[typeof error] ?? "UnknownThrown";
31
+ }
32
+ try {
33
+ const name = Object.getPrototypeOf(error)?.constructor?.name;
34
+ return typeof name === "string" && CLASS.test(name) ? name : "Error";
35
+ } catch {
36
+ return "Error";
37
+ }
38
+ }
39
+ function formatServiceLog(level, event, fields = {}, error) {
40
+ const safeEvent = TOKEN.test(event) ? event : "invalid_event";
41
+ const parts = [`[runtime] ${level} ${safeEvent}`];
42
+ for (const [key, value] of Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))) {
43
+ if (!TOKEN.test(key)) continue;
44
+ if (typeof value === "boolean") parts.push(`${key}=${value}`);
45
+ else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
46
+ }
47
+ if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
48
+ return parts.join(" ");
49
+ }
50
+ function failureEpisodeReporter(warn) {
51
+ let lastClass = null;
52
+ return {
53
+ failed(error) {
54
+ const cls = errorClass(error);
55
+ if (cls === lastClass) return;
56
+ lastClass = cls;
57
+ try {
58
+ warn(error);
59
+ } catch {
60
+ }
61
+ },
62
+ succeeded() {
63
+ lastClass = null;
64
+ }
65
+ };
66
+ }
67
+ function writeServiceLog(level, event, fields, error) {
68
+ const line = formatServiceLog(level, event, fields, error);
69
+ if (level === "error") console.error(line);
70
+ else if (level === "warn") console.warn(line);
71
+ else console.log(line);
72
+ }
73
+
74
+ // src/llm-dispatch.ts
75
+ var LLM_REQUEST_MAX_BYTES = 128 * 1024;
76
+ var LLM_RESPONSE_MAX_BYTES = 128 * 1024;
77
+ var LLM_REQUEST_TIMEOUT_MS = 18e4;
78
+ var DEFAULT_MAX_OUTPUT_TOKENS = 8192;
79
+ var CAP_REJECTED_MAX = 256;
80
+ var capRejectedModels = /* @__PURE__ */ new Set();
81
+ var CAP_ERROR_SCAN_BYTES = 16 * 1024;
82
+ var CAP_FIELD_NAMED = /max_(?:completion_|output_)?tokens/i;
83
+ var CONTEXT_OVERFLOW_NAMED = /prompt|context|input[\s_-]*(?:length|tokens)/i;
84
+ async function errorNamesOutputCap(resp) {
85
+ const reader = resp.body?.getReader();
86
+ if (!reader) return false;
87
+ const chunks = [];
88
+ let total = 0;
89
+ try {
90
+ while (total < CAP_ERROR_SCAN_BYTES) {
91
+ const { done, value } = await reader.read();
92
+ if (done) break;
93
+ if (!value) continue;
94
+ chunks.push(value);
95
+ total += value.byteLength;
96
+ }
97
+ } catch {
98
+ return false;
99
+ } finally {
100
+ await reader.cancel().catch(() => {
101
+ });
102
+ reader.releaseLock();
103
+ }
104
+ const text = new TextDecoder().decode(Buffer.concat(chunks).subarray(0, CAP_ERROR_SCAN_BYTES));
105
+ return CAP_FIELD_NAMED.test(text) && !CONTEXT_OVERFLOW_NAMED.test(text);
106
+ }
107
+ function outputCapField(endpoint) {
108
+ try {
109
+ return new URL(endpoint).hostname === "api.openai.com" ? "max_completion_tokens" : "max_tokens";
110
+ } catch {
111
+ return "max_tokens";
112
+ }
113
+ }
114
+ function normalizeEndpoint(endpoint) {
115
+ return endpoint.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
116
+ }
117
+ var LLMDispatcher = class {
118
+ endpoint;
119
+ model;
120
+ apiKey;
121
+ chatPath;
122
+ chatAuth;
123
+ authHeaderName;
124
+ fetch;
125
+ timeoutMs;
126
+ requestMaxBytes;
127
+ responseMaxBytes;
128
+ maxOutputTokens;
129
+ log;
130
+ constructor(endpoint, model, opts = {}) {
131
+ this.endpoint = normalizeEndpoint(endpoint);
132
+ this.model = model;
133
+ this.apiKey = opts.apiKey;
134
+ this.chatPath = opts.chatPath ?? "/v1/chat/completions";
135
+ this.chatAuth = opts.chatAuth ?? "bearer";
136
+ this.authHeaderName = opts.authHeaderName ?? "x-api-key";
137
+ this.fetch = opts.fetch ?? fetch;
138
+ this.timeoutMs = opts.timeoutMs ?? LLM_REQUEST_TIMEOUT_MS;
139
+ this.requestMaxBytes = opts.requestMaxBytes ?? LLM_REQUEST_MAX_BYTES;
140
+ this.responseMaxBytes = opts.responseMaxBytes ?? LLM_RESPONSE_MAX_BYTES;
141
+ this.maxOutputTokens = opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
142
+ this.log = opts.log ?? writeServiceLog;
143
+ }
144
+ async complete(messages, tools, options = {}) {
145
+ const body = {
146
+ model: this.model,
147
+ messages,
148
+ stream: false
149
+ };
150
+ const capKey = `${this.endpoint}\0${this.model}`;
151
+ const capField = outputCapField(this.endpoint);
152
+ if (tools && tools.length > 0) {
153
+ body.tools = tools;
154
+ }
155
+ const url = `${this.endpoint}${this.chatPath}`;
156
+ const headers = { "Content-Type": "application/json" };
157
+ if (this.apiKey && this.chatAuth === "bearer") {
158
+ headers.Authorization = `Bearer ${this.apiKey}`;
159
+ } else if (this.apiKey && this.chatAuth === "x-api-key") {
160
+ headers[this.authHeaderName] = this.apiKey;
161
+ }
162
+ const encode = (withCap2) => {
163
+ const encoded = JSON.stringify(withCap2 ? { ...body, [capField]: this.maxOutputTokens } : body);
164
+ if (Buffer.byteLength(encoded, "utf8") > this.requestMaxBytes) {
165
+ throw new Error("LLM request exceeded the size limit");
166
+ }
167
+ return encoded;
168
+ };
169
+ let withCap = !capRejectedModels.has(capKey);
170
+ let encodedBody = encode(withCap);
171
+ const controller = new AbortController();
172
+ let timedOut = false;
173
+ const cancel = () => controller.abort(options.signal?.reason);
174
+ if (options.signal?.aborted) cancel();
175
+ else options.signal?.addEventListener("abort", cancel, { once: true });
176
+ const timer = setTimeout(() => {
177
+ timedOut = true;
178
+ controller.abort(new Error("LLM request deadline exceeded"));
179
+ }, this.timeoutMs);
180
+ try {
181
+ const post = () => this.fetch(url, {
182
+ method: "POST",
183
+ headers,
184
+ body: encodedBody,
185
+ signal: controller.signal,
186
+ redirect: "error"
187
+ });
188
+ let resp = await post();
189
+ if (resp.status === 400 && withCap && await errorNamesOutputCap(resp)) {
190
+ if (capRejectedModels.size >= CAP_REJECTED_MAX) {
191
+ const oldest = capRejectedModels.values().next();
192
+ if (!oldest.done) capRejectedModels.delete(oldest.value);
193
+ }
194
+ capRejectedModels.add(capKey);
195
+ this.log("warn", "llm.output_cap_rejected", {});
196
+ withCap = false;
197
+ encodedBody = encode(false);
198
+ resp = await post();
199
+ }
200
+ if (!resp.ok) {
201
+ await resp.body?.cancel();
202
+ throw new Error(`LLM request failed (${resp.status})`);
203
+ }
204
+ const data = await readJsonCapped(resp, this.responseMaxBytes);
205
+ const choice = data.choices?.[0];
206
+ if (!choice) throw new Error("LLM returned no choices");
207
+ const totalTokens = data.usage?.total_tokens;
208
+ return {
209
+ content: choice.message.content,
210
+ toolCalls: choice.message.tool_calls || [],
211
+ finishReason: choice.finish_reason,
212
+ ...typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens >= 0 ? { usage: { totalTokens } } : {}
213
+ };
214
+ } catch (error) {
215
+ if (timedOut) throw new Error(`LLM request timed out after ${this.timeoutMs}ms`);
216
+ if (options.signal?.aborted) throw new Error("LLM request cancelled");
217
+ throw error;
218
+ } finally {
219
+ clearTimeout(timer);
220
+ options.signal?.removeEventListener("abort", cancel);
221
+ }
222
+ }
223
+ };
224
+ async function readJsonCapped(resp, maxBytes) {
225
+ const reader = resp.body?.getReader();
226
+ if (!reader) throw new Error("LLM returned an empty response");
227
+ const chunks = [];
228
+ let total = 0;
229
+ try {
230
+ while (true) {
231
+ const { done, value } = await reader.read();
232
+ if (done) break;
233
+ if (!value) continue;
234
+ total += value.byteLength;
235
+ if (total > maxBytes) {
236
+ await reader.cancel().catch(() => {
237
+ });
238
+ throw new Error("LLM response exceeded the size limit");
239
+ }
240
+ chunks.push(value);
241
+ }
242
+ } catch (error) {
243
+ await reader.cancel().catch(() => {
244
+ });
245
+ throw error;
246
+ } finally {
247
+ reader.releaseLock();
248
+ }
249
+ const joined = new Uint8Array(total);
250
+ let offset = 0;
251
+ for (const chunk of chunks) {
252
+ joined.set(chunk, offset);
253
+ offset += chunk.byteLength;
254
+ }
255
+ return JSON.parse(new TextDecoder().decode(joined));
256
+ }
257
+
258
+ // src/llm-budget.ts
259
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
260
+ import { dirname } from "node:path";
261
+ var DEFAULT_DAILY_CALL_LIMIT = 2e3;
262
+ var DEFAULT_DAILY_TOKEN_LIMIT = 5e6;
263
+ var DailyLimitReachedError = class extends Error {
264
+ constructor(day) {
265
+ super("daily LLM budget reached");
266
+ this.day = day;
267
+ this.name = "DailyLimitReachedError";
268
+ }
269
+ };
270
+ function utcDay(d) {
271
+ return d.toISOString().slice(0, 10);
272
+ }
273
+ var DailyLlmBudget = class {
274
+ callLimit;
275
+ tokenLimit;
276
+ statePath;
277
+ now;
278
+ log;
279
+ state;
280
+ exhaustionLoggedFor = null;
281
+ constructor(opts) {
282
+ this.callLimit = opts.callLimit;
283
+ this.tokenLimit = opts.tokenLimit;
284
+ this.statePath = opts.statePath;
285
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
286
+ this.log = opts.log ?? (() => {
287
+ });
288
+ this.state = this.load();
289
+ }
290
+ /**
291
+ * Reserve one provider call against today's budget, or throw
292
+ * {@link DailyLimitReachedError} without reserving anything.
293
+ */
294
+ beginCall() {
295
+ this.roll();
296
+ const { calls, tokens, day } = this.state;
297
+ const callsSpent = this.callLimit > 0 && calls >= this.callLimit;
298
+ const tokensSpent = this.tokenLimit > 0 && tokens >= this.tokenLimit;
299
+ if (callsSpent || tokensSpent) {
300
+ if (this.exhaustionLoggedFor !== day) {
301
+ this.exhaustionLoggedFor = day;
302
+ this.log("warn", "llm.daily_budget_exhausted", {
303
+ calls,
304
+ tokens,
305
+ call_limit: this.callLimit,
306
+ token_limit: this.tokenLimit
307
+ });
308
+ }
309
+ throw new DailyLimitReachedError(day);
310
+ }
311
+ this.state.calls++;
312
+ this.save();
313
+ }
314
+ /** Add provider-reported tokens to today's total. */
315
+ recordTokens(totalTokens) {
316
+ if (!Number.isFinite(totalTokens) || totalTokens <= 0) return;
317
+ this.roll();
318
+ this.state.tokens += Math.floor(totalTokens);
319
+ this.save();
320
+ }
321
+ snapshot() {
322
+ this.roll();
323
+ return { ...this.state };
324
+ }
325
+ roll() {
326
+ const today = utcDay(this.now());
327
+ if (this.state.day !== today) this.state = { day: today, calls: 0, tokens: 0 };
328
+ }
329
+ load() {
330
+ const fresh = { day: utcDay(this.now()), calls: 0, tokens: 0 };
331
+ if (!this.statePath) return fresh;
332
+ let raw;
333
+ try {
334
+ raw = readFileSync(this.statePath, "utf8");
335
+ } catch {
336
+ return fresh;
337
+ }
338
+ try {
339
+ const parsed = JSON.parse(raw);
340
+ if (parsed.day === fresh.day && Number.isInteger(parsed.calls) && parsed.calls >= 0 && Number.isInteger(parsed.tokens) && parsed.tokens >= 0) {
341
+ return { day: fresh.day, calls: parsed.calls, tokens: parsed.tokens };
342
+ }
343
+ return fresh;
344
+ } catch (error) {
345
+ this.log("warn", "llm.budget_state_unreadable", {}, error);
346
+ return fresh;
347
+ }
348
+ }
349
+ save() {
350
+ if (!this.statePath) return;
351
+ try {
352
+ mkdirSync(dirname(this.statePath), { recursive: true });
353
+ const tmp = `${this.statePath}.tmp`;
354
+ writeFileSync(tmp, JSON.stringify(this.state), { mode: 384 });
355
+ renameSync(tmp, this.statePath);
356
+ } catch (error) {
357
+ this.log("warn", "llm.budget_state_write_failed", {}, error);
358
+ }
359
+ }
360
+ };
361
+ function withDailyBudget(llm, budget) {
362
+ return {
363
+ async complete(messages, tools, options) {
364
+ budget.beginCall();
365
+ const result = await llm.complete(messages, tools, options);
366
+ if (result.usage) budget.recordTokens(result.usage.totalTokens);
367
+ return result;
368
+ }
369
+ };
370
+ }
371
+
372
+ // src/config.ts
15
373
  var MISSING_IDENTITY_MESSAGE = "Missing agent identity: pass --identity <id> or set SECURECOMS_IDENTITY";
16
374
  var LOG_LEVELS = /* @__PURE__ */ new Set(["debug", "info", "warn", "error"]);
17
375
  var TOOL_DEFAULTS = /* @__PURE__ */ new Set(["allow", "deny"]);
@@ -48,8 +406,16 @@ function parseList(value) {
48
406
  const items = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
49
407
  return items.length > 0 ? items : void 0;
50
408
  }
409
+ function parseIntSetting(env, name, fallback, min) {
410
+ const raw = env[name]?.trim();
411
+ if (!raw) return fallback;
412
+ if (!/^\d+$/.test(raw) || Number(raw) < min || !Number.isSafeInteger(Number(raw))) {
413
+ throw new Error(`Invalid ${name} "${raw}": expected a whole number >= ${min}`);
414
+ }
415
+ return Number(raw);
416
+ }
51
417
  function defaultSkillsDir() {
52
- return join(dirname(fileURLToPath(import.meta.url)), "..", "skills");
418
+ return join(dirname2(fileURLToPath(import.meta.url)), "..", "skills");
53
419
  }
54
420
  var DEFAULT_TOOL_ALLOW = [
55
421
  "echo",
@@ -99,7 +465,10 @@ function loadConfig(argv, env) {
99
465
  toolGateEnv: {
100
466
  ...parsedAllow !== void 0 ? { allow: parsedAllow } : {},
101
467
  ...deny !== void 0 ? { deny } : {}
102
- }
468
+ },
469
+ llmMaxOutputTokens: parseIntSetting(env, "SECURECOMS_LLM_MAX_OUTPUT_TOKENS", DEFAULT_MAX_OUTPUT_TOKENS, 1),
470
+ llmDailyCallLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_CALL_LIMIT", DEFAULT_DAILY_CALL_LIMIT, 0),
471
+ llmDailyTokenLimit: parseIntSetting(env, "SECURECOMS_LLM_DAILY_TOKEN_LIMIT", DEFAULT_DAILY_TOKEN_LIMIT, 0)
103
472
  };
104
473
  }
105
474
 
@@ -154,9 +523,23 @@ function parsePersonaVerb(argv) {
154
523
  const sub = argv[3];
155
524
  return sub === "init" || sub === "show" ? sub : null;
156
525
  }
526
+ function parseSideEffectsVerb(argv) {
527
+ if (argv[2] !== "side-effects") return null;
528
+ const sub = argv[3];
529
+ return sub === "list" || sub === "resolve" ? sub : null;
530
+ }
531
+ function parseSideEffectResolveFlags(argv) {
532
+ const outcome = flagOrEnv(argv, "--outcome", {});
533
+ return {
534
+ idempotencyKey: flagOrEnv(argv, "--idempotency-key", {}),
535
+ operationSha256: flagOrEnv(argv, "--operation-sha256", {}),
536
+ confirmationId: flagOrEnv(argv, "--confirmation-id", {}),
537
+ outcome: outcome === "completed" || outcome === "not_applied" ? outcome : void 0
538
+ };
539
+ }
157
540
 
158
541
  // src/service/install.ts
159
- import { dirname as dirname2 } from "node:path";
542
+ import { dirname as dirname3 } from "node:path";
160
543
 
161
544
  // src/service/config.ts
162
545
  import { isAbsolute, resolve } from "node:path";
@@ -388,8 +771,8 @@ function installService(input, deps) {
388
771
  const plan = prepareInstall(input, deps.platform, deps);
389
772
  const keyFile = plan.keyFile;
390
773
  if (deps.platform === "darwin") {
391
- deps.mkdirp(dirname2(cfg.plistPath));
392
- deps.mkdirp(dirname2(cfg.logPath));
774
+ deps.mkdirp(dirname3(cfg.plistPath));
775
+ deps.mkdirp(dirname3(cfg.logPath));
393
776
  deps.writeFile(cfg.plistPath, plan.contents);
394
777
  deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
395
778
  requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
@@ -397,7 +780,7 @@ function installService(input, deps) {
397
780
  return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, ...keyFile ? { keyFile } : {} };
398
781
  }
399
782
  if (deps.platform === "linux") {
400
- deps.mkdirp(dirname2(cfg.unitPath));
783
+ deps.mkdirp(dirname3(cfg.unitPath));
401
784
  deps.writeFile(cfg.unitPath, plan.contents);
402
785
  deps.run("systemctl", ["--user", "daemon-reload"]);
403
786
  deps.run("loginctl", ["enable-linger", deps.user]);
@@ -431,63 +814,6 @@ function uninstallService(input, deps) {
431
814
  throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
432
815
  }
433
816
 
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
817
  // src/llm-source.ts
492
818
  import { join as join2 } from "node:path";
493
819
 
@@ -661,34 +987,20 @@ function resolveLlmSource(cfg, explicit, deps) {
661
987
  }
662
988
 
663
989
  // 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
990
  function buildLlmShim(cfg, explicit, deps) {
672
991
  return {
673
- async complete(messages, tools) {
992
+ async complete(messages, tools, options) {
674
993
  const src = resolveLlmSource(cfg, explicit, deps);
675
994
  const dispatcher = new LLMDispatcher(src.endpoint, src.model, {
676
995
  apiKey: src.apiKey,
677
996
  chatPath: src.chatPath,
678
- chatAuth: src.chatAuth
997
+ chatAuth: src.chatAuth,
998
+ maxOutputTokens: cfg.llmMaxOutputTokens
679
999
  });
680
- return dispatcher.complete(messages, tools);
1000
+ return dispatcher.complete(messages, tools, options);
681
1001
  }
682
1002
  };
683
1003
  }
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
1004
 
693
1005
  // src/memory.ts
694
1006
  var MAX_HISTORY = 50;
@@ -742,7 +1054,7 @@ ${scored.map((entry) => `- ${entry.content}`).join("\n")}`;
742
1054
  };
743
1055
 
744
1056
  // src/skill-invoker.ts
745
- import { readFileSync as readFileSync4, readdirSync, existsSync as existsSync3, statSync } from "node:fs";
1057
+ import { readFileSync as readFileSync5, readdirSync, existsSync as existsSync3, statSync } from "node:fs";
746
1058
  import { join as join4 } from "node:path";
747
1059
 
748
1060
  // src/skill-md.ts
@@ -866,16 +1178,35 @@ ${guidance}` : description,
866
1178
  }
867
1179
 
868
1180
  // src/requires.ts
869
- function hasReadableKeyFile(paths, read) {
1181
+ function hasReadableKeyFile(paths, read, onReadError) {
870
1182
  return paths.some((path) => {
871
1183
  if (!path) return false;
872
1184
  try {
873
1185
  return read(path).trim() !== "";
874
- } catch {
1186
+ } catch (error) {
1187
+ try {
1188
+ onReadError?.(path, error);
1189
+ } catch {
1190
+ }
875
1191
  return false;
876
1192
  }
877
1193
  });
878
1194
  }
1195
+ function searchKeyGateReporter(log, isBravePath) {
1196
+ const logged = /* @__PURE__ */ new Set();
1197
+ return (path, error) => {
1198
+ if (error?.code === "ENOENT") return;
1199
+ const signature = `${path}|${errorClass(error)}`;
1200
+ if (logged.has(signature)) return;
1201
+ logged.add(signature);
1202
+ log(
1203
+ "warn",
1204
+ "runtime.search_key_unreadable",
1205
+ { at_gate: true, provider_brave: isBravePath(path) },
1206
+ error
1207
+ );
1208
+ };
1209
+ }
879
1210
  function requiresEnvFrom(cfg, hasSearchKey) {
880
1211
  return {
881
1212
  hasSearchKey,
@@ -898,7 +1229,7 @@ function resolveRequires(req, env) {
898
1229
  }
899
1230
 
900
1231
  // src/skills-config.ts
901
- import { existsSync as existsSync2, readFileSync } from "node:fs";
1232
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
902
1233
  import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
903
1234
 
904
1235
  // src/config-dirs.ts
@@ -927,13 +1258,13 @@ function loadSkillsConfigFile(path, dataDir) {
927
1258
  if (!existsSync2(path)) return empty();
928
1259
  let raw;
929
1260
  try {
930
- raw = JSON.parse(readFileSync(path, "utf8"));
1261
+ raw = JSON.parse(readFileSync2(path, "utf8"));
931
1262
  } catch (e) {
932
- console.warn(`[skills-config] ignoring ${path}: ${e instanceof Error ? e.message : String(e)}`);
1263
+ writeServiceLog("warn", "skills_config.parse_failed", {}, e);
933
1264
  return empty();
934
1265
  }
935
1266
  if (typeof raw !== "object" || raw === null) {
936
- console.warn(`[skills-config] ignoring ${path}: expected a JSON object`);
1267
+ writeServiceLog("warn", "skills_config.root_invalid");
937
1268
  return empty();
938
1269
  }
939
1270
  const src = raw;
@@ -941,7 +1272,7 @@ function loadSkillsConfigFile(path, dataDir) {
941
1272
  for (const [name, v] of Object.entries(src.endpoints ?? {})) {
942
1273
  const e = v;
943
1274
  if (typeof e?.baseUrl !== "string" || e.baseUrl === "") {
944
- console.warn(`[skills-config] endpoint "${name}" in ${path} has no baseUrl; skipped`);
1275
+ writeServiceLog("warn", "skills_config.endpoint_invalid");
945
1276
  continue;
946
1277
  }
947
1278
  endpoints[name] = {
@@ -955,7 +1286,7 @@ function loadSkillsConfigFile(path, dataDir) {
955
1286
  if (typeof d?.sqlite === "string" && d.sqlite !== "") databases[name] = { sqlite: abs2(d.sqlite, dataDir) };
956
1287
  else if (typeof d?.postgresUrlFile === "string" && d.postgresUrlFile !== "") {
957
1288
  databases[name] = { postgresUrlFile: abs2(d.postgresUrlFile, dataDir) };
958
- } else console.warn(`[skills-config] database "${name}" in ${path} needs sqlite or postgresUrlFile; skipped`);
1289
+ } else writeServiceLog("warn", "skills_config.database_invalid");
959
1290
  }
960
1291
  const flags = Array.isArray(src.flags) ? src.flags.filter((f) => typeof f === "string") : [];
961
1292
  const strings = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : void 0;
@@ -968,9 +1299,9 @@ function loadSkillsConfigFile(path, dataDir) {
968
1299
  const s = src.search;
969
1300
  if (s && typeof s === "object") {
970
1301
  if (s.provider !== "brave" && s.provider !== "tavily") {
971
- console.warn(`[skills-config] search in ${path} has an unknown provider; skipped`);
1302
+ writeServiceLog("warn", "skills_config.search_provider_invalid");
972
1303
  } else if (typeof s.keyFile !== "string" || s.keyFile === "") {
973
- console.warn(`[skills-config] search in ${path} has no keyFile; skipped`);
1304
+ writeServiceLog("warn", "skills_config.search_key_file_missing");
974
1305
  } else {
975
1306
  search = { provider: s.provider, keyFile: abs2(s.keyFile, dataDir) };
976
1307
  }
@@ -1002,12 +1333,30 @@ function htmlToText(html) {
1002
1333
  }
1003
1334
 
1004
1335
  // src/net-guard.ts
1005
- import { lookup } from "node:dns/promises";
1336
+ import { Resolver as DnsResolver } from "node:dns/promises";
1006
1337
  import { isIPv4, isIPv6 } from "node:net";
1007
1338
  var BlockedAddressError = class extends Error {
1008
1339
  };
1009
1340
  function systemResolver() {
1010
- return async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address);
1341
+ return async (host, signal) => {
1342
+ if (isIPv4(host) || isIPv6(host)) return [host];
1343
+ if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
1344
+ const resolver = new DnsResolver();
1345
+ const cancel = () => resolver.cancel();
1346
+ signal?.addEventListener("abort", cancel, { once: true });
1347
+ try {
1348
+ const results = await Promise.allSettled([resolver.resolve4(host), resolver.resolve6(host)]);
1349
+ if (signal?.aborted) throw signal.reason ?? new Error("DNS lookup cancelled");
1350
+ const addresses = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1351
+ if (addresses.length === 0) {
1352
+ const rejected = results.find((result) => result.status === "rejected");
1353
+ throw rejected?.reason ?? new Error("DNS lookup returned no addresses");
1354
+ }
1355
+ return addresses;
1356
+ } finally {
1357
+ signal?.removeEventListener("abort", cancel);
1358
+ }
1359
+ };
1011
1360
  }
1012
1361
  function v4Parts(ip) {
1013
1362
  if (!isIPv4(ip)) return null;
@@ -1054,7 +1403,7 @@ function isPrivateAddress(ip) {
1054
1403
  }
1055
1404
  return (v6[0] & 65024) === 64512 || (v6[0] & 65472) === 65152;
1056
1405
  }
1057
- async function assertPublicHost(url, resolve3) {
1406
+ async function assertPublicHost(url, resolve3, signal) {
1058
1407
  if (url.protocol !== "http:" && url.protocol !== "https:") {
1059
1408
  throw new BlockedAddressError(`refused ${url.protocol}//: only http and https are allowed`);
1060
1409
  }
@@ -1064,7 +1413,9 @@ async function assertPublicHost(url, resolve3) {
1064
1413
  }
1065
1414
  let addresses;
1066
1415
  try {
1067
- addresses = await resolve3(host);
1416
+ if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
1417
+ addresses = await resolve3(host, signal);
1418
+ if (signal?.aborted) throw signal.reason ?? new Error("network request cancelled");
1068
1419
  } catch (e) {
1069
1420
  throw new BlockedAddressError(`refused ${url.hostname}: cannot resolve (${e instanceof Error ? e.message : String(e)})`);
1070
1421
  }
@@ -1082,14 +1433,20 @@ async function safeFetch(url, opts) {
1082
1433
  const maxRedirects = opts.maxRedirects ?? 3;
1083
1434
  const maxBytes = opts.maxBytes ?? 2e6;
1084
1435
  const controller = new AbortController();
1085
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1e4);
1436
+ const cancel = () => controller.abort(opts.signal?.reason);
1437
+ if (opts.signal?.aborted) cancel();
1438
+ else opts.signal?.addEventListener("abort", cancel, { once: true });
1439
+ const timer = setTimeout(
1440
+ () => controller.abort(new Error("network request deadline exceeded")),
1441
+ opts.timeoutMs ?? 1e4
1442
+ );
1086
1443
  try {
1087
1444
  let current = new URL(url);
1088
1445
  let method = opts.method ?? "GET";
1089
1446
  let headers = opts.headers;
1090
1447
  let body = opts.body;
1091
1448
  for (let hop = 0; hop <= maxRedirects; hop++) {
1092
- await assertPublicHost(current, opts.resolve);
1449
+ await assertPublicHost(current, opts.resolve, controller.signal);
1093
1450
  const res = await opts.fetch(current.toString(), {
1094
1451
  method,
1095
1452
  headers,
@@ -1122,6 +1479,7 @@ async function safeFetch(url, opts) {
1122
1479
  throw new BlockedAddressError(`too many redirects (>${maxRedirects})`);
1123
1480
  } finally {
1124
1481
  clearTimeout(timer);
1482
+ opts.signal?.removeEventListener("abort", cancel);
1125
1483
  }
1126
1484
  }
1127
1485
  function headerMap(res) {
@@ -1171,7 +1529,8 @@ var fetchUrl = async (args, ctx) => {
1171
1529
  fetch: ctx.fetch,
1172
1530
  maxRedirects: 3,
1173
1531
  timeoutMs: 1e4,
1174
- maxBytes: 2e6
1532
+ maxBytes: 2e6,
1533
+ signal: ctx.signal
1175
1534
  });
1176
1535
  const type = res.headers["content-type"] ?? "";
1177
1536
  const text = /html|xml/i.test(type) ? htmlToText(res.text) : res.text.replace(/\s+/g, " ").trim();
@@ -1182,7 +1541,7 @@ var fetchUrl = async (args, ctx) => {
1182
1541
  };
1183
1542
 
1184
1543
  // src/builtins/http-request.ts
1185
- import { readFileSync as readFileSync2 } from "node:fs";
1544
+ import { readFileSync as readFileSync3 } from "node:fs";
1186
1545
  var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "DELETE"]);
1187
1546
  var httpRequest = async (args, ctx) => {
1188
1547
  const name = typeof args.name === "string" ? args.name : "";
@@ -1225,7 +1584,7 @@ var httpRequest = async (args, ctx) => {
1225
1584
  const headers = { accept: "application/json" };
1226
1585
  if (endpoint.headersFile) {
1227
1586
  try {
1228
- for (const [k, v] of Object.entries(JSON.parse(readFileSync2(endpoint.headersFile, "utf8")))) {
1587
+ for (const [k, v] of Object.entries(JSON.parse(readFileSync3(endpoint.headersFile, "utf8")))) {
1229
1588
  if (typeof v === "string") headers[k] = v;
1230
1589
  }
1231
1590
  } catch {
@@ -1242,7 +1601,8 @@ var httpRequest = async (args, ctx) => {
1242
1601
  headers,
1243
1602
  body,
1244
1603
  timeoutMs: 1e4,
1245
- maxBytes: 2e6
1604
+ maxBytes: 2e6,
1605
+ signal: ctx.signal
1246
1606
  });
1247
1607
  return JSON.stringify({ status: res.status, body: capTo(res.text, FETCH_TEXT_CAP) });
1248
1608
  } catch (e) {
@@ -1251,7 +1611,8 @@ var httpRequest = async (args, ctx) => {
1251
1611
  };
1252
1612
 
1253
1613
  // src/builtins/sql-query.ts
1254
- import { readFileSync as readFileSync3 } from "node:fs";
1614
+ import { readFileSync as readFileSync4 } from "node:fs";
1615
+ import { spawn } from "node:child_process";
1255
1616
 
1256
1617
  // src/sql-guard.ts
1257
1618
  var SqlNotReadOnlyError = class extends Error {
@@ -1305,16 +1666,160 @@ function assertReadOnlySelect(sql) {
1305
1666
  }
1306
1667
 
1307
1668
  // src/builtins/sql-query.ts
1669
+ var SQLITE_PROCESS_SOURCE = String.raw`
1670
+ const { DatabaseSync } = require("node:sqlite");
1671
+
1672
+ let input = "";
1673
+ process.stdin.setEncoding("utf8");
1674
+ process.stdin.on("data", (chunk) => { input += chunk; });
1675
+ process.stdin.on("end", () => {
1676
+ let handle;
1677
+ try {
1678
+ const request = JSON.parse(input);
1679
+ handle = new DatabaseSync(request.path, {
1680
+ readOnly: true,
1681
+ timeout: request.busyTimeoutMs,
1682
+ });
1683
+ // Keep SQLite-owned allocations bounded too, not only V8's heap. Temp
1684
+ // work stays in the limited heap instead of consuming unbounded disk,
1685
+ // and mmap is disabled so reads cannot bypass the allocation ceiling.
1686
+ const heapLimit = handle.prepare(
1687
+ "PRAGMA hard_heap_limit = " + request.sqliteHeapLimitBytes
1688
+ ).get();
1689
+ const effectiveHeapLimit = Number(Object.values(heapLimit ?? {})[0]);
1690
+ if (!(effectiveHeapLimit > 0 && effectiveHeapLimit <= request.sqliteHeapLimitBytes)) {
1691
+ throw new Error("SQLite heap limit could not be enforced");
1692
+ }
1693
+ handle.exec("PRAGMA temp_store = MEMORY; PRAGMA mmap_size = 0");
1694
+ const statement = handle.prepare(request.sql);
1695
+ const rows = [];
1696
+ let columns = [];
1697
+ let truncated = false;
1698
+
1699
+ for (const record of statement.iterate()) {
1700
+ if (rows.length === request.rowCap) {
1701
+ truncated = true;
1702
+ break;
1703
+ }
1704
+ if (rows.length === 0) columns = Object.keys(record);
1705
+ rows.push(columns.map((column) => record[column]));
1706
+ }
1707
+
1708
+ process.stdout.write(JSON.stringify({
1709
+ ok: true,
1710
+ result: { columns, rows, truncated },
1711
+ }));
1712
+ } catch (error) {
1713
+ process.stdout.write(JSON.stringify({
1714
+ ok: false,
1715
+ error: error instanceof Error ? error.message : String(error),
1716
+ }));
1717
+ } finally {
1718
+ if (handle) handle.close();
1719
+ }
1720
+ });
1721
+ `;
1722
+ var SQLITE_IPC_CAP_BYTES = 1024 * 1024;
1723
+ var SQLITE_HEAP_CAP_BYTES = 32 * 1024 * 1024;
1724
+ function executeSqliteQuery(path, sql, options = {}) {
1725
+ const timeoutMs = options.timeoutMs ?? SQL_TIMEOUT_MS;
1726
+ if (options.signal?.aborted) return Promise.reject(new Error("SQLite query cancelled"));
1727
+ return new Promise((resolve3, reject) => {
1728
+ const child = spawn(process.execPath, [
1729
+ "--no-warnings",
1730
+ "--max-old-space-size=32",
1731
+ "--input-type=commonjs",
1732
+ "--eval",
1733
+ SQLITE_PROCESS_SOURCE
1734
+ ], {
1735
+ // The query process needs no credentials or runtime configuration.
1736
+ // In particular, do not let NODE_OPTIONS weaken its heap boundary.
1737
+ env: {},
1738
+ stdio: ["pipe", "pipe", "ignore"],
1739
+ windowsHide: true
1740
+ });
1741
+ let settled = false;
1742
+ let stopError;
1743
+ let stdout = "";
1744
+ let stdoutBytes = 0;
1745
+ const cleanup = () => {
1746
+ clearTimeout(timer);
1747
+ options.signal?.removeEventListener("abort", onAbort);
1748
+ };
1749
+ const stop = (error) => {
1750
+ if (settled || stopError) return;
1751
+ stopError = error;
1752
+ cleanup();
1753
+ child.kill("SIGKILL");
1754
+ };
1755
+ const onAbort = () => stop(new Error("SQLite query cancelled"));
1756
+ const timer = setTimeout(
1757
+ () => stop(new Error(`SQLite query timed out after ${timeoutMs}ms`)),
1758
+ timeoutMs
1759
+ );
1760
+ options.signal?.addEventListener("abort", onAbort, { once: true });
1761
+ child.stdout.setEncoding("utf8");
1762
+ child.stdout.on("data", (chunk) => {
1763
+ if (stopError) return;
1764
+ stdoutBytes += Buffer.byteLength(chunk);
1765
+ if (stdoutBytes > SQLITE_IPC_CAP_BYTES) {
1766
+ stop(new Error("SQLite query result exceeded the materialization limit"));
1767
+ return;
1768
+ }
1769
+ stdout += chunk;
1770
+ });
1771
+ child.once("error", (error) => {
1772
+ if (settled) return;
1773
+ settled = true;
1774
+ cleanup();
1775
+ reject(error);
1776
+ });
1777
+ child.once("close", (code) => {
1778
+ if (settled) return;
1779
+ settled = true;
1780
+ cleanup();
1781
+ if (stopError) {
1782
+ reject(stopError);
1783
+ return;
1784
+ }
1785
+ if (code !== 0) {
1786
+ reject(new Error(`SQLite query process exited before returning a result (code ${code})`));
1787
+ return;
1788
+ }
1789
+ try {
1790
+ const message = JSON.parse(stdout);
1791
+ if (message.ok && message.result) resolve3(message.result);
1792
+ else reject(new Error(message.error ?? "SQLite query process failed"));
1793
+ } catch {
1794
+ reject(new Error("SQLite query process returned an invalid result"));
1795
+ }
1796
+ });
1797
+ child.stdin.on("error", () => {
1798
+ });
1799
+ child.stdin.end(JSON.stringify({
1800
+ path,
1801
+ sql,
1802
+ rowCap: SQL_ROW_CAP,
1803
+ busyTimeoutMs: timeoutMs,
1804
+ sqliteHeapLimitBytes: SQLITE_HEAP_CAP_BYTES
1805
+ }));
1806
+ });
1807
+ }
1308
1808
  function envelope(columns, rows, truncated) {
1309
1809
  let kept = rows;
1310
1810
  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 });
1811
+ try {
1812
+ let out = JSON.stringify({ columns, rows: kept, truncated: cut });
1813
+ while (resultBytes(out) > RESULT_CAP && kept.length > 0) {
1814
+ kept = kept.slice(0, Math.floor(kept.length / 2));
1815
+ cut = true;
1816
+ out = JSON.stringify({ columns, rows: kept, truncated: cut });
1817
+ }
1818
+ if (resultBytes(out) <= RESULT_CAP) return out;
1819
+ } catch {
1820
+ return toolError("database result could not be serialized");
1316
1821
  }
1317
- return capResult(out);
1822
+ return toolError("database result metadata exceeded the response limit");
1318
1823
  }
1319
1824
  var sqlQuery = async (args, ctx) => {
1320
1825
  const name = typeof args.name === "string" ? args.name : "";
@@ -1332,16 +1837,8 @@ var sqlQuery = async (args, ctx) => {
1332
1837
  }
1333
1838
  try {
1334
1839
  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
- }
1840
+ const result = await executeSqliteQuery(db.sqlite, sql, { signal: ctx.signal });
1841
+ return envelope(result.columns, result.rows, result.truncated);
1345
1842
  }
1346
1843
  let pg;
1347
1844
  try {
@@ -1352,19 +1849,32 @@ var sqlQuery = async (args, ctx) => {
1352
1849
  const Client = pg.Client ?? pg.default?.Client;
1353
1850
  if (!Client) return toolError(`the installed "pg" module does not export a Client`);
1354
1851
  const client = new Client({
1355
- connectionString: readFileSync3(db.postgresUrlFile, "utf8").trim(),
1852
+ connectionString: readFileSync4(db.postgresUrlFile, "utf8").trim(),
1356
1853
  // never logged (D4)
1357
- statement_timeout: SQL_TIMEOUT_MS
1854
+ statement_timeout: SQL_TIMEOUT_MS,
1855
+ connectionTimeoutMillis: SQL_TIMEOUT_MS,
1856
+ query_timeout: SQL_TIMEOUT_MS
1358
1857
  });
1359
- await client.connect();
1858
+ let cancelled = ctx.signal?.aborted ?? false;
1859
+ const cancel = () => {
1860
+ cancelled = true;
1861
+ void client.end().catch(() => {
1862
+ });
1863
+ };
1864
+ if (cancelled) throw new Error("Postgres query cancelled");
1865
+ ctx.signal?.addEventListener("abort", cancel, { once: true });
1360
1866
  try {
1867
+ await client.connect();
1868
+ if (cancelled) throw new Error("Postgres query cancelled");
1361
1869
  await client.query("BEGIN; SET TRANSACTION READ ONLY;");
1362
1870
  const res = await client.query({ text: sql, rowMode: "array" });
1871
+ if (cancelled) throw new Error("Postgres query cancelled");
1363
1872
  const columns = res.fields.map((f) => f.name);
1364
1873
  const rows = res.rows.slice(0, SQL_ROW_CAP);
1365
1874
  return envelope(columns, rows, res.rows.length > SQL_ROW_CAP);
1366
1875
  } finally {
1367
- await client.query("ROLLBACK").catch(() => {
1876
+ ctx.signal?.removeEventListener("abort", cancel);
1877
+ if (!cancelled) await client.query("ROLLBACK").catch(() => {
1368
1878
  });
1369
1879
  await client.end().catch(() => {
1370
1880
  });
@@ -1387,13 +1897,14 @@ var webSearch = async (args, ctx) => {
1387
1897
  try {
1388
1898
  let hits;
1389
1899
  if (brave) {
1390
- ctx.log("debug", `web_search via brave (${count} results)`);
1900
+ ctx.log("debug", "tool.web_search_requested", { count, provider_brave: true });
1391
1901
  const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
1392
1902
  const res = await safeFetch(url, {
1393
1903
  resolve: ctx.resolve,
1394
1904
  fetch: ctx.fetch,
1395
1905
  timeoutMs: 1e4,
1396
1906
  maxBytes: 2e6,
1907
+ signal: ctx.signal,
1397
1908
  headers: { accept: "application/json", "X-Subscription-Token": brave }
1398
1909
  });
1399
1910
  if (res.status !== 200) return toolError(`brave search returned HTTP ${res.status}`);
@@ -1404,12 +1915,13 @@ var webSearch = async (args, ctx) => {
1404
1915
  snippet: r.description ?? ""
1405
1916
  }));
1406
1917
  } else {
1407
- ctx.log("debug", `web_search via tavily (${count} results)`);
1918
+ ctx.log("debug", "tool.web_search_requested", { count, provider_brave: false });
1408
1919
  const res = await safeFetch("https://api.tavily.com/search", {
1409
1920
  resolve: ctx.resolve,
1410
1921
  fetch: ctx.fetch,
1411
1922
  timeoutMs: 1e4,
1412
1923
  maxBytes: 2e6,
1924
+ signal: ctx.signal,
1413
1925
  method: "POST",
1414
1926
  headers: { "content-type": "application/json" },
1415
1927
  body: JSON.stringify({ api_key: tavily, query, max_results: count })
@@ -1432,14 +1944,70 @@ var webSearch = async (args, ctx) => {
1432
1944
  var RESULT_CAP = 2e4;
1433
1945
  var TRUNCATION_MARKER = "\u2026[truncated]";
1434
1946
  var FETCH_TEXT_CAP = RESULT_CAP - 4096;
1947
+ function resultBytes(s) {
1948
+ return Buffer.byteLength(s, "utf8");
1949
+ }
1950
+ function utf8Prefix(s, maxBytes) {
1951
+ if (maxBytes <= 0) return "";
1952
+ const bytes = Buffer.from(s, "utf8");
1953
+ if (bytes.length <= maxBytes) return s;
1954
+ let end = maxBytes;
1955
+ while (end > 0) {
1956
+ const candidate = bytes.subarray(0, end).toString("utf8");
1957
+ if (!candidate.endsWith("\uFFFD") && resultBytes(candidate) <= maxBytes) return candidate;
1958
+ end--;
1959
+ }
1960
+ return "";
1961
+ }
1962
+ function fitJsonString(value, build) {
1963
+ const codePoints = Array.from(value);
1964
+ let low = 0;
1965
+ let high = codePoints.length;
1966
+ let best = build("");
1967
+ while (low <= high) {
1968
+ const middle = Math.floor((low + high) / 2);
1969
+ const candidate = build(codePoints.slice(0, middle).join(""));
1970
+ if (resultBytes(candidate) <= RESULT_CAP) {
1971
+ best = candidate;
1972
+ low = middle + 1;
1973
+ } else {
1974
+ high = middle - 1;
1975
+ }
1976
+ }
1977
+ return best;
1978
+ }
1435
1979
  function capTo(s, limit) {
1436
- return s.length <= limit ? s : s.slice(0, limit) + TRUNCATION_MARKER;
1980
+ if (resultBytes(s) <= limit) return s;
1981
+ const markerBytes = resultBytes(TRUNCATION_MARKER);
1982
+ return utf8Prefix(s, Math.max(0, limit - markerBytes)) + TRUNCATION_MARKER;
1437
1983
  }
1438
1984
  function capResult(s) {
1439
1985
  return capTo(s, RESULT_CAP);
1440
1986
  }
1987
+ function boundToolResult(content) {
1988
+ if (resultBytes(content) <= RESULT_CAP) return content;
1989
+ try {
1990
+ const parsed = JSON.parse(content);
1991
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1992
+ const record = parsed;
1993
+ if (typeof record.error === "string") {
1994
+ const message = typeof record.message === "string" ? record.message : record.error;
1995
+ return toolError(message);
1996
+ }
1997
+ }
1998
+ return fitJsonString(content, (result) => JSON.stringify({ result, truncated: true }));
1999
+ } catch {
2000
+ return capResult(content);
2001
+ }
2002
+ }
1441
2003
  function toolError(message) {
1442
- return JSON.stringify({ error: "tool_failed", message });
2004
+ const plain = JSON.stringify({ error: "tool_failed", message });
2005
+ if (resultBytes(plain) <= RESULT_CAP) return plain;
2006
+ return fitJsonString(message, (prefix) => JSON.stringify({
2007
+ error: "tool_failed",
2008
+ message: prefix,
2009
+ truncated: true
2010
+ }));
1443
2011
  }
1444
2012
  var NO_CONTEXT = "no runtime context: this built-in needs the runtime's network context";
1445
2013
  function offlineContext() {
@@ -1467,12 +2035,148 @@ var BUILTINS = {
1467
2035
  sql_query: sqlQuery
1468
2036
  };
1469
2037
 
2038
+ // src/side-effect-ledger.ts
2039
+ import { createHash } from "node:crypto";
2040
+ import { chmodSync, mkdirSync as mkdirSync2 } from "node:fs";
2041
+ import { dirname as dirname4 } from "node:path";
2042
+ import { DatabaseSync } from "node:sqlite";
2043
+ var DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES = 1e4;
2044
+ function canonicalJson(value) {
2045
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
2046
+ return JSON.stringify(value);
2047
+ }
2048
+ if (typeof value === "number") {
2049
+ if (!Number.isFinite(value)) throw new Error("operation arguments must be finite JSON values");
2050
+ return JSON.stringify(value);
2051
+ }
2052
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
2053
+ if (typeof value === "object") {
2054
+ const record = value;
2055
+ return `{${Object.keys(record).sort().map(
2056
+ (key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
2057
+ ).join(",")}}`;
2058
+ }
2059
+ throw new Error("operation arguments must be JSON values");
2060
+ }
2061
+ function operationSha256(tool, args) {
2062
+ const normalized = tool === "http_request" && typeof args.method === "string" ? { ...args, method: args.method.toUpperCase() } : args;
2063
+ return createHash("sha256").update(`${tool}
2064
+ ${canonicalJson(normalized)}`, "utf8").digest("hex");
2065
+ }
2066
+ var SideEffectLedger = class {
2067
+ constructor(path, maxEntries = DEFAULT_SIDE_EFFECT_LEDGER_MAX_ENTRIES) {
2068
+ this.maxEntries = maxEntries;
2069
+ mkdirSync2(dirname4(path), { recursive: true, mode: 448 });
2070
+ this.db = new DatabaseSync(path);
2071
+ chmodSync(path, 384);
2072
+ this.db.exec("PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA busy_timeout=1000;");
2073
+ this.db.exec(`
2074
+ CREATE TABLE IF NOT EXISTS side_effect_claims (
2075
+ idempotency_key TEXT PRIMARY KEY,
2076
+ operation_sha256 TEXT NOT NULL,
2077
+ confirmation_id TEXT NOT NULL UNIQUE,
2078
+ expires_at TEXT NOT NULL,
2079
+ state TEXT NOT NULL CHECK (state IN ('in_progress', 'completed')),
2080
+ created_at TEXT NOT NULL,
2081
+ completed_at TEXT
2082
+ ) STRICT
2083
+ `);
2084
+ }
2085
+ db;
2086
+ claim(grant, now) {
2087
+ this.db.exec("BEGIN IMMEDIATE");
2088
+ try {
2089
+ const prior = this.db.prepare(`
2090
+ SELECT idempotency_key, operation_sha256, confirmation_id, state
2091
+ FROM side_effect_claims WHERE idempotency_key = ? OR confirmation_id = ?
2092
+ `).get(grant.idempotency_key, grant.confirmation_id);
2093
+ if (prior) {
2094
+ this.db.exec("COMMIT");
2095
+ if (prior.operation_sha256 !== grant.operation_sha256 || prior.confirmation_id !== grant.confirmation_id || prior.idempotency_key !== grant.idempotency_key) return { kind: "conflict" };
2096
+ return { kind: prior.state === "completed" ? "completed" : "ambiguous" };
2097
+ }
2098
+ const count = this.db.prepare("SELECT COUNT(*) AS count FROM side_effect_claims").get();
2099
+ if (count.count >= this.maxEntries) {
2100
+ this.db.exec("COMMIT");
2101
+ return { kind: "full" };
2102
+ }
2103
+ this.db.prepare(`
2104
+ INSERT INTO side_effect_claims
2105
+ (idempotency_key, operation_sha256, confirmation_id, expires_at, state, created_at)
2106
+ VALUES (?, ?, ?, ?, 'in_progress', ?)
2107
+ `).run(
2108
+ grant.idempotency_key,
2109
+ grant.operation_sha256,
2110
+ grant.confirmation_id,
2111
+ grant.expires_at,
2112
+ now.toISOString()
2113
+ );
2114
+ this.db.exec("COMMIT");
2115
+ return { kind: "execute" };
2116
+ } catch (error) {
2117
+ try {
2118
+ this.db.exec("ROLLBACK");
2119
+ } catch {
2120
+ }
2121
+ throw error;
2122
+ }
2123
+ }
2124
+ complete(grant, now) {
2125
+ const result = this.db.prepare(`
2126
+ UPDATE side_effect_claims SET state = 'completed', completed_at = ?
2127
+ WHERE idempotency_key = ? AND operation_sha256 = ? AND confirmation_id = ?
2128
+ AND state = 'in_progress'
2129
+ `).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2130
+ if (result.changes !== 1) throw new Error("side-effect claim could not be completed");
2131
+ }
2132
+ /** Content-free inventory for an operator investigating an interrupted effect. */
2133
+ listAmbiguous() {
2134
+ return this.db.prepare(`
2135
+ SELECT idempotency_key, operation_sha256, confirmation_id, expires_at, created_at
2136
+ FROM side_effect_claims WHERE state='in_progress' ORDER BY created_at, idempotency_key
2137
+ `).all();
2138
+ }
2139
+ /**
2140
+ * Record the operator's external reconciliation result. `completed` prevents
2141
+ * replay; `not_applied` removes the pre-effect claim so the exact confirmed
2142
+ * operation may be retried. Every binding field must match the durable row.
2143
+ */
2144
+ reconcile(grant, outcome, now) {
2145
+ this.db.exec("BEGIN IMMEDIATE");
2146
+ try {
2147
+ const row = this.db.prepare(`
2148
+ SELECT state FROM side_effect_claims
2149
+ WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=?
2150
+ `).get(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2151
+ if (!row) throw new Error("ambiguous side-effect claim not found or binding mismatch");
2152
+ if (row.state !== "in_progress") throw new Error("side-effect claim is already completed");
2153
+ if (outcome === "completed") {
2154
+ this.db.prepare(`
2155
+ UPDATE side_effect_claims SET state='completed', completed_at=?
2156
+ WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
2157
+ `).run(now.toISOString(), grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2158
+ } else {
2159
+ this.db.prepare(`
2160
+ DELETE FROM side_effect_claims
2161
+ WHERE idempotency_key=? AND operation_sha256=? AND confirmation_id=? AND state='in_progress'
2162
+ `).run(grant.idempotency_key, grant.operation_sha256, grant.confirmation_id);
2163
+ }
2164
+ this.db.exec("COMMIT");
2165
+ } catch (error) {
2166
+ try {
2167
+ this.db.exec("ROLLBACK");
2168
+ } catch {
2169
+ }
2170
+ throw error;
2171
+ }
2172
+ }
2173
+ close() {
2174
+ this.db.close();
2175
+ }
2176
+ };
2177
+
1470
2178
  // src/skill-invoker.ts
1471
2179
  var SkillInvoker = class {
1472
- skills = /* @__PURE__ */ new Map();
1473
- sources = /* @__PURE__ */ new Map();
1474
- skillsDir;
1475
- ctx;
1476
2180
  /**
1477
2181
  * `ctx` carries everything a built-in may touch: the network, DNS, the clock
1478
2182
  * and the key files. A caller that has no runtime (the loader tests, and any
@@ -1480,10 +2184,15 @@ var SkillInvoker = class {
1480
2184
  * that need nothing still run and the ones that need the network fail as a
1481
2185
  * tool_failed result instead of throwing.
1482
2186
  */
1483
- constructor(skillsDir, ctx) {
2187
+ constructor(skillsDir, ctx, sideEffectLedger) {
2188
+ this.sideEffectLedger = sideEffectLedger;
1484
2189
  this.skillsDir = skillsDir;
1485
2190
  this.ctx = ctx ?? offlineContext();
1486
2191
  }
2192
+ skills = /* @__PURE__ */ new Map();
2193
+ sources = /* @__PURE__ */ new Map();
2194
+ skillsDir;
2195
+ ctx;
1487
2196
  /**
1488
2197
  * Load all skill manifests from the skills directory. Supports two
1489
2198
  * layouts: flat `<skillsDir>/*.json` files, and one level of nesting via
@@ -1491,7 +2200,7 @@ var SkillInvoker = class {
1491
2200
  */
1492
2201
  loadSkills() {
1493
2202
  if (!existsSync3(this.skillsDir)) {
1494
- console.warn(`[SkillInvoker] Skills directory not found: ${this.skillsDir}`);
2203
+ writeServiceLog("warn", "skills.directory_missing");
1495
2204
  return;
1496
2205
  }
1497
2206
  const flat = [], manifests = [], skillMds = [];
@@ -1507,40 +2216,38 @@ var SkillInvoker = class {
1507
2216
  }
1508
2217
  for (const p of [...flat.sort(), ...manifests.sort()]) this.loadManifestFile(p);
1509
2218
  for (const p of skillMds.sort()) this.loadSkillMdFile(p);
1510
- console.log(`[SkillInvoker] Loaded ${this.skills.size} skills`);
2219
+ writeServiceLog("info", "skills.loaded", { count: this.skills.size });
1511
2220
  }
1512
2221
  /** Later file wins, with a warning naming both paths (spec §3). */
1513
2222
  register(manifest, path) {
1514
2223
  const prior = this.sources.get(manifest.name);
1515
- if (prior) console.warn(`[SkillInvoker] Duplicate skill "${manifest.name}": ${path} overrides ${prior}`);
2224
+ if (prior) writeServiceLog("warn", "skills.duplicate_replaced");
1516
2225
  this.skills.set(manifest.name, manifest);
1517
2226
  this.sources.set(manifest.name, path);
1518
2227
  }
1519
2228
  loadSkillMdFile(path) {
1520
2229
  try {
1521
- this.register(parseSkillMd(readFileSync4(path, "utf-8"), path), path);
2230
+ this.register(parseSkillMd(readFileSync5(path, "utf-8"), path), path);
1522
2231
  } catch (err) {
1523
- console.warn(`[SkillInvoker] Skipping ${path}: ${err instanceof Error ? err.message : String(err)}`);
2232
+ writeServiceLog("warn", "skills.skill_md_invalid", {}, err);
1524
2233
  }
1525
2234
  }
1526
2235
  loadManifestFile(path) {
1527
2236
  try {
1528
- const raw = readFileSync4(path, "utf-8");
2237
+ const raw = readFileSync5(path, "utf-8");
1529
2238
  const manifest = JSON.parse(raw);
1530
2239
  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
- );
2240
+ writeServiceLog("warn", "skills.manifest_handler_invalid");
1534
2241
  return;
1535
2242
  }
1536
2243
  if (manifest.name && manifest.description) {
1537
2244
  this.register(manifest, path);
1538
2245
  } else {
1539
2246
  const missing = !manifest.name ? "name" : "description";
1540
- console.warn(`[SkillInvoker] Skipping manifest ${path}: missing "${missing}"`);
2247
+ writeServiceLog("warn", "skills.manifest_field_missing", { name_missing: missing === "name" });
1541
2248
  }
1542
2249
  } catch (err) {
1543
- console.warn(`[SkillInvoker] Failed to load skill ${path}:`, err);
2250
+ writeServiceLog("warn", "skills.manifest_load_failed", {}, err);
1544
2251
  }
1545
2252
  }
1546
2253
  /** Convert loaded skills to OpenAI tool definitions for LLM dispatch. */
@@ -1580,27 +2287,76 @@ var SkillInvoker = class {
1580
2287
  async invoke(name, args, context) {
1581
2288
  const skill = this.skills.get(name);
1582
2289
  if (!skill) {
1583
- return JSON.stringify({ error: `Unknown skill: ${name}` });
2290
+ return boundToolResult(JSON.stringify({ error: `Unknown skill: ${name}` }));
2291
+ }
2292
+ const execute = async () => {
2293
+ const handler = skill.handler;
2294
+ if (handler.startsWith("builtin:")) {
2295
+ return this.invokeBuiltin(handler.slice(8), args, context?.signal);
2296
+ }
2297
+ if (handler.startsWith("script:")) {
2298
+ return this.invokeScript(handler.slice(7), args, context?.signal);
2299
+ }
2300
+ return boundToolResult(JSON.stringify({ error: `Unknown handler type: ${handler}` }));
2301
+ };
2302
+ return context?.sideEffect ? this.invokeSideEffect(name, args, context.sideEffect, execute) : execute();
2303
+ }
2304
+ async invokeSideEffect(name, args, grant, execute) {
2305
+ const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
2306
+ if (name !== "http_request" || !(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
2307
+ return toolError("This tool has no supported side-effect replay contract");
2308
+ }
2309
+ if (grant.tool !== name || operationSha256(name, args) !== grant.operation_sha256) {
2310
+ return toolError("Confirmed operation digest does not match this tool call");
2311
+ }
2312
+ const now = this.ctx.now();
2313
+ const expiresAt = Date.parse(grant.expires_at);
2314
+ if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
2315
+ return toolError("Confirmed operation grant is expired or invalid");
2316
+ }
2317
+ if (!this.sideEffectLedger) {
2318
+ return toolError("Side-effect replay protection is unavailable");
2319
+ }
2320
+ let claim;
2321
+ try {
2322
+ claim = this.sideEffectLedger.claim(grant, now);
2323
+ } catch {
2324
+ return toolError("Side-effect replay claim could not be persisted");
1584
2325
  }
1585
- const handler = skill.handler;
1586
- if (handler.startsWith("builtin:")) {
1587
- return this.invokeBuiltin(handler.slice(8), args);
2326
+ if (claim.kind === "completed") return JSON.stringify({ status: "already_applied" });
2327
+ if (claim.kind === "ambiguous") {
2328
+ return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
1588
2329
  }
1589
- if (handler.startsWith("script:")) {
1590
- return this.invokeScript(handler.slice(7), args);
2330
+ if (claim.kind === "conflict") {
2331
+ return toolError("Idempotency key was already bound to a different confirmed operation");
1591
2332
  }
1592
- return JSON.stringify({ error: `Unknown handler type: ${handler}` });
2333
+ if (claim.kind === "full") {
2334
+ return toolError("Side-effect replay ledger is full; reconcile it before running new operations");
2335
+ }
2336
+ let result;
2337
+ try {
2338
+ result = await execute();
2339
+ } catch {
2340
+ return toolError("Side-effect outcome is ambiguous; reconcile it before retrying");
2341
+ }
2342
+ if (isToolFailure(result)) return result;
2343
+ try {
2344
+ this.sideEffectLedger.complete(grant, this.ctx.now());
2345
+ } catch {
2346
+ return toolError("Side-effect completed but replay state is ambiguous; reconcile before retrying");
2347
+ }
2348
+ return result;
1593
2349
  }
1594
- async invokeBuiltin(name, args) {
2350
+ async invokeBuiltin(name, args, signal) {
1595
2351
  const builtin = BUILTINS[name];
1596
2352
  if (!builtin) return JSON.stringify({ error: `Unknown builtin: ${name}` });
1597
2353
  try {
1598
- return capResult(await builtin(args, this.ctx));
2354
+ return boundToolResult(await builtin(args, { ...this.ctx, signal }));
1599
2355
  } catch (e) {
1600
2356
  return toolError(e instanceof Error ? e.message : String(e));
1601
2357
  }
1602
2358
  }
1603
- async invokeScript(scriptPath, args) {
2359
+ async invokeScript(scriptPath, args, signal) {
1604
2360
  const { resolve: resolve3, relative } = await import("node:path");
1605
2361
  const resolved = resolve3(this.skillsDir, scriptPath);
1606
2362
  const rel = relative(this.skillsDir, resolved);
@@ -1620,26 +2376,82 @@ var SkillInvoker = class {
1620
2376
  try {
1621
2377
  accessSync2(resolved, constants2.X_OK);
1622
2378
  } catch {
1623
- return JSON.stringify({ error: `Script not found or not executable: ${scriptPath}` });
2379
+ return toolError(`Script not found or not executable: ${scriptPath}`);
1624
2380
  }
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",
2381
+ if (signal?.aborted) return toolError("Script execution cancelled");
2382
+ const { spawn: spawn2 } = await import("node:child_process");
2383
+ return new Promise((resolveResult) => {
2384
+ const child = spawn2(resolved, [], {
1632
2385
  cwd: this.skillsDir,
1633
- env: { PATH: "/usr/bin:/bin", HOME: "/tmp" }
2386
+ env: { PATH: "/usr/bin:/bin", HOME: "/tmp" },
2387
+ stdio: ["pipe", "pipe", "ignore"],
2388
+ windowsHide: true,
2389
+ // A script may launch children. On POSIX, make one process group so
2390
+ // cancellation kills the whole operation rather than orphaning them.
2391
+ detached: process.platform !== "win32"
1634
2392
  });
1635
- return result;
1636
- } catch (err) {
1637
- return JSON.stringify({
1638
- error: `Script execution failed: ${err instanceof Error ? err.message : String(err)}`
2393
+ let stdout = "";
2394
+ let stdoutBytes = 0;
2395
+ let stopMessage = null;
2396
+ let settled = false;
2397
+ const cleanup = () => {
2398
+ clearTimeout(timer);
2399
+ signal?.removeEventListener("abort", onAbort);
2400
+ };
2401
+ const stop = (message) => {
2402
+ if (settled || stopMessage) return;
2403
+ stopMessage = message;
2404
+ if (process.platform !== "win32" && child.pid) {
2405
+ try {
2406
+ process.kill(-child.pid, "SIGKILL");
2407
+ } catch {
2408
+ child.kill("SIGKILL");
2409
+ }
2410
+ } else {
2411
+ child.kill("SIGKILL");
2412
+ }
2413
+ };
2414
+ const onAbort = () => stop("Script execution cancelled");
2415
+ const timer = setTimeout(() => stop("Script execution timed out after 30000ms"), 3e4);
2416
+ signal?.addEventListener("abort", onAbort, { once: true });
2417
+ child.stdout.setEncoding("utf8");
2418
+ child.stdout.on("data", (chunk) => {
2419
+ if (stopMessage) return;
2420
+ stdoutBytes += Buffer.byteLength(chunk);
2421
+ if (stdoutBytes > 1024 * 1024) {
2422
+ stop("Script output exceeded the materialization limit");
2423
+ return;
2424
+ }
2425
+ stdout += chunk;
1639
2426
  });
1640
- }
2427
+ child.once("error", () => {
2428
+ if (settled) return;
2429
+ settled = true;
2430
+ cleanup();
2431
+ resolveResult(toolError("Script execution failed"));
2432
+ });
2433
+ child.once("close", (code) => {
2434
+ if (settled) return;
2435
+ settled = true;
2436
+ cleanup();
2437
+ if (stopMessage) resolveResult(toolError(stopMessage));
2438
+ else if (code !== 0) resolveResult(toolError(`Script execution failed (code ${code ?? "unknown"})`));
2439
+ else resolveResult(boundToolResult(stdout));
2440
+ });
2441
+ child.stdin.on("error", () => {
2442
+ });
2443
+ child.stdin.end(JSON.stringify(args));
2444
+ });
1641
2445
  }
1642
2446
  };
2447
+ function isToolFailure(result) {
2448
+ try {
2449
+ const parsed = JSON.parse(result);
2450
+ return parsed.error === "tool_failed";
2451
+ } catch {
2452
+ return false;
2453
+ }
2454
+ }
1643
2455
 
1644
2456
  // src/tool-gate.ts
1645
2457
  function createLocalToolGate(policy) {
@@ -1688,7 +2500,7 @@ function createDynamicToolGate(policyFor) {
1688
2500
  }
1689
2501
 
1690
2502
  // src/data-plane.ts
1691
- import { readFileSync as readFileSync5 } from "node:fs";
2503
+ import { readFileSync as readFileSync6 } from "node:fs";
1692
2504
  import { WebSocket } from "node:http";
1693
2505
  import { homedir as homedir3 } from "node:os";
1694
2506
  import { join as join5 } from "node:path";
@@ -1733,7 +2545,7 @@ var NO_TURN_AUTHORIZATION = Object.freeze({
1733
2545
  function parseTurnAuthorization(value) {
1734
2546
  if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_AUTHORIZATION;
1735
2547
  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;
2548
+ 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
2549
  const grants = [];
1738
2550
  for (const value2 of raw.tool_grants) {
1739
2551
  if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_AUTHORIZATION;
@@ -1743,7 +2555,7 @@ function parseTurnAuthorization(value) {
1743
2555
  grants.push(Object.freeze({ tool: grant.tool, mode: "read" }));
1744
2556
  continue;
1745
2557
  }
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;
2558
+ 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
2559
  grants.push(Object.freeze({
1748
2560
  tool: grant.tool,
1749
2561
  mode: "side_effect",
@@ -1761,9 +2573,7 @@ function parseTurnAuthorization(value) {
1761
2573
  });
1762
2574
  }
1763
2575
  var SEEN_MAX = 5e3;
1764
- function errText(e) {
1765
- return e instanceof Error ? e.message : String(e);
1766
- }
2576
+ var TERMINAL_REPLY_ERRORS = /* @__PURE__ */ new Set(["agent_paused", "send_forbidden"]);
1767
2577
  var SecureComsDataPlane = class {
1768
2578
  identityId;
1769
2579
  connectionPath;
@@ -1773,7 +2583,18 @@ var SecureComsDataPlane = class {
1773
2583
  log;
1774
2584
  sleep;
1775
2585
  random;
2586
+ healthTimeoutMs;
1776
2587
  onMessage;
2588
+ noticeReplies;
2589
+ /**
2590
+ * Final-wave I1(b): sockets whose daemon advertised the `nack` feature. On
2591
+ * those a failed turn is NACKED — only that message is retried, behind the
2592
+ * daemon's backoff, and the socket (with every other channel's turn) stays
2593
+ * up. Anywhere else a failed turn still closes the socket, as before.
2594
+ */
2595
+ nackCapable = /* @__PURE__ */ new WeakSet();
2596
+ /** Sockets whose hello is unanswered: a refusal there is expected, not an error. */
2597
+ helloPending = /* @__PURE__ */ new WeakSet();
1777
2598
  seen = /* @__PURE__ */ new Set();
1778
2599
  stopped = false;
1779
2600
  running = false;
@@ -1800,6 +2621,16 @@ var SecureComsDataPlane = class {
1800
2621
  * generation is current, so a restart can never end up with two live loops.
1801
2622
  */
1802
2623
  generation = 0;
2624
+ /** Turn controllers grouped by the socket whose result would receive them. */
2625
+ turns = /* @__PURE__ */ new Map();
2626
+ /**
2627
+ * Phase 3 F2: per-channel processing chains. A channel's messages are
2628
+ * handled strictly one after another in arrival order (the receipt lookup,
2629
+ * the turn and the reply), never concurrently — even when a daemon flushes a
2630
+ * whole backlog at once. Different channels still run alongside each other.
2631
+ */
2632
+ channelChains = /* @__PURE__ */ new Map();
2633
+ healthController = null;
1803
2634
  constructor(cfg, onMessage) {
1804
2635
  this.identityId = cfg.identityId;
1805
2636
  const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
@@ -1807,10 +2638,7 @@ var SecureComsDataPlane = class {
1807
2638
  this.establishedMs = cfg.establishedMs ?? 5e3;
1808
2639
  this.initialDelayMs = cfg.initialDelayMs ?? 250;
1809
2640
  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
- });
2641
+ this.log = cfg.log ?? writeServiceLog;
1814
2642
  this.sleep = cfg.sleep ?? ((ms) => new Promise((resolve3) => {
1815
2643
  this.sleepTimer = setTimeout(() => {
1816
2644
  this.sleepTimer = null;
@@ -1818,7 +2646,9 @@ var SecureComsDataPlane = class {
1818
2646
  }, ms);
1819
2647
  }));
1820
2648
  this.random = cfg.random ?? Math.random;
2649
+ this.healthTimeoutMs = cfg.healthTimeoutMs ?? 2e3;
1821
2650
  this.onMessage = onMessage;
2651
+ this.noticeReplies = cfg.noticeReplies ?? /* @__PURE__ */ new Set();
1822
2652
  }
1823
2653
  /**
1824
2654
  * Resolves once the reconnect loop is running — NOT once attached. The loop
@@ -1837,13 +2667,19 @@ var SecureComsDataPlane = class {
1837
2667
  this.generation++;
1838
2668
  this.clearSettle();
1839
2669
  this.cancelSleep();
2670
+ this.healthController?.abort(new Error("transport stopped"));
2671
+ this.healthController = null;
2672
+ for (const controllers of this.turns.values()) {
2673
+ for (const controller of controllers) controller.abort(new Error("transport stopped"));
2674
+ }
2675
+ this.turns.clear();
1840
2676
  const ws = this.socket;
1841
2677
  this.socket = null;
1842
2678
  if (ws) {
1843
2679
  try {
1844
2680
  ws.close();
1845
2681
  } catch (e) {
1846
- this.log("warn", `error closing socket: ${errText(e)}`);
2682
+ this.log("warn", "transport.socket_close_failed", {}, e);
1847
2683
  }
1848
2684
  }
1849
2685
  }
@@ -1897,7 +2733,7 @@ var SecureComsDataPlane = class {
1897
2733
  await this.attemptOnce();
1898
2734
  delay = this.initialDelayMs;
1899
2735
  } catch (e) {
1900
- this.log("error", errText(e));
2736
+ this.log("error", "transport.connection_attempt_failed", {}, e);
1901
2737
  }
1902
2738
  if (!live()) break;
1903
2739
  await this.wait(this.random() * delay);
@@ -1906,10 +2742,22 @@ var SecureComsDataPlane = class {
1906
2742
  if (this.generation === generation) this.running = false;
1907
2743
  }
1908
2744
  async attemptOnce() {
1909
- const conn = JSON.parse(readFileSync5(this.connectionPath, "utf8"));
2745
+ const conn = JSON.parse(readFileSync6(this.connectionPath, "utf8"));
1910
2746
  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();
2747
+ const token = readFileSync6(conn.token_file, "utf8").trim();
2748
+ const healthController = new AbortController();
2749
+ this.healthController = healthController;
2750
+ const healthTimer = setTimeout(
2751
+ () => healthController.abort(new Error("health gate deadline exceeded")),
2752
+ this.healthTimeoutMs
2753
+ );
2754
+ let health;
2755
+ try {
2756
+ health = await (await fetch(`${httpBase}/healthz`, { signal: healthController.signal })).json();
2757
+ } finally {
2758
+ clearTimeout(healthTimer);
2759
+ if (this.healthController === healthController) this.healthController = null;
2760
+ }
1913
2761
  if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
1914
2762
  throw new Error(
1915
2763
  `gate refused: expected ${this.identityId}, connection file says ${conn.identity_id}, daemon on this port is ${health.identity_id}`
@@ -1923,7 +2771,7 @@ var SecureComsDataPlane = class {
1923
2771
  try {
1924
2772
  await new Promise((resolve3, reject) => {
1925
2773
  ws.addEventListener("error", (e) => {
1926
- reject(new Error(`socket error: ${errText(e?.message ?? e)}`));
2774
+ reject(Object.assign(new Error("socket_error"), { cause: e }));
1927
2775
  });
1928
2776
  ws.addEventListener("close", (e) => {
1929
2777
  if (established) resolve3();
@@ -1936,19 +2784,29 @@ var SecureComsDataPlane = class {
1936
2784
  }
1937
2785
  });
1938
2786
  ws.addEventListener("open", () => {
2787
+ this.helloPending.add(ws);
2788
+ try {
2789
+ ws.send(JSON.stringify({ type: "hello", features: ["nack"] }));
2790
+ } catch {
2791
+ }
1939
2792
  settle = setTimeout(() => {
1940
2793
  established = true;
1941
- this.log("info", `attached to ${conn.bridge_url}`);
2794
+ this.log("info", "transport.attached");
1942
2795
  }, this.establishedMs);
1943
2796
  this.settle = settle;
1944
2797
  });
1945
2798
  ws.addEventListener("message", (ev) => {
1946
- void this.handleFrame(ws, ev.data).catch((e) => {
1947
- this.log("error", `frame handling failed: ${errText(e)}`);
2799
+ void this.handleFrame(ws, ev.data, httpBase, token).catch((e) => {
2800
+ this.log("error", "transport.frame_handling_failed", {}, e);
1948
2801
  });
1949
2802
  });
1950
2803
  });
1951
2804
  } finally {
2805
+ const controllers = this.turns.get(ws);
2806
+ if (controllers) {
2807
+ for (const controller of controllers) controller.abort(new Error("transport disconnected"));
2808
+ this.turns.delete(ws);
2809
+ }
1952
2810
  if (settle !== null) clearTimeout(settle);
1953
2811
  if (this.settle === settle) this.settle = null;
1954
2812
  try {
@@ -1958,28 +2816,101 @@ var SecureComsDataPlane = class {
1958
2816
  if (this.socket === ws) this.socket = null;
1959
2817
  }
1960
2818
  }
1961
- async handleFrame(ws, data) {
2819
+ /**
2820
+ * Final-wave I1(b): fail ONE message. Against a nack-capable daemon, send a
2821
+ * `nack` — only this message is retried and the socket stays up for every
2822
+ * other channel's turn. Otherwise close the socket, the only failure
2823
+ * signal an older daemon understands (it re-buffers what was in flight).
2824
+ */
2825
+ failTurn(ws, messageId, reason) {
2826
+ if (this.nackCapable.has(ws) && ws.readyState === ws.OPEN) {
2827
+ try {
2828
+ ws.send(JSON.stringify({ type: "nack", message_id: messageId, reason }));
2829
+ return;
2830
+ } catch {
2831
+ }
2832
+ }
2833
+ try {
2834
+ ws.close();
2835
+ } catch {
2836
+ }
2837
+ }
2838
+ async handleFrame(ws, data, httpBase, token) {
1962
2839
  let frame;
1963
2840
  try {
1964
2841
  frame = JSON.parse(String(data));
1965
2842
  } catch (e) {
1966
- this.log("error", `ignoring unparseable frame: ${errText(e)}`);
2843
+ this.log("error", "transport.frame_parse_failed", {}, e);
2844
+ return;
2845
+ }
2846
+ if (frame?.type === "hello") {
2847
+ this.helloPending.delete(ws);
2848
+ if (Array.isArray(frame.features) && frame.features.includes("nack")) this.nackCapable.add(ws);
1967
2849
  return;
1968
2850
  }
1969
2851
  if (frame?.type === "error") {
1970
- this.log("error", `daemon error frame: ${String(frame.error)}: ${String(frame.message)}`);
2852
+ if (frame.in_reply_to === void 0 && this.helloPending.has(ws)) {
2853
+ this.helloPending.delete(ws);
2854
+ this.log("info", "transport.daemon_without_nack");
2855
+ return;
2856
+ }
2857
+ if (typeof frame.in_reply_to === "string" && TERMINAL_REPLY_ERRORS.has(String(frame.error))) {
2858
+ this.log("warn", "transport.reply_terminal", { paused: frame.error === "agent_paused" });
2859
+ this.remember(frame.in_reply_to);
2860
+ ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
2861
+ return;
2862
+ }
2863
+ this.log("error", "transport.daemon_error_frame");
2864
+ if (typeof frame.in_reply_to === "string") this.failTurn(ws, frame.in_reply_to, "reply_failed");
2865
+ return;
2866
+ }
2867
+ if (frame?.type === "sent" && typeof frame.in_reply_to === "string") {
2868
+ this.remember(frame.in_reply_to);
2869
+ ws.send(JSON.stringify({ type: "ack", message_id: frame.in_reply_to }));
1971
2870
  return;
1972
2871
  }
1973
2872
  if (frame?.type !== "message") return;
1974
2873
  const messageId = frame.message_id;
1975
2874
  if (typeof messageId !== "string") {
1976
- this.log("error", "ignoring message frame with no message_id");
2875
+ this.log("error", "transport.message_id_missing");
1977
2876
  return;
1978
2877
  }
1979
2878
  const channelId = String(frame.channel_id);
2879
+ const previous = this.channelChains.get(channelId) ?? Promise.resolve();
2880
+ const run = () => this.handleMessage(ws, frame, messageId, channelId, httpBase, token);
2881
+ const mine = previous.then(run, run);
2882
+ this.channelChains.set(channelId, mine);
2883
+ try {
2884
+ await mine;
2885
+ } finally {
2886
+ if (this.channelChains.get(channelId) === mine) this.channelChains.delete(channelId);
2887
+ }
2888
+ }
2889
+ async handleMessage(ws, frame, messageId, channelId, httpBase, token) {
2890
+ if (ws.readyState !== ws.OPEN) return;
2891
+ let turnController = null;
1980
2892
  try {
1981
2893
  if (!this.seen.has(messageId)) {
1982
- this.remember(messageId);
2894
+ const receipt = await fetch(
2895
+ `${httpBase}/v1/reply-receipt?in_reply_to=${encodeURIComponent(messageId)}`,
2896
+ {
2897
+ headers: { authorization: `Bearer ${token}` },
2898
+ signal: AbortSignal.timeout(this.healthTimeoutMs)
2899
+ }
2900
+ );
2901
+ if (!receipt.ok) throw new Error(`reply receipt lookup failed: HTTP ${receipt.status}`);
2902
+ if ((await receipt.json()).found === true) {
2903
+ this.remember(messageId);
2904
+ ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
2905
+ return;
2906
+ }
2907
+ turnController = new AbortController();
2908
+ let socketTurns = this.turns.get(ws);
2909
+ if (!socketTurns) {
2910
+ socketTurns = /* @__PURE__ */ new Set();
2911
+ this.turns.set(ws, socketTurns);
2912
+ }
2913
+ socketTurns.add(turnController);
1983
2914
  const reply = await this.onMessage({
1984
2915
  text: String(frame.text ?? ""),
1985
2916
  senderId: String(frame.sender_id ?? ""),
@@ -1987,23 +2918,31 @@ var SecureComsDataPlane = class {
1987
2918
  messageId,
1988
2919
  authorization: parseTurnAuthorization(frame.authorization),
1989
2920
  memory: parseTurnMemory(frame.memory)
1990
- });
2921
+ }, turnController.signal);
1991
2922
  if (reply != null) {
2923
+ const text = String(reply);
1992
2924
  ws.send(JSON.stringify({
1993
2925
  type: "send",
1994
2926
  channel_id: channelId,
1995
- text: String(reply),
1996
- in_reply_to: messageId
2927
+ text,
2928
+ in_reply_to: messageId,
2929
+ ...this.noticeReplies.has(text) ? { notice: true } : {}
1997
2930
  }));
2931
+ return;
1998
2932
  }
2933
+ this.remember(messageId);
2934
+ ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
2935
+ } else {
2936
+ ws.send(JSON.stringify({ type: "ack", message_id: messageId }));
1999
2937
  }
2000
2938
  } catch (e) {
2001
- this.log("error", `handler threw: ${errText(e)}`);
2939
+ this.log("error", "transport.handler_failed", {}, e);
2940
+ this.failTurn(ws, messageId, "turn_failed");
2002
2941
  } 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)}`);
2942
+ if (turnController) {
2943
+ const socketTurns = this.turns.get(ws);
2944
+ socketTurns?.delete(turnController);
2945
+ if (socketTurns?.size === 0) this.turns.delete(ws);
2007
2946
  }
2008
2947
  }
2009
2948
  }
@@ -2016,6 +2955,14 @@ function createSecureComsDataPlane(cfg, onMessage) {
2016
2955
  async function runToolCalls(toolCalls, skills, context) {
2017
2956
  const out = [];
2018
2957
  for (const call of toolCalls) {
2958
+ if (context?.signal?.aborted) {
2959
+ out.push({
2960
+ role: "tool",
2961
+ content: toolError("Tool execution cancelled"),
2962
+ tool_call_id: call.id
2963
+ });
2964
+ continue;
2965
+ }
2019
2966
  let args;
2020
2967
  try {
2021
2968
  args = JSON.parse(call.function.arguments);
@@ -2024,12 +2971,14 @@ async function runToolCalls(toolCalls, skills, context) {
2024
2971
  }
2025
2972
  let content;
2026
2973
  try {
2027
- content = await skills.invoke(call.function.name, args, context);
2974
+ content = boundToolResult(await skills.invoke(call.function.name, args, {
2975
+ roomId: context?.roomId,
2976
+ groupId: context?.groupId,
2977
+ signal: context?.signal,
2978
+ sideEffect: context?.sideEffects?.get(call.id)
2979
+ }));
2028
2980
  } catch (err) {
2029
- content = JSON.stringify({
2030
- error: "tool_failed",
2031
- message: String(err instanceof Error ? err.message : err)
2032
- });
2981
+ content = toolError(String(err instanceof Error ? err.message : err));
2033
2982
  }
2034
2983
  out.push({ role: "tool", content, tool_call_id: call.id });
2035
2984
  }
@@ -2039,14 +2988,24 @@ async function runToolCalls(toolCalls, skills, context) {
2039
2988
  // src/turn-loop.ts
2040
2989
  var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
2041
2990
  var DEFAULT_MAX_ITERATIONS = 5;
2991
+ var DEFAULT_MAX_CONCURRENT_TURNS = 4;
2992
+ var DEFAULT_TURN_TIMEOUT_MS = 3e5;
2993
+ var DAILY_LIMIT_RESPONSE = "This agent has reached its daily usage limit and will respond again after 00:00 UTC.";
2042
2994
  var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["echo", "web_search", "fetch_url", "sql_query", "current_time"]);
2043
2995
  function hasReadGrant(auth, toolName) {
2044
2996
  return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "read");
2045
2997
  }
2998
+ function hasSideEffectGrant(auth, toolName) {
2999
+ return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "side_effect");
3000
+ }
2046
3001
  function toolIsOffered(auth, toolName) {
2047
- return hasReadGrant(auth, toolName) && (READ_ONLY_TOOLS.has(toolName) || toolName === "http_request");
3002
+ if (READ_ONLY_TOOLS.has(toolName)) return hasReadGrant(auth, toolName);
3003
+ if (toolName === "http_request") {
3004
+ return hasReadGrant(auth, toolName) || hasSideEffectGrant(auth, toolName);
3005
+ }
3006
+ return false;
2048
3007
  }
2049
- function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
3008
+ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames, now = /* @__PURE__ */ new Date()) {
2050
3009
  if (auth.sender_membership !== "active") {
2051
3010
  return { allowed: false, reason: "Sender membership is not active" };
2052
3011
  }
@@ -2057,8 +3016,26 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
2057
3016
  try {
2058
3017
  const args = JSON.parse(argumentsJson);
2059
3018
  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" };
3019
+ if (method === "GET") {
3020
+ if (!hasReadGrant(auth, toolName)) {
3021
+ return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
3022
+ }
3023
+ } else {
3024
+ if (!(/* @__PURE__ */ new Set(["POST", "PUT", "DELETE"])).has(method)) {
3025
+ return { allowed: false, reason: `HTTP method "${method}" is not supported` };
3026
+ }
3027
+ const digest = operationSha256(toolName, args);
3028
+ const grant = auth.tool_grants.find(
3029
+ (candidate) => candidate.mode === "side_effect" && candidate.tool === toolName && candidate.operation_sha256 === digest
3030
+ );
3031
+ if (!grant) {
3032
+ return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
3033
+ }
3034
+ const expiresAt = Date.parse(grant.expires_at);
3035
+ if (!Number.isFinite(expiresAt) || expiresAt <= now.getTime()) {
3036
+ return { allowed: false, reason: "The confirmed operation grant is expired or invalid" };
3037
+ }
3038
+ return { allowed: true, reason: `Tool "${toolName}" has an exact confirmed operation grant`, sideEffect: grant };
2062
3039
  }
2063
3040
  } catch {
2064
3041
  return { allowed: false, reason: "Tool arguments are not valid JSON" };
@@ -2068,16 +3045,133 @@ function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
2068
3045
  }
2069
3046
  function buildHandler(deps) {
2070
3047
  const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
3048
+ const maxConcurrentTurns = deps.maxConcurrentTurns ?? DEFAULT_MAX_CONCURRENT_TURNS;
3049
+ const turnTimeoutMs = deps.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
2071
3050
  const log = deps.log ?? (() => {
2072
3051
  });
3052
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
2073
3053
  const { llm, memoryFor, skills, gate } = deps;
2074
- return async (m) => {
3054
+ const channelTails = /* @__PURE__ */ new Map();
3055
+ const slotWaiters = [];
3056
+ let activeTurns = 0;
3057
+ const limitNotified = /* @__PURE__ */ new Map();
3058
+ function cancelledError() {
3059
+ return new Error("Agent turn cancelled");
3060
+ }
3061
+ function waitUnlessAborted(gate2, signal) {
3062
+ if (!signal) return gate2;
3063
+ if (signal.aborted) return Promise.reject(cancelledError());
3064
+ return new Promise((resolve3, reject) => {
3065
+ const onAbort = () => reject(cancelledError());
3066
+ signal.addEventListener("abort", onAbort, { once: true });
3067
+ void gate2.then(() => {
3068
+ signal.removeEventListener("abort", onAbort);
3069
+ resolve3();
3070
+ });
3071
+ });
3072
+ }
3073
+ async function lockChannel(channelId, signal) {
3074
+ const previous = channelTails.get(channelId) ?? Promise.resolve();
3075
+ let release;
3076
+ const mine = new Promise((resolve3) => {
3077
+ release = resolve3;
3078
+ });
3079
+ const tail = previous.then(() => mine);
3080
+ channelTails.set(channelId, tail);
3081
+ let released = false;
3082
+ const done = () => {
3083
+ if (released) return;
3084
+ released = true;
3085
+ release();
3086
+ if (channelTails.get(channelId) === tail) channelTails.delete(channelId);
3087
+ };
3088
+ try {
3089
+ await waitUnlessAborted(previous, signal);
3090
+ } catch (error) {
3091
+ done();
3092
+ throw error;
3093
+ }
3094
+ return done;
3095
+ }
3096
+ function acquireSlot(signal) {
3097
+ let released = false;
3098
+ const release = () => {
3099
+ if (released) return;
3100
+ released = true;
3101
+ activeTurns--;
3102
+ slotWaiters.shift()?.();
3103
+ };
3104
+ if (activeTurns < maxConcurrentTurns) {
3105
+ activeTurns++;
3106
+ return Promise.resolve(release);
3107
+ }
3108
+ if (signal?.aborted) return Promise.reject(cancelledError());
3109
+ return new Promise((resolve3, reject) => {
3110
+ const grant = () => {
3111
+ signal?.removeEventListener("abort", onAbort);
3112
+ activeTurns++;
3113
+ resolve3(release);
3114
+ };
3115
+ const onAbort = () => {
3116
+ const i = slotWaiters.indexOf(grant);
3117
+ if (i >= 0) slotWaiters.splice(i, 1);
3118
+ reject(cancelledError());
3119
+ };
3120
+ signal?.addEventListener("abort", onAbort, { once: true });
3121
+ slotWaiters.push(grant);
3122
+ });
3123
+ }
3124
+ function dailyLimitReply(channelId, day) {
3125
+ if (limitNotified.get(channelId) === day) return null;
3126
+ for (const [ch, d] of limitNotified) if (d !== day) limitNotified.delete(ch);
3127
+ limitNotified.set(channelId, day);
3128
+ log("warn", "turn.daily_limit_reply");
3129
+ return DAILY_LIMIT_RESPONSE;
3130
+ }
3131
+ return async (m, upstreamSignal) => {
2075
3132
  const text = m.text?.trim() ? m.text : "";
2076
3133
  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
- );
3134
+ const mustWait = channelTails.has(m.channelId) || activeTurns >= maxConcurrentTurns;
3135
+ if (mustWait) {
3136
+ log("info", "turn.queued", {
3137
+ same_channel: channelTails.has(m.channelId),
3138
+ active_turns: activeTurns
3139
+ });
3140
+ }
3141
+ const releaseChannel = await lockChannel(m.channelId, upstreamSignal);
3142
+ let releaseSlot;
3143
+ try {
3144
+ releaseSlot = await acquireSlot(upstreamSignal);
3145
+ } catch (error) {
3146
+ releaseChannel();
3147
+ throw error;
3148
+ }
3149
+ const controller = new AbortController();
3150
+ let timedOut = false;
3151
+ const cancel = () => controller.abort(upstreamSignal?.reason);
3152
+ if (upstreamSignal?.aborted) cancel();
3153
+ else upstreamSignal?.addEventListener("abort", cancel, { once: true });
3154
+ const timer = setTimeout(() => {
3155
+ timedOut = true;
3156
+ controller.abort(new Error("turn deadline exceeded"));
3157
+ }, turnTimeoutMs);
3158
+ try {
3159
+ return await runTurn(m, controller.signal);
3160
+ } catch (error) {
3161
+ if (error instanceof DailyLimitReachedError) return dailyLimitReply(m.channelId, error.day);
3162
+ if (timedOut) throw new Error(`Agent turn timed out after ${turnTimeoutMs}ms`);
3163
+ if (upstreamSignal?.aborted) throw cancelledError();
3164
+ throw error;
3165
+ } finally {
3166
+ clearTimeout(timer);
3167
+ upstreamSignal?.removeEventListener("abort", cancel);
3168
+ releaseSlot();
3169
+ releaseChannel();
3170
+ }
3171
+ };
3172
+ async function runTurn(m, signal) {
3173
+ const text = m.text;
3174
+ log("info", "turn.message_received", { chars: text.length });
2081
3175
  const memory = memoryFor(m.channelId);
2082
3176
  memory.replace(m.memory);
2083
3177
  const messages = [{ role: "system", content: deps.systemPromptFor() }];
@@ -2090,7 +3184,7 @@ function buildHandler(deps) {
2090
3184
  const offeredNames = new Set(tools.map((tool) => tool.function.name));
2091
3185
  let response = null;
2092
3186
  for (let iteration = 0; iteration < maxIterations; iteration++) {
2093
- const result = await llm.complete(messages, tools.length > 0 ? tools : void 0);
3187
+ const result = await llm.complete(messages, tools.length > 0 ? tools : void 0, { signal });
2094
3188
  if (result.toolCalls.length === 0) {
2095
3189
  response = result.content;
2096
3190
  break;
@@ -2105,26 +3199,38 @@ function buildHandler(deps) {
2105
3199
  m.authorization,
2106
3200
  call.function.name,
2107
3201
  call.function.arguments,
2108
- offeredNames
3202
+ offeredNames,
3203
+ now()
2109
3204
  );
3205
+ const localVerdict = channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict;
2110
3206
  return {
2111
3207
  call,
2112
- verdict: channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict
3208
+ verdict: {
3209
+ ...localVerdict,
3210
+ ...localVerdict.allowed && channelVerdict.sideEffect ? { sideEffect: channelVerdict.sideEffect } : {}
3211
+ }
2113
3212
  };
2114
3213
  });
2115
3214
  const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
3215
+ const sideEffects = new Map(
3216
+ verdicts.flatMap(({ call, verdict }) => verdict.allowed && verdict.sideEffect ? [[call.id, verdict.sideEffect]] : [])
3217
+ );
2116
3218
  for (const v of verdicts) {
2117
3219
  if (!v.verdict.allowed) {
2118
- log("warn", `tool call denied: ${v.call.function.name} \u2014 ${v.verdict.reason}`);
3220
+ log("warn", "turn.tool_call_denied");
2119
3221
  }
2120
3222
  }
2121
- const executed = await runToolCalls(allowed, skills, { roomId: m.channelId });
3223
+ const executed = await runToolCalls(allowed, skills, {
3224
+ roomId: m.channelId,
3225
+ signal,
3226
+ sideEffects
3227
+ });
2122
3228
  const byId = new Map(executed.map((r) => [r.tool_call_id, r]));
2123
3229
  for (const { call, verdict } of verdicts) {
2124
3230
  if (verdict.allowed) {
2125
3231
  const done = byId.get(call.id);
2126
3232
  if (done) {
2127
- messages.push({ role: "tool", content: capResult(done.content), tool_call_id: done.tool_call_id });
3233
+ messages.push({ role: "tool", content: done.content, tool_call_id: done.tool_call_id });
2128
3234
  continue;
2129
3235
  }
2130
3236
  messages.push({
@@ -2142,23 +3248,23 @@ function buildHandler(deps) {
2142
3248
  }
2143
3249
  }
2144
3250
  if (!response) {
2145
- log("warn", `no final answer after ${maxIterations} iterations; dropping the turn`);
3251
+ log("warn", "turn.no_final_answer", { iterations: maxIterations });
2146
3252
  return null;
2147
3253
  }
2148
3254
  memory.addMessage({ role: "assistant", content: response });
2149
- log("info", `response ready (chars=${response.length})`);
3255
+ log("info", "turn.response_ready", { chars: response.length });
2150
3256
  return response;
2151
- };
3257
+ }
2152
3258
  }
2153
3259
 
2154
3260
  // 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";
3261
+ import { existsSync as existsSync5, readFileSync as readFileSync8, renameSync as renameSync3, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
3262
+ import { dirname as dirname5, join as join7 } from "node:path";
2157
3263
  import { fileURLToPath as fileURLToPath2 } from "node:url";
2158
3264
 
2159
3265
  // 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";
3266
+ import { createHash as createHash2 } from "node:crypto";
3267
+ import { chmodSync as chmodSync2, existsSync as existsSync4, lstatSync, readFileSync as readFileSync7, readdirSync as readdirSync2, renameSync as renameSync2 } from "node:fs";
2162
3268
  import { join as join6 } from "node:path";
2163
3269
  var MEMORY_FILE = "MEMORY.md";
2164
3270
  var QUARANTINE_MEMORY_FILE = "MEMORY.quarantined.md";
@@ -2173,14 +3279,14 @@ function quarantineOrder(a, b) {
2173
3279
  return index(a) - index(b) || a.localeCompare(b);
2174
3280
  }
2175
3281
  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");
3282
+ return createHash2("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
2177
3283
  }
2178
3284
  function readLegacySource(path, kind, contentBudget, channelId) {
2179
3285
  try {
2180
3286
  let st = lstatSync(path);
2181
3287
  if (st.isSymbolicLink() || !st.isFile()) return null;
2182
3288
  if ((st.mode & 511) !== 384) {
2183
- chmodSync(path, 384);
3289
+ chmodSync2(path, 384);
2184
3290
  st = lstatSync(path);
2185
3291
  }
2186
3292
  if (st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES) {
@@ -2196,7 +3302,7 @@ function readLegacySource(path, kind, contentBudget, channelId) {
2196
3302
  };
2197
3303
  }
2198
3304
  if (st.size > contentBudget) return null;
2199
- const bytes = readFileSync6(path);
3305
+ const bytes = readFileSync7(path);
2200
3306
  if (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget) return null;
2201
3307
  return {
2202
3308
  id: sourceId(kind, channelId, path, bytes),
@@ -2281,7 +3387,7 @@ function legacyMemoryFingerprint(managedDir, dataDir) {
2281
3387
  }
2282
3388
  } catch {
2283
3389
  }
2284
- return createHash("sha256").update(metadata.join("\n")).digest("hex");
3390
+ return createHash2("sha256").update(metadata.join("\n")).digest("hex");
2285
3391
  }
2286
3392
  function nextQuarantinePath(dir) {
2287
3393
  const first = join6(dir, QUARANTINE_MEMORY_FILE);
@@ -2300,8 +3406,8 @@ function quarantineLegacyMemory(managedDir, dataDir) {
2300
3406
  throw new Error(`refusing to quarantine symlinked legacy memory: ${source}`);
2301
3407
  }
2302
3408
  const target = nextQuarantinePath(dir);
2303
- renameSync(source, target);
2304
- chmodSync(target, 384);
3409
+ renameSync2(source, target);
3410
+ chmodSync2(target, 384);
2305
3411
  moved.push(target);
2306
3412
  }
2307
3413
  return moved;
@@ -2322,7 +3428,7 @@ var SAFETY_PREAMBLE = [
2322
3428
  "- Only use the tools you were given; do not invent capabilities."
2323
3429
  ].join("\n");
2324
3430
  function defaultPersonaDir() {
2325
- return join7(dirname3(fileURLToPath2(import.meta.url)), "..", "persona");
3431
+ return join7(dirname5(fileURLToPath2(import.meta.url)), "..", "persona");
2326
3432
  }
2327
3433
  function assemblePrompt(parts) {
2328
3434
  return parts.map((p) => `${p.heading}
@@ -2335,13 +3441,13 @@ function loadPersona(dirs) {
2335
3441
  if (!path) continue;
2336
3442
  let body;
2337
3443
  try {
2338
- body = readFileSync7(path, "utf8");
2339
- } catch {
2340
- console.warn(`[persona] cannot read ${path}; ignoring it`);
3444
+ body = readFileSync8(path, "utf8");
3445
+ } catch (error) {
3446
+ writeServiceLog("warn", "persona.read_failed", {}, error);
2341
3447
  continue;
2342
3448
  }
2343
3449
  if (Buffer.byteLength(body, "utf8") > PERSONA_MAX_BYTES) {
2344
- console.warn(`[persona] ${path} is larger than ${PERSONA_MAX_BYTES} bytes; truncating`);
3450
+ writeServiceLog("warn", "persona.truncated", { max_bytes: PERSONA_MAX_BYTES });
2345
3451
  body = body.slice(0, PERSONA_MAX_BYTES);
2346
3452
  }
2347
3453
  const trimmed = body.trim();
@@ -2361,7 +3467,7 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
2361
3467
  const path2 = join7(dataDir, file);
2362
3468
  if (!existsSync5(path2)) continue;
2363
3469
  try {
2364
- persona[file] = readFileSync7(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
3470
+ persona[file] = readFileSync8(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
2365
3471
  } catch {
2366
3472
  }
2367
3473
  }
@@ -2378,9 +3484,9 @@ function writeLocalConfigReport(managedDir, dataDir, now, flags) {
2378
3484
  const path = join7(managedDir, "local-config.json");
2379
3485
  const tmp = `${path}.${process.pid}.tmp`;
2380
3486
  try {
2381
- writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
3487
+ writeFileSync2(tmp, `${JSON.stringify(doc, null, 2)}
2382
3488
  `, { mode: 384 });
2383
- renameSync2(tmp, path);
3489
+ renameSync3(tmp, path);
2384
3490
  } catch (e) {
2385
3491
  try {
2386
3492
  unlinkSync(tmp);
@@ -2399,7 +3505,7 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
2399
3505
  skipped.push(target);
2400
3506
  continue;
2401
3507
  }
2402
- writeFile(target, readFileSync7(join7(defaultsDir, file), "utf8"));
3508
+ writeFile(target, readFileSync8(join7(defaultsDir, file), "utf8"));
2403
3509
  written.push(target);
2404
3510
  }
2405
3511
  return { written, skipped };
@@ -2431,31 +3537,32 @@ function installWarningFilter(proc = process) {
2431
3537
  proc.removeListener("warning", listener);
2432
3538
  }
2433
3539
  proc.on("warning", (w) => {
2434
- if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
2435
- console.error(w.stack ?? `${w.name}: ${w.message}`);
3540
+ try {
3541
+ if (w.name === "ExperimentalWarning" && /SQLite/i.test(w.message)) return;
3542
+ } catch {
3543
+ }
3544
+ writeServiceLog("warn", "runtime.node_warning", {}, w);
2436
3545
  });
2437
3546
  }
2438
3547
 
2439
3548
  // src/index.ts
2440
3549
  if (process.argv.includes("--version")) {
2441
- const pkg = JSON.parse(readFileSync8(join8(dirname4(dirname4(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
3550
+ const pkg = JSON.parse(readFileSync9(join8(dirname6(dirname6(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
2442
3551
  console.log(pkg.version);
2443
3552
  process.exit(0);
2444
3553
  }
2445
3554
  var LEVEL_RANK = { debug: 0, info: 1, warn: 2, error: 3 };
2446
3555
  function makeLogger(cfg) {
2447
3556
  const floor = LEVEL_RANK[cfg.logLevel];
2448
- return (level, msg) => {
3557
+ return (level, event, fields, error) => {
2449
3558
  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);
3559
+ writeServiceLog(level, event, fields, error);
2453
3560
  };
2454
3561
  }
2455
3562
  function realLlmSourceDeps() {
2456
3563
  return {
2457
3564
  home: homedir4(),
2458
- readFile: (p) => readFileSync8(p, "utf8"),
3565
+ readFile: (p) => readFileSync9(p, "utf8"),
2459
3566
  statFile: (p) => ({ mode: statSync2(p).mode }),
2460
3567
  exists: (p) => existsSync6(p)
2461
3568
  };
@@ -2467,9 +3574,9 @@ function realServiceDeps() {
2467
3574
  const r = spawnSync(cmd, args, { encoding: "utf8" });
2468
3575
  return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
2469
3576
  },
2470
- writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
3577
+ writeFile: (p, data) => writeFileSync3(p, data, { mode: 420 }),
2471
3578
  removeFile: (p) => rmSync(p, { force: true }),
2472
- mkdirp: (p) => mkdirSync(p, { recursive: true }),
3579
+ mkdirp: (p) => mkdirSync3(p, { recursive: true }),
2473
3580
  fileExists: (p) => existsSync6(p),
2474
3581
  canRead: (p) => {
2475
3582
  try {
@@ -2566,6 +3673,46 @@ async function main() {
2566
3673
  installWarningFilter();
2567
3674
  const verb = parseVerb(process.argv);
2568
3675
  if (verb) runServiceVerb(verb);
3676
+ const sideEffectsVerb = parseSideEffectsVerb(process.argv);
3677
+ if (process.argv[2] === "side-effects" && !sideEffectsVerb) {
3678
+ console.error("securecoms-agent: side-effects requires list or resolve");
3679
+ process.exit(2);
3680
+ }
3681
+ if (sideEffectsVerb) {
3682
+ let cfgForLedger;
3683
+ try {
3684
+ cfgForLedger = loadConfig(process.argv.slice(2), process.env);
3685
+ } catch (e) {
3686
+ console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
3687
+ process.exit(2);
3688
+ return;
3689
+ }
3690
+ const ledger = new SideEffectLedger(join8(cfgForLedger.dataDir, "side-effect-ledger.sqlite"));
3691
+ try {
3692
+ if (sideEffectsVerb === "list") {
3693
+ console.log(JSON.stringify({ ambiguous: ledger.listAmbiguous() }, null, 2));
3694
+ } else {
3695
+ const flags = parseSideEffectResolveFlags(process.argv);
3696
+ if (!flags.idempotencyKey || !flags.operationSha256 || !/^[0-9a-f]{64}$/.test(flags.operationSha256) || !flags.confirmationId || !flags.outcome) {
3697
+ throw new Error(
3698
+ "resolve requires --idempotency-key, --operation-sha256, --confirmation-id, and --outcome completed|not_applied"
3699
+ );
3700
+ }
3701
+ ledger.reconcile({
3702
+ idempotency_key: flags.idempotencyKey,
3703
+ operation_sha256: flags.operationSha256,
3704
+ confirmation_id: flags.confirmationId
3705
+ }, flags.outcome, /* @__PURE__ */ new Date());
3706
+ console.log(JSON.stringify({ reconciled: true, outcome: flags.outcome }));
3707
+ }
3708
+ } catch (e) {
3709
+ console.error(`securecoms-agent: side-effects ${sideEffectsVerb} failed: ${e instanceof Error ? e.message : String(e)}`);
3710
+ process.exitCode = 1;
3711
+ } finally {
3712
+ ledger.close();
3713
+ }
3714
+ process.exit(process.exitCode ?? 0);
3715
+ }
2569
3716
  const personaVerb = parsePersonaVerb(process.argv);
2570
3717
  if (personaVerb) {
2571
3718
  let cfgForPersona;
@@ -2577,11 +3724,11 @@ async function main() {
2577
3724
  return;
2578
3725
  }
2579
3726
  if (personaVerb === "init") {
2580
- mkdirSync(cfgForPersona.dataDir, { recursive: true });
3727
+ mkdirSync3(cfgForPersona.dataDir, { recursive: true });
2581
3728
  const r = personaInit(
2582
3729
  cfgForPersona.dataDir,
2583
3730
  defaultPersonaDir(),
2584
- (p, d) => writeFileSync2(p, d, { mode: 384 }),
3731
+ (p, d) => writeFileSync3(p, d, { mode: 384 }),
2585
3732
  (p) => existsSync6(p)
2586
3733
  );
2587
3734
  for (const p of r.written) console.log(`wrote ${p}`);
@@ -2600,12 +3747,27 @@ async function main() {
2600
3747
  try {
2601
3748
  cfg = loadConfig(process.argv.slice(2), process.env);
2602
3749
  } catch (e) {
2603
- console.error(`securecoms-agent: ${e instanceof Error ? e.message : String(e)}`);
3750
+ const argv = process.argv.slice(2);
3751
+ const hasIdentityArg = argv.some(
3752
+ (arg, index) => arg === "--identity" && !!argv[index + 1] || arg.startsWith("--identity=") && arg.length > 11
3753
+ );
3754
+ writeServiceLog(
3755
+ "error",
3756
+ !hasIdentityArg && !process.env.SECURECOMS_IDENTITY ? "runtime.identity_required.use_--identity" : "runtime.config_invalid",
3757
+ {},
3758
+ e
3759
+ );
2604
3760
  process.exit(2);
2605
3761
  return;
2606
3762
  }
2607
3763
  const log = makeLogger(cfg);
2608
- const llm = buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
3764
+ const budget = new DailyLlmBudget({
3765
+ callLimit: cfg.llmDailyCallLimit ?? DEFAULT_DAILY_CALL_LIMIT,
3766
+ tokenLimit: cfg.llmDailyTokenLimit ?? DEFAULT_DAILY_TOKEN_LIMIT,
3767
+ statePath: join8(cfg.dataDir, `llm-budget-${cfg.identityId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`),
3768
+ log
3769
+ });
3770
+ const llm = withDailyBudget(buildLlmShim(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()), budget);
2609
3771
  const memories = /* @__PURE__ */ new Map();
2610
3772
  const memoryFor = (channelId) => {
2611
3773
  let mem = memories.get(channelId);
@@ -2629,18 +3791,29 @@ async function main() {
2629
3791
  const path = keyFilePath(which);
2630
3792
  if (!path) return null;
2631
3793
  try {
2632
- return readFileSync8(path, "utf8").trim() || null;
2633
- } catch {
2634
- log("warn", `cannot read the ${which} key file ${path}`);
3794
+ return readFileSync9(path, "utf8").trim() || null;
3795
+ } catch (e) {
3796
+ log("warn", "runtime.search_key_unreadable", { provider_brave: which === "brave" }, e);
2635
3797
  return null;
2636
3798
  }
2637
3799
  };
2638
3800
  const configDirs = configSearchPath(cfg.identityId, cfg.dataDir);
2639
- const hasSearchKey = () => hasReadableKeyFile([keyFilePath("brave"), keyFilePath("tavily")], (p) => readFileSync8(p, "utf8"));
3801
+ const reportSearchKeyGate = searchKeyGateReporter(log, (p) => p === keyFilePath("brave"));
3802
+ const hasSearchKey = () => hasReadableKeyFile(
3803
+ [keyFilePath("brave"), keyFilePath("tavily")],
3804
+ (p) => readFileSync9(p, "utf8"),
3805
+ reportSearchKeyGate
3806
+ );
3807
+ const reportLlmSourceUnresolved = failureEpisodeReporter(
3808
+ (e) => log("warn", "runtime.llm_source_unresolved", {}, e)
3809
+ );
2640
3810
  const hasLlmKey = () => {
2641
3811
  try {
2642
- return hasLlmKeyFor(resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()));
2643
- } catch {
3812
+ const source = resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps());
3813
+ reportLlmSourceUnresolved.succeeded();
3814
+ return hasLlmKeyFor(source);
3815
+ } catch (e) {
3816
+ reportLlmSourceUnresolved.failed(e);
2644
3817
  return false;
2645
3818
  }
2646
3819
  };
@@ -2659,7 +3832,8 @@ async function main() {
2659
3832
  now: () => /* @__PURE__ */ new Date(),
2660
3833
  log
2661
3834
  };
2662
- const skills = new SkillInvoker(cfg.skillsDir, builtinCtx);
3835
+ const sideEffectLedger = new SideEffectLedger(join8(cfg.dataDir, "side-effect-ledger.sqlite"));
3836
+ const skills = new SkillInvoker(cfg.skillsDir, builtinCtx, sideEffectLedger);
2663
3837
  skills.loadSkills();
2664
3838
  const policyFor = () => resolveToolPolicy(cfg.toolGateEnv, currentSkillsConfig().tools, {
2665
3839
  default: cfg.toolGate.default,
@@ -2673,11 +3847,11 @@ async function main() {
2673
3847
  reportLocalConfig({ hasSearchKey: searchKey, hasLlmKey: hasLlmKey() });
2674
3848
  return skills.available(requiresEnvFrom(sc, searchKey)).filter((t) => g.gate(t.function.name).allowed);
2675
3849
  };
2676
- for (const held of skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey()))) {
2677
- console.log(`unavailable: ${held.name} (missing ${held.missing.join(", ")})`);
2678
- }
3850
+ const unavailableCount = skills.unavailable(requiresEnvFrom(currentSkillsConfig(), hasSearchKey())).length;
3851
+ if (unavailableCount > 0) log("info", "runtime.skills_unavailable", { count: unavailableCount });
2679
3852
  const plane = createSecureComsDataPlane(
2680
- { identityId: cfg.identityId, log: (level, msg) => log(level, msg) },
3853
+ // Final-wave I2: the daily-limit reply is a notice, not an agent turn.
3854
+ { identityId: cfg.identityId, log, noticeReplies: /* @__PURE__ */ new Set([DAILY_LIMIT_RESPONSE]) },
2681
3855
  buildHandler({
2682
3856
  llm,
2683
3857
  memoryFor,
@@ -2691,16 +3865,21 @@ async function main() {
2691
3865
  tools: installationEligibleTools
2692
3866
  })
2693
3867
  );
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
- );
3868
+ log("info", "runtime.started", {
3869
+ configured_skills: installationEligibleTools().length,
3870
+ tool_gate_default_deny: cfg.toolGate.default === "deny"
3871
+ });
2697
3872
  await plane.start();
2698
3873
  const shutdown = () => {
2699
- log("info", "shutting down");
3874
+ log("info", "runtime.shutting_down");
2700
3875
  plane.stop();
3876
+ sideEffectLedger.close();
2701
3877
  process.exit(0);
2702
3878
  };
2703
3879
  process.on("SIGINT", shutdown);
2704
3880
  process.on("SIGTERM", shutdown);
2705
3881
  }
2706
- void main();
3882
+ void main().catch((error) => {
3883
+ writeServiceLog("error", "runtime.fatal", {}, error);
3884
+ process.exit(1);
3885
+ });