@adapt-toolkit/a2adapt 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,15 +4,80 @@ import { createRequire } from 'node:module'; const require = createRequire(impor
4
4
  // src/cli.ts
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { connect } from "node:net";
7
- import { homedir, userInfo } from "node:os";
8
- import { resolve, join, dirname } from "node:path";
7
+ import { homedir as homedir2, userInfo } from "node:os";
8
+ import { resolve as resolve2, join as join2, dirname as dirname2 } from "node:path";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { createInterface } from "node:readline/promises";
11
+ import * as fs2 from "node:fs";
12
+
13
+ // src/config.ts
10
14
  import * as fs from "node:fs";
11
- var STATE_DIR = resolve(process.env.A2ADAPT_STATE_DIR ?? resolve(homedir(), ".a2adapt"));
12
- var PORT = parseInt(process.env.A2ADAPT_PORT ?? "3030", 10);
13
- var BROKER_URL = process.env.A2ADAPT_BROKER_URL ?? "ws://a2adapt.adaptframework.solutions/broker";
14
- var PID_PATH = join(STATE_DIR, "daemon.pid");
15
- var LOG_PATH = join(STATE_DIR, "daemon.log");
15
+ import { homedir } from "node:os";
16
+ import { resolve, join, dirname } from "node:path";
17
+ var DEFAULT_CONFIG = {
18
+ brokerUrl: "ws://a2adapt.adaptframework.solutions/broker",
19
+ port: 3030,
20
+ stateDir: resolve(homedir(), ".a2adapt"),
21
+ gcIntervalMs: 36e5
22
+ };
23
+ function configPath() {
24
+ return process.env.A2ADAPT_CONFIG ?? join(homedir(), ".a2adapt", "config.json");
25
+ }
26
+ function readFileConfig() {
27
+ let raw;
28
+ try {
29
+ raw = fs.readFileSync(configPath(), "utf8");
30
+ } catch {
31
+ return {};
32
+ }
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(raw);
36
+ } catch {
37
+ return {};
38
+ }
39
+ const out2 = {};
40
+ if (typeof parsed.brokerUrl === "string") out2.brokerUrl = parsed.brokerUrl;
41
+ if (typeof parsed.port === "number" && Number.isFinite(parsed.port)) out2.port = parsed.port;
42
+ if (typeof parsed.stateDir === "string") out2.stateDir = resolve(parsed.stateDir);
43
+ if (typeof parsed.gcIntervalMs === "number" && Number.isFinite(parsed.gcIntervalMs)) {
44
+ out2.gcIntervalMs = parsed.gcIntervalMs;
45
+ }
46
+ return out2;
47
+ }
48
+ function envInt(name) {
49
+ const v = process.env[name];
50
+ if (v === void 0) return void 0;
51
+ const n = parseInt(v, 10);
52
+ return Number.isNaN(n) ? void 0 : n;
53
+ }
54
+ function loadConfig() {
55
+ const file = readFileConfig();
56
+ return {
57
+ brokerUrl: process.env.A2ADAPT_BROKER_URL ?? file.brokerUrl ?? DEFAULT_CONFIG.brokerUrl,
58
+ port: envInt("A2ADAPT_PORT") ?? file.port ?? DEFAULT_CONFIG.port,
59
+ stateDir: resolve(process.env.A2ADAPT_STATE_DIR ?? file.stateDir ?? DEFAULT_CONFIG.stateDir),
60
+ gcIntervalMs: envInt("A2ADAPT_GC_INTERVAL_MS") ?? file.gcIntervalMs ?? DEFAULT_CONFIG.gcIntervalMs
61
+ };
62
+ }
63
+ function writeConfig(cfg) {
64
+ const path = configPath();
65
+ fs.mkdirSync(dirname(path), { recursive: true });
66
+ fs.writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
67
+ try {
68
+ fs.chmodSync(path, 384);
69
+ } catch {
70
+ }
71
+ return path;
72
+ }
73
+
74
+ // src/cli.ts
75
+ var CONFIG = loadConfig();
76
+ var STATE_DIR = CONFIG.stateDir;
77
+ var PORT = CONFIG.port;
78
+ var BROKER_URL = CONFIG.brokerUrl;
79
+ var PID_PATH = join2(STATE_DIR, "daemon.pid");
80
+ var LOG_PATH = join2(STATE_DIR, "daemon.log");
16
81
  var SELF = fileURLToPath(import.meta.url);
17
82
  var out = (...p) => process.stdout.write(`${p.join(" ")}
18
83
  `);
@@ -21,7 +86,7 @@ var err = (...p) => process.stderr.write(`${p.join(" ")}
21
86
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
87
  function readPid() {
23
88
  try {
24
- const n = parseInt(fs.readFileSync(PID_PATH, "utf8").trim(), 10);
89
+ const n = parseInt(fs2.readFileSync(PID_PATH, "utf8").trim(), 10);
25
90
  return Number.isFinite(n) ? n : null;
26
91
  } catch {
27
92
  return null;
@@ -40,7 +105,7 @@ function runningPid() {
40
105
  if (pid && isAlive(pid)) return pid;
41
106
  if (pid) {
42
107
  try {
43
- fs.rmSync(PID_PATH, { force: true });
108
+ fs2.rmSync(PID_PATH, { force: true });
44
109
  } catch {
45
110
  }
46
111
  }
@@ -73,8 +138,8 @@ async function cmdStart() {
73
138
  out(`a2adapt-mcp is already running (pid ${existing}, port ${PORT}).`);
74
139
  return;
75
140
  }
76
- fs.mkdirSync(STATE_DIR, { recursive: true });
77
- const logFd = fs.openSync(LOG_PATH, "a");
141
+ fs2.mkdirSync(STATE_DIR, { recursive: true });
142
+ const logFd = fs2.openSync(LOG_PATH, "a");
78
143
  const child = spawn(process.execPath, [SELF, "serve"], {
79
144
  detached: true,
80
145
  stdio: ["ignore", logFd, logFd],
@@ -91,7 +156,7 @@ async function cmdStart() {
91
156
  err("failed to spawn the daemon.");
92
157
  process.exit(1);
93
158
  }
94
- fs.writeFileSync(PID_PATH, String(child.pid));
159
+ fs2.writeFileSync(PID_PATH, String(child.pid));
95
160
  out(`starting a2adapt-mcp (pid ${child.pid})\u2026`);
96
161
  const ready = await waitForPort(PORT);
97
162
  if (ready) {
@@ -125,7 +190,7 @@ async function cmdStop() {
125
190
  }
126
191
  }
127
192
  try {
128
- fs.rmSync(PID_PATH, { force: true });
193
+ fs2.rmSync(PID_PATH, { force: true });
129
194
  } catch {
130
195
  }
131
196
  out("stopped.");
@@ -150,21 +215,80 @@ async function cmdStatus() {
150
215
  out(` state: ${STATE_DIR}`);
151
216
  out(` logs: ${LOG_PATH}`);
152
217
  }
218
+ async function cmdSetup() {
219
+ const current = loadConfig();
220
+ const path = configPath();
221
+ out(`a2adapt-mcp setup \u2014 ${path}`);
222
+ out("Enter a value, or press Enter to keep the current [bracketed] one.");
223
+ out("");
224
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
225
+ let next;
226
+ try {
227
+ const ask = async (label, cur) => {
228
+ const ans = (await rl.question(` ${label} [${cur}]: `)).trim();
229
+ return ans === "" ? cur : ans;
230
+ };
231
+ const brokerUrl = await ask("broker URL", current.brokerUrl);
232
+ const portStr = await ask("HTTP port", String(current.port));
233
+ const stateDir = await ask("state dir", current.stateDir);
234
+ const gcStr = await ask("GC interval (ms)", String(current.gcIntervalMs));
235
+ const port = parseInt(portStr, 10);
236
+ if (!Number.isFinite(port) || port <= 0) {
237
+ err(`invalid port: ${portStr}`);
238
+ process.exit(1);
239
+ }
240
+ const gcIntervalMs = parseInt(gcStr, 10);
241
+ if (!Number.isFinite(gcIntervalMs) || gcIntervalMs <= 0) {
242
+ err(`invalid GC interval: ${gcStr}`);
243
+ process.exit(1);
244
+ }
245
+ next = { brokerUrl, port, stateDir: resolve2(stateDir), gcIntervalMs };
246
+ } finally {
247
+ rl.close();
248
+ }
249
+ writeConfig(next);
250
+ out("");
251
+ out(`wrote ${path} (mode 0600):`);
252
+ out(JSON.stringify(next, null, 2));
253
+ const shadowed = ["A2ADAPT_BROKER_URL", "A2ADAPT_PORT", "A2ADAPT_STATE_DIR", "A2ADAPT_GC_INTERVAL_MS"].filter((k) => process.env[k] !== void 0);
254
+ if (shadowed.length) {
255
+ out("");
256
+ out(`note: these env vars are set and OVERRIDE the file at runtime: ${shadowed.join(", ")}`);
257
+ }
258
+ const pid = runningPid();
259
+ if (!pid) return;
260
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
261
+ let restart = false;
262
+ try {
263
+ const ans = (await rl2.question(`
264
+ daemon is running (pid ${pid}); restart now to apply? [y/N]: `)).trim().toLowerCase();
265
+ restart = ans === "y" || ans === "yes";
266
+ } finally {
267
+ rl2.close();
268
+ }
269
+ if (!restart) {
270
+ out("not restarting \u2014 changes apply on the next `a2adapt-mcp restart`.");
271
+ return;
272
+ }
273
+ await cmdStop();
274
+ const r = spawnSync(process.execPath, [SELF, "start"], { stdio: "inherit" });
275
+ if (r.status !== 0) process.exit(r.status ?? 1);
276
+ }
153
277
  function cmdWatch(which) {
154
278
  const offsets = /* @__PURE__ */ new Map();
155
279
  const scan = (initial) => {
156
280
  let names;
157
281
  try {
158
- names = fs.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
282
+ names = fs2.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
159
283
  } catch {
160
284
  return;
161
285
  }
162
286
  for (const name of names) {
163
287
  if (which && name !== which) continue;
164
- const logPath = join(STATE_DIR, name, "notifications.log");
288
+ const logPath = join2(STATE_DIR, name, "notifications.log");
165
289
  let size;
166
290
  try {
167
- size = fs.statSync(logPath).size;
291
+ size = fs2.statSync(logPath).size;
168
292
  } catch {
169
293
  continue;
170
294
  }
@@ -180,10 +304,10 @@ function cmdWatch(which) {
180
304
  }
181
305
  let chunk;
182
306
  try {
183
- const fd = fs.openSync(logPath, "r");
307
+ const fd = fs2.openSync(logPath, "r");
184
308
  const buf = Buffer.alloc(size - seen);
185
- fs.readSync(fd, buf, 0, buf.length, seen);
186
- fs.closeSync(fd);
309
+ fs2.readSync(fd, buf, 0, buf.length, seen);
310
+ fs2.closeSync(fd);
187
311
  chunk = buf.toString("utf8");
188
312
  } catch {
189
313
  continue;
@@ -218,10 +342,10 @@ function cmdWatch(which) {
218
342
  var SYSTEMD_UNIT = "a2adapt.service";
219
343
  var LAUNCHD_LABEL = "solutions.adaptframework.a2adapt";
220
344
  function systemdUnitPath() {
221
- return join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT);
345
+ return join2(homedir2(), ".config", "systemd", "user", SYSTEMD_UNIT);
222
346
  }
223
347
  function launchdPlistPath() {
224
- return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
348
+ return join2(homedir2(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
225
349
  }
226
350
  function run(cmd, args) {
227
351
  const r = spawnSync(cmd, args, { stdio: "inherit" });
@@ -229,7 +353,7 @@ function run(cmd, args) {
229
353
  }
230
354
  function installSystemd() {
231
355
  const unitPath = systemdUnitPath();
232
- fs.mkdirSync(dirname(unitPath), { recursive: true });
356
+ fs2.mkdirSync(dirname2(unitPath), { recursive: true });
233
357
  const unit = `[Unit]
234
358
  Description=a2adapt MCP daemon (secure agent-to-agent messaging over ADAPT)
235
359
  After=network-online.target
@@ -248,7 +372,7 @@ RestartSec=2
248
372
  [Install]
249
373
  WantedBy=default.target
250
374
  `;
251
- fs.writeFileSync(unitPath, unit);
375
+ fs2.writeFileSync(unitPath, unit);
252
376
  out(`wrote ${unitPath}`);
253
377
  run("systemctl", ["--user", "daemon-reload"]);
254
378
  if (!run("systemctl", ["--user", "enable", "--now", SYSTEMD_UNIT])) {
@@ -269,7 +393,7 @@ function uninstallSystemd() {
269
393
  run("systemctl", ["--user", "disable", "--now", SYSTEMD_UNIT]);
270
394
  const unitPath = systemdUnitPath();
271
395
  try {
272
- fs.rmSync(unitPath, { force: true });
396
+ fs2.rmSync(unitPath, { force: true });
273
397
  out(`removed ${unitPath}`);
274
398
  } catch (e) {
275
399
  err(`failed to remove ${unitPath}: ${String(e)}`);
@@ -279,7 +403,7 @@ function uninstallSystemd() {
279
403
  }
280
404
  function installLaunchd() {
281
405
  const plistPath = launchdPlistPath();
282
- fs.mkdirSync(dirname(plistPath), { recursive: true });
406
+ fs2.mkdirSync(dirname2(plistPath), { recursive: true });
283
407
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
284
408
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
285
409
  <plist version="1.0">
@@ -305,7 +429,7 @@ function installLaunchd() {
305
429
  </dict>
306
430
  </plist>
307
431
  `;
308
- fs.writeFileSync(plistPath, plist);
432
+ fs2.writeFileSync(plistPath, plist);
309
433
  out(`wrote ${plistPath}`);
310
434
  run("launchctl", ["unload", plistPath]);
311
435
  if (!run("launchctl", ["load", "-w", plistPath])) {
@@ -320,7 +444,7 @@ function uninstallLaunchd() {
320
444
  const plistPath = launchdPlistPath();
321
445
  run("launchctl", ["unload", plistPath]);
322
446
  try {
323
- fs.rmSync(plistPath, { force: true });
447
+ fs2.rmSync(plistPath, { force: true });
324
448
  out(`removed ${plistPath}`);
325
449
  } catch (e) {
326
450
  err(`failed to remove ${plistPath}: ${String(e)}`);
@@ -348,14 +472,17 @@ function usage() {
348
472
  out(" stop stop the running daemon");
349
473
  out(" restart stop then start");
350
474
  out(" status show whether the daemon is running");
475
+ out(" setup interactively edit the config file (broker / port / state dir / gc)");
351
476
  out(" serve run in the foreground (used by start; handy for debugging)");
352
477
  out(" watch [identity] stream one line per new inbound message (wake source for a Monitor)");
353
478
  out("");
354
479
  out(" install-service install + start a boot-persistent service (systemd/launchd)");
355
480
  out(" uninstall-service stop + remove that service");
356
481
  out("");
357
- out("Config (env): A2ADAPT_BROKER_URL, A2ADAPT_PORT (3030), A2ADAPT_STATE_DIR (~/.a2adapt)");
358
- out("(install-service bakes the current config values into the service definition.)");
482
+ out("Config precedence (per field): env var > config.json > default.");
483
+ out(" config.json: A2ADAPT_CONFIG, else ~/.a2adapt/config.json \u2014 edit with `setup`.");
484
+ out(" env: A2ADAPT_BROKER_URL, A2ADAPT_PORT (3030), A2ADAPT_STATE_DIR (~/.a2adapt), A2ADAPT_GC_INTERVAL_MS (3600000)");
485
+ out("(install-service bakes the resolved config values into the service definition.)");
359
486
  }
360
487
  async function main() {
361
488
  const cmd = process.argv[2] ?? "help";
@@ -363,12 +490,12 @@ async function main() {
363
490
  case "serve":
364
491
  case "run":
365
492
  if (!process.env.A2ADAPT_TRANSPORT) process.env.A2ADAPT_TRANSPORT = "http";
366
- fs.mkdirSync(STATE_DIR, { recursive: true });
367
- fs.writeFileSync(PID_PATH, String(process.pid));
493
+ fs2.mkdirSync(STATE_DIR, { recursive: true });
494
+ fs2.writeFileSync(PID_PATH, String(process.pid));
368
495
  {
369
496
  const cleanup = () => {
370
497
  try {
371
- fs.rmSync(PID_PATH, { force: true });
498
+ fs2.rmSync(PID_PATH, { force: true });
372
499
  } catch {
373
500
  }
374
501
  };
@@ -380,7 +507,7 @@ async function main() {
380
507
  });
381
508
  }
382
509
  }
383
- await import(pathToFileURL(join(dirname(SELF), "index.js")).href);
510
+ await import(pathToFileURL(join2(dirname2(SELF), "index.js")).href);
384
511
  break;
385
512
  case "start":
386
513
  await cmdStart();
@@ -395,6 +522,9 @@ async function main() {
395
522
  case "status":
396
523
  await cmdStatus();
397
524
  break;
525
+ case "setup":
526
+ await cmdSetup();
527
+ break;
398
528
  case "watch":
399
529
  cmdWatch(process.argv[3]);
400
530
  break;
package/dist/index.js CHANGED
@@ -2981,7 +2981,7 @@ var require_compile = __commonJS({
2981
2981
  const schOrFunc = root.refs[ref];
2982
2982
  if (schOrFunc)
2983
2983
  return schOrFunc;
2984
- let _sch = resolve2.call(this, root, ref);
2984
+ let _sch = resolve3.call(this, root, ref);
2985
2985
  if (_sch === void 0) {
2986
2986
  const schema = (_a = root.localRefs) === null || _a === void 0 ? void 0 : _a[ref];
2987
2987
  const { schemaId } = this.opts;
@@ -3008,7 +3008,7 @@ var require_compile = __commonJS({
3008
3008
  function sameSchemaEnv(s1, s2) {
3009
3009
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3010
3010
  }
3011
- function resolve2(root, ref) {
3011
+ function resolve3(root, ref) {
3012
3012
  let sch;
3013
3013
  while (typeof (sch = this.refs[ref]) == "string")
3014
3014
  ref = sch;
@@ -3639,7 +3639,7 @@ var require_fast_uri = __commonJS({
3639
3639
  }
3640
3640
  return uri;
3641
3641
  }
3642
- function resolve2(baseURI, relativeURI, options) {
3642
+ function resolve3(baseURI, relativeURI, options) {
3643
3643
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3644
3644
  const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3645
3645
  schemelessOptions.skipEscape = true;
@@ -3897,7 +3897,7 @@ var require_fast_uri = __commonJS({
3897
3897
  var fastUri = {
3898
3898
  SCHEMES,
3899
3899
  normalize,
3900
- resolve: resolve2,
3900
+ resolve: resolve3,
3901
3901
  resolveComponent,
3902
3902
  equal,
3903
3903
  serialize,
@@ -6873,12 +6873,12 @@ var require_dist = __commonJS({
6873
6873
  throw new Error(`Unknown format "${name}"`);
6874
6874
  return f;
6875
6875
  };
6876
- function addFormats(ajv, list, fs2, exportName) {
6876
+ function addFormats(ajv, list, fs3, exportName) {
6877
6877
  var _a;
6878
6878
  var _b;
6879
6879
  (_a = (_b = ajv.opts.code).formats) !== null && _a !== void 0 ? _a : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
6880
6880
  for (const f of list)
6881
- ajv.addFormat(f, fs2[f]);
6881
+ ajv.addFormat(f, fs3[f]);
6882
6882
  }
6883
6883
  module.exports = exports = formatsPlugin;
6884
6884
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -18980,7 +18980,7 @@ var Protocol = class {
18980
18980
  return;
18981
18981
  }
18982
18982
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
18983
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
18983
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
18984
18984
  options?.signal?.throwIfAborted();
18985
18985
  }
18986
18986
  } catch (error2) {
@@ -18997,7 +18997,7 @@ var Protocol = class {
18997
18997
  */
18998
18998
  request(request, resultSchema, options) {
18999
18999
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
19000
- return new Promise((resolve2, reject) => {
19000
+ return new Promise((resolve3, reject) => {
19001
19001
  const earlyReject = (error2) => {
19002
19002
  reject(error2);
19003
19003
  };
@@ -19075,7 +19075,7 @@ var Protocol = class {
19075
19075
  if (!parseResult.success) {
19076
19076
  reject(parseResult.error);
19077
19077
  } else {
19078
- resolve2(parseResult.data);
19078
+ resolve3(parseResult.data);
19079
19079
  }
19080
19080
  } catch (error2) {
19081
19081
  reject(error2);
@@ -19336,12 +19336,12 @@ var Protocol = class {
19336
19336
  }
19337
19337
  } catch {
19338
19338
  }
19339
- return new Promise((resolve2, reject) => {
19339
+ return new Promise((resolve3, reject) => {
19340
19340
  if (signal.aborted) {
19341
19341
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
19342
19342
  return;
19343
19343
  }
19344
- const timeoutId = setTimeout(resolve2, interval);
19344
+ const timeoutId = setTimeout(resolve3, interval);
19345
19345
  signal.addEventListener("abort", () => {
19346
19346
  clearTimeout(timeoutId);
19347
19347
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -20441,7 +20441,7 @@ var McpServer = class {
20441
20441
  let task = createTaskResult.task;
20442
20442
  const pollInterval = task.pollInterval ?? 5e3;
20443
20443
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
20444
- await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
20444
+ await new Promise((resolve3) => setTimeout(resolve3, pollInterval));
20445
20445
  const updatedTask = await extra.taskStore.getTask(taskId);
20446
20446
  if (!updatedTask) {
20447
20447
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -21090,12 +21090,12 @@ var StdioServerTransport = class {
21090
21090
  this.onclose?.();
21091
21091
  }
21092
21092
  send(message) {
21093
- return new Promise((resolve2) => {
21093
+ return new Promise((resolve3) => {
21094
21094
  const json = serializeMessage(message);
21095
21095
  if (this._stdout.write(json)) {
21096
- resolve2();
21096
+ resolve3();
21097
21097
  } else {
21098
- this._stdout.once("drain", resolve2);
21098
+ this._stdout.once("drain", resolve3);
21099
21099
  }
21100
21100
  });
21101
21101
  }
@@ -21594,7 +21594,7 @@ var responseViaResponseObject = async (res, outgoing, options = {}) => {
21594
21594
  });
21595
21595
  if (!chunk) {
21596
21596
  if (i === 1) {
21597
- await new Promise((resolve2) => setTimeout(resolve2));
21597
+ await new Promise((resolve3) => setTimeout(resolve3));
21598
21598
  maxReadCount = 3;
21599
21599
  continue;
21600
21600
  }
@@ -22094,9 +22094,9 @@ data:
22094
22094
  const initRequest = messages.find((m) => isInitializeRequest(m));
22095
22095
  const clientProtocolVersion = initRequest ? initRequest.params.protocolVersion : req.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
22096
22096
  if (this._enableJsonResponse) {
22097
- return new Promise((resolve2) => {
22097
+ return new Promise((resolve3) => {
22098
22098
  this._streamMapping.set(streamId, {
22099
- resolveJson: resolve2,
22099
+ resolveJson: resolve3,
22100
22100
  cleanup: () => {
22101
22101
  this._streamMapping.delete(streamId);
22102
22102
  }
@@ -22427,23 +22427,75 @@ var StreamableHTTPServerTransport = class {
22427
22427
  };
22428
22428
 
22429
22429
  // src/index.ts
22430
- import { homedir } from "node:os";
22431
- import { resolve, join, dirname } from "node:path";
22430
+ import { resolve as resolve2, join as join2, dirname as dirname2 } from "node:path";
22432
22431
  import { fileURLToPath } from "node:url";
22433
22432
  import { randomBytes, randomUUID } from "node:crypto";
22434
22433
  import { createServer as createHttpServer } from "node:http";
22435
- import * as fs from "node:fs";
22434
+ import * as fs2 from "node:fs";
22436
22435
  import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
22437
22436
  import { adapt_wrapper } from "@adapt-toolkit/sdk/executables";
22438
22437
  import { PacketWrapperConfigurator } from "@adapt-toolkit/sdk/wrappers";
22439
22438
  import { object_to_adapt_value } from "@adapt-toolkit/sdk/wrapper";
22440
- var VERSION = true ? "0.5.1" : "0.0.0-dev";
22441
- var STATE_DIR = resolve(
22442
- process.env.A2ADAPT_STATE_DIR ?? resolve(homedir(), ".a2adapt")
22443
- );
22444
- var BROKER_URL = process.env.A2ADAPT_BROKER_URL ?? "ws://a2adapt.adaptframework.solutions/broker";
22439
+
22440
+ // src/config.ts
22441
+ import * as fs from "node:fs";
22442
+ import { homedir } from "node:os";
22443
+ import { resolve, join, dirname } from "node:path";
22444
+ var DEFAULT_CONFIG = {
22445
+ brokerUrl: "ws://a2adapt.adaptframework.solutions/broker",
22446
+ port: 3030,
22447
+ stateDir: resolve(homedir(), ".a2adapt"),
22448
+ gcIntervalMs: 36e5
22449
+ };
22450
+ function configPath() {
22451
+ return process.env.A2ADAPT_CONFIG ?? join(homedir(), ".a2adapt", "config.json");
22452
+ }
22453
+ function readFileConfig() {
22454
+ let raw;
22455
+ try {
22456
+ raw = fs.readFileSync(configPath(), "utf8");
22457
+ } catch {
22458
+ return {};
22459
+ }
22460
+ let parsed;
22461
+ try {
22462
+ parsed = JSON.parse(raw);
22463
+ } catch {
22464
+ return {};
22465
+ }
22466
+ const out = {};
22467
+ if (typeof parsed.brokerUrl === "string") out.brokerUrl = parsed.brokerUrl;
22468
+ if (typeof parsed.port === "number" && Number.isFinite(parsed.port)) out.port = parsed.port;
22469
+ if (typeof parsed.stateDir === "string") out.stateDir = resolve(parsed.stateDir);
22470
+ if (typeof parsed.gcIntervalMs === "number" && Number.isFinite(parsed.gcIntervalMs)) {
22471
+ out.gcIntervalMs = parsed.gcIntervalMs;
22472
+ }
22473
+ return out;
22474
+ }
22475
+ function envInt(name) {
22476
+ const v = process.env[name];
22477
+ if (v === void 0) return void 0;
22478
+ const n = parseInt(v, 10);
22479
+ return Number.isNaN(n) ? void 0 : n;
22480
+ }
22481
+ function loadConfig() {
22482
+ const file = readFileConfig();
22483
+ return {
22484
+ brokerUrl: process.env.A2ADAPT_BROKER_URL ?? file.brokerUrl ?? DEFAULT_CONFIG.brokerUrl,
22485
+ port: envInt("A2ADAPT_PORT") ?? file.port ?? DEFAULT_CONFIG.port,
22486
+ stateDir: resolve(process.env.A2ADAPT_STATE_DIR ?? file.stateDir ?? DEFAULT_CONFIG.stateDir),
22487
+ gcIntervalMs: envInt("A2ADAPT_GC_INTERVAL_MS") ?? file.gcIntervalMs ?? DEFAULT_CONFIG.gcIntervalMs
22488
+ };
22489
+ }
22490
+
22491
+ // src/index.ts
22492
+ var VERSION = true ? "0.6.0" : "0.0.0-dev";
22493
+ var CONFIG = loadConfig();
22494
+ var STATE_DIR = CONFIG.stateDir;
22495
+ var BROKER_URL = CONFIG.brokerUrl;
22445
22496
  var TRANSPORT = process.env.A2ADAPT_TRANSPORT ?? "http";
22446
- var PORT = parseInt(process.env.A2ADAPT_PORT ?? "3030", 10);
22497
+ var PORT = CONFIG.port;
22498
+ var GC_INTERVAL_MS = CONFIG.gcIntervalMs;
22447
22499
  var log = (...parts) => process.stderr.write(`a2adapt: ${parts.join(" ")}
22448
22500
  `);
22449
22501
  var NAME_RE = /^[A-Za-z0-9 _.-]{1,64}$/;
@@ -22457,15 +22509,15 @@ function validateName(name) {
22457
22509
  return null;
22458
22510
  }
22459
22511
  function locateUnit() {
22460
- const here = dirname(fileURLToPath(import.meta.url));
22512
+ const here = dirname2(fileURLToPath(import.meta.url));
22461
22513
  const override = process.env.A2ADAPT_UNIT_DIR;
22462
- const candidates = override ? [resolve(override)] : [join(here, "mufl_code"), join(here, "..", "mufl_code")];
22514
+ const candidates = override ? [resolve2(override)] : [join2(here, "mufl_code"), join2(here, "..", "mufl_code")];
22463
22515
  for (const dir of candidates) {
22464
- if (!fs.existsSync(dir)) continue;
22465
- const muflo = fs.readdirSync(dir).find((f) => f.endsWith(".muflo"));
22516
+ if (!fs2.existsSync(dir)) continue;
22517
+ const muflo = fs2.readdirSync(dir).find((f) => f.endsWith(".muflo"));
22466
22518
  if (muflo) {
22467
22519
  const hash = muflo.slice(0, -".muflo".length);
22468
- const contents = new Uint8Array(fs.readFileSync(join(dir, muflo)));
22520
+ const contents = new Uint8Array(fs2.readFileSync(join2(dir, muflo)));
22469
22521
  return { dir, hash, contents };
22470
22522
  }
22471
22523
  }
@@ -22479,18 +22531,18 @@ var identities = /* @__PURE__ */ new Map();
22479
22531
  var sessionBinding = /* @__PURE__ */ new Map();
22480
22532
  var bindingOwner = /* @__PURE__ */ new Map();
22481
22533
  var evictedSessions = /* @__PURE__ */ new Set();
22482
- var identityDir = (name) => join(STATE_DIR, name);
22483
- var seedPath = (dir) => join(dir, "identity.seed");
22484
- var dataPath = (dir) => join(dir, "state_data.bin");
22485
- var notifyLogPath = (dir) => join(dir, "notifications.log");
22486
- var unreadPath = (dir) => join(dir, "unread.json");
22534
+ var identityDir = (name) => join2(STATE_DIR, name);
22535
+ var seedPath = (dir) => join2(dir, "identity.seed");
22536
+ var dataPath = (dir) => join2(dir, "state_data.bin");
22537
+ var notifyLogPath = (dir) => join2(dir, "notifications.log");
22538
+ var unreadPath = (dir) => join2(dir, "unread.json");
22487
22539
  function listPersistedNames() {
22488
- if (!fs.existsSync(STATE_DIR)) return [];
22489
- return fs.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs.existsSync(seedPath(join(STATE_DIR, d.name)))).map((d) => d.name);
22540
+ if (!fs2.existsSync(STATE_DIR)) return [];
22541
+ return fs2.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs2.existsSync(seedPath(join2(STATE_DIR, d.name)))).map((d) => d.name);
22490
22542
  }
22491
22543
  function hasSavedState(dir) {
22492
22544
  try {
22493
- return fs.existsSync(dataPath(dir)) && fs.statSync(dataPath(dir)).size > 0;
22545
+ return fs2.existsSync(dataPath(dir)) && fs2.statSync(dataPath(dir)).size > 0;
22494
22546
  } catch {
22495
22547
  return false;
22496
22548
  }
@@ -22501,19 +22553,19 @@ function saveState(id) {
22501
22553
  object_to_adapt_value({ name: "::actor::export_state", targ: void 0 })
22502
22554
  );
22503
22555
  const bytes = Buffer.from(exported.Serialize());
22504
- fs.mkdirSync(id.dir, { recursive: true });
22556
+ fs2.mkdirSync(id.dir, { recursive: true });
22505
22557
  const tmp = `${dataPath(id.dir)}.tmp`;
22506
- fs.writeFileSync(tmp, bytes);
22507
- fs.renameSync(tmp, dataPath(id.dir));
22558
+ fs2.writeFileSync(tmp, bytes);
22559
+ fs2.renameSync(tmp, dataPath(id.dir));
22508
22560
  } catch (err) {
22509
22561
  log(`[${id.name}] failed to save state:`, String(err));
22510
22562
  }
22511
22563
  }
22512
22564
  function appendNotifyLog(id, from, msgId, date3) {
22513
22565
  try {
22514
- fs.mkdirSync(id.dir, { recursive: true });
22566
+ fs2.mkdirSync(id.dir, { recursive: true });
22515
22567
  const line = JSON.stringify({ event: "message_received", from, msg_id: msgId, date: date3 }) + "\n";
22516
- fs.appendFileSync(notifyLogPath(id.dir), line);
22568
+ fs2.appendFileSync(notifyLogPath(id.dir), line);
22517
22569
  } catch (err) {
22518
22570
  log(`[${id.name}] failed to append notifications.log:`, String(err));
22519
22571
  }
@@ -22526,10 +22578,10 @@ function refreshUnread(id) {
22526
22578
  count: unread.length,
22527
22579
  recent: unread.slice(-10).map((m) => ({ from: m.sender_name, msg_id: m.msg_id, date: m.date }))
22528
22580
  };
22529
- fs.mkdirSync(id.dir, { recursive: true });
22581
+ fs2.mkdirSync(id.dir, { recursive: true });
22530
22582
  const tmp = `${unreadPath(id.dir)}.tmp`;
22531
- fs.writeFileSync(tmp, JSON.stringify(snapshot));
22532
- fs.renameSync(tmp, unreadPath(id.dir));
22583
+ fs2.writeFileSync(tmp, JSON.stringify(snapshot));
22584
+ fs2.renameSync(tmp, unreadPath(id.dir));
22533
22585
  } catch (err) {
22534
22586
  log(`[${id.name}] failed to refresh unread snapshot:`, String(err));
22535
22587
  }
@@ -22665,9 +22717,9 @@ function createPacket(name, seed, dir) {
22665
22717
  }
22666
22718
  async function provisionIdentity(name) {
22667
22719
  const dir = identityDir(name);
22668
- fs.mkdirSync(dir, { recursive: true });
22720
+ fs2.mkdirSync(dir, { recursive: true });
22669
22721
  const seed = randomBytes(24).toString("hex");
22670
- fs.writeFileSync(seedPath(dir), seed, { mode: 384 });
22722
+ fs2.writeFileSync(seedPath(dir), seed, { mode: 384 });
22671
22723
  const id = await createPacket(name, seed, dir);
22672
22724
  await mutatingTx(id, "::actor::set_my_name", { name });
22673
22725
  saveState(id);
@@ -22675,11 +22727,11 @@ async function provisionIdentity(name) {
22675
22727
  }
22676
22728
  async function restoreIdentity(name) {
22677
22729
  const dir = identityDir(name);
22678
- const seed = fs.readFileSync(seedPath(dir), "utf8").trim();
22730
+ const seed = fs2.readFileSync(seedPath(dir), "utf8").trim();
22679
22731
  const id = await createPacket(name, seed, dir);
22680
22732
  if (hasSavedState(dir)) {
22681
22733
  try {
22682
- const buf = fs.readFileSync(dataPath(dir));
22734
+ const buf = fs2.readFileSync(dataPath(dir));
22683
22735
  const adaptData = id.pw.packet.ParseValue(new Uint8Array(buf));
22684
22736
  await mutatingTx(id, "::actor::import_state", adaptData);
22685
22737
  } catch (err) {
@@ -22786,10 +22838,10 @@ function packInvite(raw) {
22786
22838
  [zlibConstants.BROTLI_PARAM_SIZE_HINT]: raw.length
22787
22839
  }
22788
22840
  });
22789
- return Buffer.concat([Buffer.from([INVITE_BROTLI_V1]), compressed]).toString("base64");
22841
+ return Buffer.concat([Buffer.from([INVITE_BROTLI_V1]), compressed]).toString("base64url");
22790
22842
  }
22791
22843
  function unpackInvite(b64) {
22792
- const outer = Buffer.from(b64.trim(), "base64");
22844
+ const outer = Buffer.from(b64.replace(/\s+/g, ""), "base64url");
22793
22845
  if (outer.length < 2) throw new Error("the invite blob is too short or not valid base64");
22794
22846
  const version2 = outer[0];
22795
22847
  if (version2 === INVITE_BROTLI_V1) return Buffer.from(brotliDecompressSync(outer.subarray(1)));
@@ -22897,7 +22949,7 @@ ${lines.join("\n")}`);
22897
22949
  sessionBinding.delete(holder);
22898
22950
  }
22899
22951
  try {
22900
- fs.rmSync(id.dir, { recursive: true, force: true });
22952
+ fs2.rmSync(id.dir, { recursive: true, force: true });
22901
22953
  } catch (err) {
22902
22954
  return textResult(`Identity "${name}" removed from memory, but deleting ${id.dir} failed: ${String(err)}`, true);
22903
22955
  }
@@ -23003,6 +23055,23 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
23003
23055
  }
23004
23056
  }
23005
23057
  );
23058
+ server.tool(
23059
+ "remove_contact",
23060
+ "Forget a contact (by name or container id) \u2014 drops it from the bound identity's contacts, so you can no longer message them and inbound messages from them are rejected. This is a contacts-layer forget, NOT a key wipe: the per-peer channel key material persists, so re-adding the same peer reuses the existing encrypted channel rather than re-handshaking. Requires a bound identity.",
23061
+ { contact: external_exports.string().min(1).describe("Contact name or container id to remove.") },
23062
+ async ({ contact }) => {
23063
+ const { id, err } = boundOr();
23064
+ if (err) return err;
23065
+ try {
23066
+ const data = await mutatingTx(id, "::actor::remove_contact", { contact });
23067
+ const name = data.Reduce("removed").Visualize();
23068
+ const cid = data.Reduce("container_id").Visualize();
23069
+ return textResult(`Removed contact "${name}" (${cid}).`);
23070
+ } catch (e) {
23071
+ return textResult(`remove_contact failed: ${String(e)}`, true);
23072
+ }
23073
+ }
23074
+ );
23006
23075
  server.tool(
23007
23076
  "list_incoming_messages",
23008
23077
  "List ALL messages in the bound identity's inbox (decrypted), each with its id and status (unread/read). A read-only history view \u2014 it does not change any status. To consume new mail use get_messages instead.",
@@ -23025,7 +23094,7 @@ ${inbox.map((m) => fmtMsg(m)).join("\n")}`
23025
23094
  );
23026
23095
  server.tool(
23027
23096
  "get_messages",
23028
- 'Fetch the messages the bound identity has not seen yet (status "unread") and mark them "read". This is the ONLY call that returns message bodies. A message is delivered here exactly once, so reading and acting on it immediately will not double-process. Note each message id: pass them to mark_processed when done, or to defer_messages to leave a message for another session.',
23097
+ 'Fetch the messages the bound identity has not seen yet (status "unread") and mark them "processed". This is the ONLY call that returns message bodies, and each message is delivered exactly once, so reading and acting on it immediately never double-processes \u2014 no acknowledgement call is needed. If you read a message but crash or want to hand it to another session before acting, call defer_messages to put it back to "unread"; otherwise handled messages are garbage-collected automatically.',
23029
23098
  {},
23030
23099
  async () => {
23031
23100
  const { id, err } = boundOr();
@@ -23039,33 +23108,16 @@ ${inbox.map((m) => fmtMsg(m)).join("\n")}`
23039
23108
  `${fresh.length} new message(s):
23040
23109
  ${fresh.map((m) => fmtMsg(m, false)).join("\n")}
23041
23110
 
23042
- When handled: mark_processed({ msg_ids: [${fresh.map((m) => m.msg_id).join(", ")}] }). To leave any for another session: defer_messages({ msg_ids: [...] }).`
23111
+ These are now marked processed (auto-GC'd later). To hand any back to another session: defer_messages({ msg_ids: [${fresh.map((m) => m.msg_id).join(", ")}] }).`
23043
23112
  );
23044
23113
  } catch (e) {
23045
23114
  return textResult(`get_messages failed: ${String(e)}`, true);
23046
23115
  }
23047
23116
  }
23048
23117
  );
23049
- server.tool(
23050
- "mark_processed",
23051
- "Mark the given messages handled \u2014 they are removed from the inbox permanently. Idempotent: ids already gone are ignored. Optional for correctness (get_messages already prevents re-delivery); use it to keep the inbox tidy.",
23052
- { msg_ids: external_exports.array(external_exports.number().int()).min(1).describe("Message ids (from get_messages) to mark processed.") },
23053
- async ({ msg_ids }) => {
23054
- const { id, err } = boundOr();
23055
- if (err) return err;
23056
- try {
23057
- const data = await mutatingTx(id, "::actor::mark_processed", { msg_ids });
23058
- const n = data.Reduce("processed").Visualize();
23059
- refreshUnread(id);
23060
- return textResult(`Marked ${n} message(s) processed.`);
23061
- } catch (e) {
23062
- return textResult(`mark_processed failed: ${String(e)}`, true);
23063
- }
23064
- }
23065
- );
23066
23118
  server.tool(
23067
23119
  "defer_messages",
23068
- `Put already-read messages back into the queue (status "unread") so another session's get_messages will pick them up. Use when you read a message but want to leave it for someone else to process.`,
23120
+ `Put handled messages back into the queue (status "unread") so another session's get_messages picks them up. Works on messages you have read (status "processed") and even ones already queued for GC ("ready_to_delete"), so a message stays recoverable across a full GC cycle.`,
23069
23121
  { msg_ids: external_exports.array(external_exports.number().int()).min(1).describe("Message ids (from get_messages) to defer back to unread.") },
23070
23122
  async ({ msg_ids }) => {
23071
23123
  const { id, err } = boundOr();
@@ -23083,12 +23135,12 @@ When handled: mark_processed({ msg_ids: [${fresh.map((m) => m.msg_id).join(", ")
23083
23135
  return server;
23084
23136
  }
23085
23137
  function readBody(req) {
23086
- return new Promise((resolve2, reject) => {
23138
+ return new Promise((resolve3, reject) => {
23087
23139
  let data = "";
23088
23140
  req.on("data", (chunk) => data += chunk);
23089
23141
  req.on("end", () => {
23090
23142
  try {
23091
- resolve2(JSON.parse(data));
23143
+ resolve3(JSON.parse(data));
23092
23144
  } catch (e) {
23093
23145
  reject(e);
23094
23146
  }
@@ -23096,6 +23148,36 @@ function readBody(req) {
23096
23148
  req.on("error", reject);
23097
23149
  });
23098
23150
  }
23151
+ var gcTimer = null;
23152
+ var gcRunning = false;
23153
+ function startGcTimer() {
23154
+ if (gcTimer) return;
23155
+ gcTimer = setInterval(() => {
23156
+ if (gcRunning) return;
23157
+ gcRunning = true;
23158
+ void (async () => {
23159
+ try {
23160
+ for (const id of identities.values()) {
23161
+ try {
23162
+ await mutatingTx(id, "::actor::gc", {});
23163
+ } catch (e) {
23164
+ log(`gc(${id.name}) failed:`, String(e));
23165
+ }
23166
+ }
23167
+ } finally {
23168
+ gcRunning = false;
23169
+ }
23170
+ })();
23171
+ }, GC_INTERVAL_MS);
23172
+ gcTimer.unref?.();
23173
+ log(`message GC timer armed (every ${GC_INTERVAL_MS}ms)`);
23174
+ }
23175
+ function stopGcTimer() {
23176
+ if (gcTimer) {
23177
+ clearInterval(gcTimer);
23178
+ gcTimer = null;
23179
+ }
23180
+ }
23099
23181
  async function main() {
23100
23182
  if (TRANSPORT === "stdio") {
23101
23183
  const server = createMcpServer(() => "stdio");
@@ -23104,8 +23186,10 @@ async function main() {
23104
23186
  await server.connect(transport);
23105
23187
  log("MCP stdio transport connected, booting wrapper\u2026");
23106
23188
  await bootWrapper();
23189
+ startGcTimer();
23107
23190
  log(`MCP server v${VERSION} ready (transport=stdio, identities=${identities.size}, state=${STATE_DIR}, broker=${BROKER_URL})`);
23108
23191
  const flush = () => {
23192
+ stopGcTimer();
23109
23193
  for (const id of identities.values()) saveState(id);
23110
23194
  process.exit(0);
23111
23195
  };
@@ -23115,6 +23199,7 @@ async function main() {
23115
23199
  }
23116
23200
  log("booting wrapper\u2026");
23117
23201
  await bootWrapper();
23202
+ startGcTimer();
23118
23203
  log(`wrapper ready (identities=${identities.size}), starting HTTP server\u2026`);
23119
23204
  const transports = {};
23120
23205
  const httpServer = createHttpServer(async (req, res) => {
@@ -23191,6 +23276,7 @@ async function main() {
23191
23276
  });
23192
23277
  const shutdown = async () => {
23193
23278
  log("shutting down\u2026");
23279
+ stopGcTimer();
23194
23280
  for (const sid of Object.keys(transports)) {
23195
23281
  try {
23196
23282
  await transports[sid].close();
@@ -5,16 +5,17 @@
5
5
  // encrypted; the key exchange is handled for us by the stdlib `encrypted_channel`
6
6
  // library — we only ever address peers by their container id.
7
7
  //
8
- // User transactions (each backs one MCP tool):
8
+ // User transactions (each backs one MCP tool, except gc which the host fires):
9
9
  // set_my_name — set the display name peers see for me
10
10
  // generate_invite — make a slim personal invite blob for a named peer
11
11
  // add_contact — join via an invite blob, reply to the inviter
12
12
  // send_message — send an e2e-encrypted message to a contact
13
+ // remove_contact — forget a contact (drops it from my contacts)
13
14
  // list_contacts — (readonly) my contacts
14
15
  // list_incoming_messages — (readonly) my inbox, with per-message id + status
15
- // get_messages — return unread messages + mark them read (sole body egress)
16
- // mark_processeddelete handled messages from the inbox
17
- // defer_messages flip read messages back to unread for another session
16
+ // get_messages — return unread messages + mark them processed (sole body egress)
17
+ // defer_messagesflip processed/ready_to_delete messages back to unread
18
+ // gc two-generation GC of handled messages (host-fired, not a tool)
18
19
  //
19
20
  // External transactions (inbound, not exposed as tools):
20
21
  // accept_contact — inviter learns the joiner's identity + name
@@ -55,10 +56,12 @@ application actor loads libraries
55
56
  // (each key knows its own function + id), so the reconstructed identity is
56
57
  // byte-identical to the signed one and the self-signatures still verify.
57
58
  metadef invite_t: ($d -> global_id, $n -> str, $c -> global_id, $k -> key_utils::t_publickey(,), $a -> crypto_signature(,)).
58
- // A received message carries a stable per-packet id and a lifecycle
59
- // status: "unread" (just arrived) -> "read" (handed to the agent via
60
- // get_messages). mark_processed deletes it; defer_messages flips it back
61
- // to "unread" so another session can pick it up.
59
+ // A received message carries a stable per-packet id and a lifecycle status:
60
+ // "unread" (just arrived) -> "processed" (handed to the agent via
61
+ // get_messages) -> "ready_to_delete" (first gc tick) -> deleted (next gc
62
+ // tick). defer_messages flips "processed"/"ready_to_delete" back to "unread"
63
+ // so another session can pick it up — restorable from either generation,
64
+ // which is what makes the two-generation gc window race-free.
62
65
  metadef message_t: ($msg_id -> int, $sender_id -> global_id, $sender_name -> str, $text -> str, $date -> time, $status -> str).
63
66
  // The pre-lifecycle inbox shape (no per-message id/status). import_state
64
67
  // migrates blobs in this shape forward — see below.
@@ -236,6 +239,23 @@ application actor loads libraries
236
239
  }).
237
240
  }
238
241
 
242
+ trn remove_contact _:($contact -> contact_ref: str)
243
+ {
244
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
245
+
246
+ target_id = resolve_contact contact_ref.
247
+ removed = contacts target_id.
248
+ removed_name = removed? $name.
249
+
250
+ delete contacts target_id.
251
+ if peer_ads target_id != NIL { delete peer_ads target_id. }
252
+
253
+ return transaction::success [
254
+ _return_data ($removed -> removed_name, $container_id -> target_id),
255
+ _save_state NIL
256
+ ].
257
+ }
258
+
239
259
  trn readonly list_contacts _
240
260
  {
241
261
  return contacts.
@@ -246,13 +266,13 @@ application actor loads libraries
246
266
  return inbox.
247
267
  }
248
268
 
249
- // Hand the agent every message it has not seen yet (status "unread") and
250
- // flip those to "read". This is the ONLY place message bodies leave the
251
- // packet. Delivery is the dedup point: a message is returned by get_messages
252
- // exactly once, so an agent that reads and acts immediately never double-
253
- // processes no explicit acknowledgement is required for safety. To leave a
254
- // message for another session instead, call defer_messages (status -> unread
255
- // again); to discard it for good, call mark_processed (removed from inbox).
269
+ // Hand the agent every message it has not seen yet (status "unread") and flip
270
+ // those to "processed" the ONLY place message bodies leave the packet, and
271
+ // the sole dedup point: a message is returned exactly once, so an agent that
272
+ // reads and acts immediately never double-processes. CRASH WINDOW: a
273
+ // "processed" body has already left; an agent that crashes after get_messages
274
+ // but before acting must defer_messages to recover it (-> "unread") before gc
275
+ // promotes it through "ready_to_delete" and deletes it (>= 1 gc cycle).
256
276
  trn get_messages _
257
277
  {
258
278
  current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
@@ -263,16 +283,16 @@ application actor loads libraries
263
283
  {
264
284
  if (m $status) == "unread"
265
285
  {
266
- read_m is message_t = (
286
+ processed_m is message_t = (
267
287
  $msg_id -> m $msg_id,
268
288
  $sender_id -> m $sender_id,
269
289
  $sender_name -> m $sender_name,
270
290
  $text -> m $text,
271
291
  $date -> m $date,
272
- $status -> "read"
292
+ $status -> "processed"
273
293
  ).
274
294
  fresh (_count fresh|) -> m.
275
- new_inbox (_count new_inbox|) -> read_m.
295
+ new_inbox (_count new_inbox|) -> processed_m.
276
296
  }
277
297
  else
278
298
  {
@@ -287,40 +307,57 @@ application actor loads libraries
287
307
  ].
288
308
  }
289
309
 
290
- // Remove the given messages from the inbox permanently the agent is done
291
- // with them. Idempotent: ids that are already gone are silently skipped.
292
- trn mark_processed _:($msg_ids -> ids: int[])
310
+ // Two-generation garbage collection of handled messages, fired by the host on
311
+ // a timer (NOT piggybacked on other transactions that would collapse the
312
+ // window under traffic). Order matters: (A) delete everything already marked
313
+ // "ready_to_delete", THEN (B) promote "processed" -> "ready_to_delete". A
314
+ // single pass keyed on the current status gives exactly that — a message is in
315
+ // one status, so a freshly-processed message is promoted (not dropped) this
316
+ // tick and only deleted on the NEXT tick, guaranteeing it survives a full
317
+ // cycle (>= 1 interval) as a deferrable "ready_to_delete".
318
+ trn gc _
293
319
  {
294
320
  current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
295
321
 
296
- wanted is (int ->> bool) = (,).
297
- sc ids -- ( -> id) { wanted id -> TRUE. }
298
-
299
- new_inbox is message_t[] = [].
300
- removed is int = 0.
322
+ kept is message_t[] = [].
323
+ deleted is int = 0.
324
+ promoted is int = 0.
301
325
  sc inbox -- ( -> m)
302
326
  {
303
- if wanted (m $msg_id)
327
+ if (m $status) == "ready_to_delete"
304
328
  {
305
- removed -> removed + 1.
329
+ deleted -> deleted + 1.
330
+ }
331
+ elif (m $status) == "processed"
332
+ {
333
+ kept (_count kept|) -> (
334
+ $msg_id -> m $msg_id,
335
+ $sender_id -> m $sender_id,
336
+ $sender_name -> m $sender_name,
337
+ $text -> m $text,
338
+ $date -> m $date,
339
+ $status -> "ready_to_delete"
340
+ ).
341
+ promoted -> promoted + 1.
306
342
  }
307
343
  else
308
344
  {
309
- new_inbox (_count new_inbox|) -> m.
345
+ kept (_count kept|) -> m.
310
346
  }
311
347
  }
312
- inbox -> new_inbox.
348
+ inbox -> kept.
313
349
 
314
350
  return transaction::success [
315
- _return_data ($processed -> removed),
351
+ _return_data ($deleted -> deleted, $promoted -> promoted),
316
352
  _save_state NIL
317
353
  ].
318
354
  }
319
355
 
320
- // Put already-read messages back into the queue (status -> "unread") so a
321
- // different session will pick them up on its next get_messages. The explicit,
322
- // opt-in counterpart to the safe default: forgetting to defer just means the
323
- // message stays handled by you; only a deliberate defer re-exposes it.
356
+ // Put handled messages back into the queue (status -> "unread") so a different
357
+ // session picks them up on its next get_messages. Restores from EITHER post-
358
+ // read generation "processed" or "ready_to_delete" so a message stays
359
+ // recoverable across a full gc cycle. The opt-in counterpart to the safe
360
+ // default: forgetting to defer just means the message stays handled by you.
324
361
  trn defer_messages _:($msg_ids -> ids: int[])
325
362
  {
326
363
  current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
@@ -332,7 +369,7 @@ application actor loads libraries
332
369
  deferred is int = 0.
333
370
  sc inbox -- ( -> m)
334
371
  {
335
- if (wanted (m $msg_id)) && ((m $status) == "read")
372
+ if (wanted (m $msg_id)) && (((m $status) == "processed") || ((m $status) == "ready_to_delete"))
336
373
  {
337
374
  new_inbox (_count new_inbox|) -> (
338
375
  $msg_id -> m $msg_id,
@@ -389,12 +426,13 @@ application actor loads libraries
389
426
  pending_invites -> (data $pending_invites) safe (global_id ->> str).
390
427
  peer_ads -> (data $peer_ads) safe (global_id ->> address_document_types::t_address_document).
391
428
 
392
- // The inbox + next_msg_seq are the only parts the message-lifecycle change
429
+ // The inbox + next_msg_seq are the only parts the message-lifecycle changes
393
430
  // touched. A pre-lifecycle blob has no $next_msg_seq and inbox entries with
394
431
  // no id/status — MIGRATE it forward (the whole point of code-independent
395
432
  // state is that an old export upgrades, never resets): assign each legacy
396
433
  // message a sequential id and status "unread", and seed next_msg_seq past
397
- // them. A current blob is taken as-is.
434
+ // them. A current-shape blob is loaded as-is except the gc vocabulary bump
435
+ // ("read" -> "processed", see below).
398
436
  if (data $next_msg_seq) == NIL
399
437
  {
400
438
  legacy_inbox = (data $inbox) safe (legacy_message_t[]).
@@ -417,7 +455,24 @@ application actor loads libraries
417
455
  }
418
456
  else
419
457
  {
420
- inbox -> (data $inbox) safe (message_t[]).
458
+ // Current-shape blob. One vocabulary change: a blob written before the
459
+ // gc change may carry the old "read" status — migrate it to "processed"
460
+ // (else such a message is stuck: never returned, gc'd, or deferred).
461
+ loaded = (data $inbox) safe (message_t[]).
462
+ upgraded is message_t[] = [].
463
+ sc loaded -- ( -> m)
464
+ {
465
+ mstatus = ((m $status) == "read" ?? "processed" ; m $status).
466
+ upgraded (_count upgraded|) -> (
467
+ $msg_id -> m $msg_id,
468
+ $sender_id -> m $sender_id,
469
+ $sender_name -> m $sender_name,
470
+ $text -> m $text,
471
+ $date -> m $date,
472
+ $status -> mstatus
473
+ ).
474
+ }
475
+ inbox -> upgraded.
421
476
  next_msg_seq -> (data $next_msg_seq) safe int.
422
477
  }
423
478
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adapt-toolkit/a2adapt",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "MCP server daemon for a2adapt — one native ADAPT wrapper hosting N self-sovereign identities, exposing secure agent-to-agent messaging tools over HTTP (Streamable HTTP). Run `a2adapt-mcp start`.",
5
5
  "type": "module",
6
6
  "license": "MIT",