@adapt-toolkit/a2adapt 0.8.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.
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import * as fs2 from "node:fs";
13
13
  // src/config.ts
14
14
  import * as fs from "node:fs";
15
15
  import { homedir } from "node:os";
16
- import { resolve, join, dirname } from "node:path";
16
+ import { resolve, join, dirname, basename } from "node:path";
17
17
  var DEFAULT_CONFIG = {
18
18
  brokerUrl: "ws://a2adapt.adaptframework.solutions/broker",
19
19
  port: 3030,
@@ -70,6 +70,29 @@ function writeConfig(cfg) {
70
70
  }
71
71
  return path;
72
72
  }
73
+ var IDENTITY_FILENAME = ".a2adapt-identity";
74
+ function buildIdentityFile(opts) {
75
+ if (!opts.name.trim()) throw new Error("identity name must not be empty");
76
+ const obj = { identity: opts.name.trim() };
77
+ if (opts.force) obj.force = true;
78
+ obj.expose_local = opts.exposeLocal ?? true;
79
+ obj.local_auto_accept = opts.localAutoAccept ?? true;
80
+ return obj;
81
+ }
82
+ function resolveIdentityFilePath(target) {
83
+ const abs = resolve(target);
84
+ return basename(abs) === IDENTITY_FILENAME ? abs : join(abs, IDENTITY_FILENAME);
85
+ }
86
+ function writeIdentityFile(target, opts, overwrite = false) {
87
+ const obj = buildIdentityFile(opts);
88
+ const path = resolveIdentityFilePath(target);
89
+ if (!overwrite && fs.existsSync(path)) {
90
+ throw new Error(`${path} already exists \u2014 pass overwrite to replace it`);
91
+ }
92
+ fs.mkdirSync(dirname(path), { recursive: true });
93
+ fs.writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
94
+ return path;
95
+ }
73
96
 
74
97
  // src/cli.ts
75
98
  var CONFIG = loadConfig();
@@ -274,6 +297,93 @@ daemon is running (pid ${pid}); restart now to apply? [y/N]: `)).trim().toLowerC
274
297
  const r = spawnSync(process.execPath, [SELF, "start"], { stdio: "inherit" });
275
298
  if (r.status !== 0) process.exit(r.status ?? 1);
276
299
  }
300
+ function flagPair(argv, on, off) {
301
+ if (argv.includes(off)) return { value: false, set: true };
302
+ if (argv.includes(on)) return { value: true, set: true };
303
+ return { value: void 0, set: false };
304
+ }
305
+ function flagValue(argv, name) {
306
+ const i = argv.indexOf(name);
307
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : void 0;
308
+ }
309
+ async function cmdDefineLocalIdentityFile(argv) {
310
+ const name = flagValue(argv, "--name");
311
+ const force = flagPair(argv, "--force-bind", "--no-force-bind");
312
+ const localBook = flagPair(argv, "--local-book", "--no-local-book");
313
+ const autoAccept = flagPair(argv, "--auto-accept-local", "--no-auto-accept-local");
314
+ const overwrite = argv.includes("--overwrite");
315
+ const print = argv.includes("--print");
316
+ const target = flagValue(argv, "--path") ?? flagValue(argv, "--dir") ?? process.cwd();
317
+ const nonInteractive = name !== void 0 || force.set || localBook.set || autoAccept.set || print;
318
+ let opts;
319
+ if (nonInteractive) {
320
+ if (!name || !name.trim()) {
321
+ err("define-local-identity-file: --name is required in non-interactive mode.");
322
+ process.exit(1);
323
+ }
324
+ opts = {
325
+ name: name.trim(),
326
+ force: force.value ?? false,
327
+ exposeLocal: localBook.value ?? true,
328
+ localAutoAccept: autoAccept.value ?? true
329
+ };
330
+ } else {
331
+ opts = await runIdentitySurvey();
332
+ }
333
+ if (print) {
334
+ out(JSON.stringify(buildIdentityFile(opts), null, 2));
335
+ return;
336
+ }
337
+ const path = resolveIdentityFilePath(target);
338
+ if (!overwrite && fs2.existsSync(path)) {
339
+ if (nonInteractive) {
340
+ err(`define-local-identity-file: ${path} already exists \u2014 pass --overwrite to replace it.`);
341
+ process.exit(1);
342
+ }
343
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
344
+ let ok = false;
345
+ try {
346
+ const ans = (await rl.question(`
347
+ ${path} already exists \u2014 overwrite? [y/N]: `)).trim().toLowerCase();
348
+ ok = ans === "y" || ans === "yes";
349
+ } finally {
350
+ rl.close();
351
+ }
352
+ if (!ok) {
353
+ out("aborted \u2014 nothing written.");
354
+ return;
355
+ }
356
+ }
357
+ const written = writeIdentityFile(target, opts, true);
358
+ out("");
359
+ out(`wrote ${written}:`);
360
+ out(JSON.stringify(buildIdentityFile(opts), null, 2));
361
+ }
362
+ async function runIdentitySurvey() {
363
+ out(`a2adapt-mcp define-local-identity-file \u2014 interactive`);
364
+ out(`Answer the prompts; the result is written to ${join2(process.cwd(), IDENTITY_FILENAME)}.`);
365
+ out("");
366
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
367
+ try {
368
+ const askYesNo = async (label, def) => {
369
+ const hint = def ? "Y/n" : "y/N";
370
+ const ans = (await rl.question(` ${label} [${hint}]: `)).trim().toLowerCase();
371
+ if (ans === "") return def;
372
+ return ans === "y" || ans === "yes";
373
+ };
374
+ let name = "";
375
+ while (!name) {
376
+ name = (await rl.question(" Identity name: ")).trim();
377
+ if (!name) out(" (name is required)");
378
+ }
379
+ const force = await askYesNo("Force-bind (pin pre-authorizes evicting another session)?", false);
380
+ const exposeLocal = await askYesNo("Add to the host-local contact book?", true);
381
+ const localAutoAccept = await askYesNo("Auto-accept local invites/introductions?", true);
382
+ return { name, force, exposeLocal, localAutoAccept };
383
+ } finally {
384
+ rl.close();
385
+ }
386
+ }
277
387
  function cmdWatch(which) {
278
388
  const offsets = /* @__PURE__ */ new Map();
279
389
  const scan = (initial) => {
@@ -321,9 +431,15 @@ function cmdWatch(which) {
321
431
  } catch {
322
432
  continue;
323
433
  }
324
- out(
325
- `[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
326
- );
434
+ if (msg.event === "local_contact_request") {
435
+ out(`[${name}] pending local introduction from ${msg.from ?? "?"} \u2014 respond_to_introduction to approve/reject`);
436
+ } else if (msg.event === "pending_message") {
437
+ out(`[${name}] ${msg.from ?? "?"} queued a message awaiting introduction approval (${msg.queued ?? "?"} queued)`);
438
+ } else {
439
+ out(
440
+ `[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
441
+ );
442
+ }
327
443
  }
328
444
  }
329
445
  };
@@ -476,6 +592,12 @@ function usage() {
476
592
  out(" serve run in the foreground (used by start; handy for debugging)");
477
593
  out(" watch [identity] stream one line per new inbound message (wake source for a Monitor)");
478
594
  out("");
595
+ out(" define-local-identity-file write a .a2adapt-identity workspace pin");
596
+ out(" interactive (default): 4-question survey, writes to CWD");
597
+ out(" scripted: --name <s> [--force-bind] [--local-book] [--auto-accept-local]");
598
+ out(" negate with --no-force-bind / --no-local-book / --no-auto-accept-local");
599
+ out(" --dir <path> | --path <file> (default CWD) \xB7 --overwrite \xB7 --print");
600
+ out("");
479
601
  out(" install-service install + start a boot-persistent service (systemd/launchd)");
480
602
  out(" uninstall-service stop + remove that service");
481
603
  out("");
@@ -525,6 +647,9 @@ async function main() {
525
647
  case "setup":
526
648
  await cmdSetup();
527
649
  break;
650
+ case "define-local-identity-file":
651
+ await cmdDefineLocalIdentityFile(process.argv.slice(3));
652
+ break;
528
653
  case "watch":
529
654
  cmdWatch(process.argv[3]);
530
655
  break;
@@ -86,8 +86,14 @@ function findPinnedIdentity(start) {
86
86
  continue;
87
87
  }
88
88
  try {
89
- const name = String(JSON.parse(raw).identity ?? "").trim();
90
- return name || null;
89
+ const parsed = JSON.parse(raw);
90
+ const name = String(parsed.identity ?? "").trim();
91
+ if (!name) return null;
92
+ const pin = { identity: name };
93
+ if (typeof parsed.force === "boolean") pin.force = parsed.force;
94
+ if (typeof parsed.expose_local === "boolean") pin.expose_local = parsed.expose_local;
95
+ if (typeof parsed.local_auto_accept === "boolean") pin.local_auto_accept = parsed.local_auto_accept;
96
+ return pin;
91
97
  } catch {
92
98
  return null;
93
99
  }
@@ -117,9 +123,20 @@ function anyIdentityBound() {
117
123
  return err.code === "EPERM";
118
124
  }
119
125
  }
120
- function renderIdentityDirective(name, exists) {
121
- const bind = exists ? `call \`choose_identity({ name: "${name}" })\` to bind it to this session` : `it does not exist yet \u2014 call \`create_identity({ name: "${name}" })\` to create and bind it`;
122
- return `a2adapt \u2014 this workspace is pinned to identity "${name}" (via ${IDENTITY_FILE}). Before other a2adapt work, ${bind}. Then arm a Monitor on the wake source \`a2adapt-mcp watch ${name}\` so new mail wakes you. Do this once, up front. The pin is the workspace default: if the user explicitly asks to use a different identity, bind that instead \u2014 the user's choice always wins. If choose_identity reports the identity is held by another session, do NOT retry with force \u2014 tell the user it is bound elsewhere and ask whether to forcibly rebind it to this session; only pass force=true after they confirm.`;
126
+ function renderIdentityDirective(pin, exists) {
127
+ const name = pin.identity;
128
+ let bind;
129
+ if (exists) {
130
+ bind = pin.force ? `call \`choose_identity({ name: "${name}", force: true })\` to bind it to this session (the workspace pin sets force, so evicting another holder is pre-authorized \u2014 no need to ask)` : `call \`choose_identity({ name: "${name}" })\` to bind it to this session`;
131
+ } else {
132
+ const extras = [];
133
+ if (pin.expose_local !== void 0) extras.push(`expose_local: ${pin.expose_local}`);
134
+ if (pin.local_auto_accept !== void 0) extras.push(`local_auto_accept: ${pin.local_auto_accept}`);
135
+ const args = [`name: "${name}"`, ...extras].join(", ");
136
+ bind = `it does not exist yet \u2014 call \`create_identity({ ${args} })\` to create and bind it`;
137
+ }
138
+ const forceTail = pin.force ? "" : ` If choose_identity reports the identity is held by another session, do NOT retry with force \u2014 tell the user it is bound elsewhere and ask whether to forcibly rebind it to this session; only pass force=true after they confirm.`;
139
+ return `a2adapt \u2014 this workspace is pinned to identity "${name}" (via ${IDENTITY_FILE}). Before other a2adapt work, ${bind}. Then arm a Monitor on the wake source \`a2adapt-mcp watch ${name}\` so new mail wakes you. Do this once, up front. The pin is the workspace default: if the user explicitly asks to use a different identity, bind that instead \u2014 the user's choice always wins.` + forceTail;
123
140
  }
124
141
  function sessionStart() {
125
142
  const raw = readStdin();
@@ -137,7 +154,7 @@ function sessionStart() {
137
154
  const pinned = findPinnedIdentity(cwd);
138
155
  const unread = collectUnread();
139
156
  const blocks = [];
140
- if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned)));
157
+ if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned.identity)));
141
158
  if (unread.length > 0) blocks.push(renderContext(unread));
142
159
  if (blocks.length === 0) return noop();
143
160
  emit({
@@ -164,7 +181,7 @@ function userPromptSubmit() {
164
181
  continue: true,
165
182
  hookSpecificOutput: {
166
183
  hookEventName: "UserPromptSubmit",
167
- additionalContext: renderIdentityDirective(pinned, identityExists(pinned))
184
+ additionalContext: renderIdentityDirective(pinned, identityExists(pinned.identity))
168
185
  }
169
186
  });
170
187
  }
package/dist/index.js CHANGED
@@ -22427,7 +22427,7 @@ var StreamableHTTPServerTransport = class {
22427
22427
  };
22428
22428
 
22429
22429
  // src/index.ts
22430
- import { resolve as resolve2, join as join2, dirname as dirname2 } from "node:path";
22430
+ import { resolve as resolve2, join as join2, dirname as dirname2, isAbsolute } from "node:path";
22431
22431
  import { fileURLToPath } from "node:url";
22432
22432
  import { randomBytes, randomUUID } from "node:crypto";
22433
22433
  import { createServer as createHttpServer } from "node:http";
@@ -22440,7 +22440,7 @@ import { object_to_adapt_value } from "@adapt-toolkit/sdk/wrapper";
22440
22440
  // src/config.ts
22441
22441
  import * as fs from "node:fs";
22442
22442
  import { homedir } from "node:os";
22443
- import { resolve, join, dirname } from "node:path";
22443
+ import { resolve, join, dirname, basename } from "node:path";
22444
22444
  var DEFAULT_CONFIG = {
22445
22445
  brokerUrl: "ws://a2adapt.adaptframework.solutions/broker",
22446
22446
  port: 3030,
@@ -22487,9 +22487,32 @@ function loadConfig() {
22487
22487
  gcIntervalMs: envInt("A2ADAPT_GC_INTERVAL_MS") ?? file.gcIntervalMs ?? DEFAULT_CONFIG.gcIntervalMs
22488
22488
  };
22489
22489
  }
22490
+ var IDENTITY_FILENAME = ".a2adapt-identity";
22491
+ function buildIdentityFile(opts) {
22492
+ if (!opts.name.trim()) throw new Error("identity name must not be empty");
22493
+ const obj = { identity: opts.name.trim() };
22494
+ if (opts.force) obj.force = true;
22495
+ obj.expose_local = opts.exposeLocal ?? true;
22496
+ obj.local_auto_accept = opts.localAutoAccept ?? true;
22497
+ return obj;
22498
+ }
22499
+ function resolveIdentityFilePath(target) {
22500
+ const abs = resolve(target);
22501
+ return basename(abs) === IDENTITY_FILENAME ? abs : join(abs, IDENTITY_FILENAME);
22502
+ }
22503
+ function writeIdentityFile(target, opts, overwrite = false) {
22504
+ const obj = buildIdentityFile(opts);
22505
+ const path = resolveIdentityFilePath(target);
22506
+ if (!overwrite && fs.existsSync(path)) {
22507
+ throw new Error(`${path} already exists \u2014 pass overwrite to replace it`);
22508
+ }
22509
+ fs.mkdirSync(dirname(path), { recursive: true });
22510
+ fs.writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
22511
+ return path;
22512
+ }
22490
22513
 
22491
22514
  // src/index.ts
22492
- var VERSION = true ? "0.8.0" : "0.0.0-dev";
22515
+ var VERSION = true ? "0.9.0" : "0.0.0-dev";
22493
22516
  var CONFIG = loadConfig();
22494
22517
  var STATE_DIR = CONFIG.stateDir;
22495
22518
  var BROKER_URL = CONFIG.brokerUrl;
@@ -22499,6 +22522,7 @@ var GC_INTERVAL_MS = CONFIG.gcIntervalMs;
22499
22522
  var log = (...parts) => process.stderr.write(`a2adapt: ${parts.join(" ")}
22500
22523
  `);
22501
22524
  var NAME_RE = /^[A-Za-z0-9 _.-]{1,64}$/;
22525
+ var BOOK_DIR_NAME = "contact-book";
22502
22526
  function validateName(name) {
22503
22527
  if (!NAME_RE.test(name)) {
22504
22528
  return "name must be 1-64 chars of letters, digits, space, _ . or -";
@@ -22506,6 +22530,9 @@ function validateName(name) {
22506
22530
  if (name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
22507
22531
  return "invalid name";
22508
22532
  }
22533
+ if (name === BOOK_DIR_NAME) {
22534
+ return `"${BOOK_DIR_NAME}" is reserved for the local contact book`;
22535
+ }
22509
22536
  return null;
22510
22537
  }
22511
22538
  function locateUnit() {
@@ -22528,6 +22555,8 @@ function locateUnit() {
22528
22555
  var UNIT;
22529
22556
  var wrapper;
22530
22557
  var identities = /* @__PURE__ */ new Map();
22558
+ var registrar = null;
22559
+ var registrarAdBlob = null;
22531
22560
  var sessionBinding = /* @__PURE__ */ new Map();
22532
22561
  var bindingOwner = /* @__PURE__ */ new Map();
22533
22562
  var evictedSessions = /* @__PURE__ */ new Set();
@@ -22551,6 +22580,102 @@ function listPersistedNames() {
22551
22580
  if (!fs2.existsSync(STATE_DIR)) return [];
22552
22581
  return fs2.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs2.existsSync(seedPath(join2(STATE_DIR, d.name)))).map((d) => d.name);
22553
22582
  }
22583
+ var bookDir = () => join2(STATE_DIR, BOOK_DIR_NAME);
22584
+ var registrarSeedPath = () => join2(bookDir(), "registrar.seed");
22585
+ var bookPath = () => join2(bookDir(), "book.json");
22586
+ function readBook() {
22587
+ try {
22588
+ const parsed = JSON.parse(fs2.readFileSync(bookPath(), "utf8"));
22589
+ return parsed && typeof parsed.entries === "object" ? parsed.entries : {};
22590
+ } catch {
22591
+ return {};
22592
+ }
22593
+ }
22594
+ function writeBook(entries) {
22595
+ fs2.mkdirSync(bookDir(), { recursive: true });
22596
+ const tmp = `${bookPath()}.tmp`;
22597
+ fs2.writeFileSync(tmp, JSON.stringify({ v: 1, entries }, null, 2), { mode: 384 });
22598
+ fs2.renameSync(tmp, bookPath());
22599
+ }
22600
+ function exportAdBlob(id) {
22601
+ return Buffer.from(readonlyTx(id, "::actor::export_address_document").GetBinary());
22602
+ }
22603
+ async function publishToBook(id) {
22604
+ if (!registrar) throw new Error("registrar is not available");
22605
+ const adBlob = exportAdBlob(id);
22606
+ const sigData = await mutatingTx(registrar, "::actor::sign_book_entry", {
22607
+ name: id.name,
22608
+ ad: registrar.pw.packet.NewBinaryFromBuffer(adBlob)
22609
+ });
22610
+ const entries = readBook();
22611
+ entries[id.name] = {
22612
+ v: 1,
22613
+ name: id.name,
22614
+ container_id: id.cid,
22615
+ address_document: adBlob.toString("base64url"),
22616
+ published_at: (/* @__PURE__ */ new Date()).toISOString(),
22617
+ registrar_sig: Buffer.from(sigData.Reduce("sig").GetBinary()).toString("base64url")
22618
+ };
22619
+ writeBook(entries);
22620
+ log(`[${id.name}] published to the local contact book`);
22621
+ }
22622
+ function unpublishFromBook(name) {
22623
+ const entries = readBook();
22624
+ if (!(name in entries)) return;
22625
+ delete entries[name];
22626
+ writeBook(entries);
22627
+ log(`[${name}] removed from the local contact book`);
22628
+ }
22629
+ async function pinRegistrar(id) {
22630
+ if (!registrarAdBlob) throw new Error("registrar is not available");
22631
+ await mutatingTx(id, "::actor::pin_registrar", {
22632
+ registrar_ad: id.pw.packet.NewBinaryFromBuffer(registrarAdBlob)
22633
+ });
22634
+ }
22635
+ async function sendViaLocalBook(id, contact, text) {
22636
+ if (!registrar) {
22637
+ throw new Error(`"${contact}" is not a contact, and the local contact book is unavailable.`);
22638
+ }
22639
+ const entries = readBook();
22640
+ const entry = entries[contact] ?? Object.values(entries).find((e) => e.container_id === contact);
22641
+ if (!entry) {
22642
+ throw new Error(
22643
+ `"${contact}" is not a contact and has no local contact-book entry. Use generate_invite/add_contact for remote peers, or list_local_contact_book to see local ones.`
22644
+ );
22645
+ }
22646
+ if (entry.container_id === id.cid) {
22647
+ throw new Error("that contact-book entry is this identity itself.");
22648
+ }
22649
+ const targetAd = Buffer.from(entry.address_document, "base64url");
22650
+ const entrySig = Buffer.from(entry.registrar_sig, "base64url");
22651
+ const joinerAd = exportAdBlob(id);
22652
+ const minted = await mutatingTx(registrar, "::actor::mint_introduction", {
22653
+ joiner_ad: registrar.pw.packet.NewBinaryFromBuffer(joinerAd),
22654
+ target_ad: registrar.pw.packet.NewBinaryFromBuffer(targetAd)
22655
+ });
22656
+ const introBlob = Buffer.from(minted.Reduce("intro").GetBinary());
22657
+ await mutatingTx(id, "::actor::connect_local", {
22658
+ name: entry.name,
22659
+ target_ad: id.pw.packet.NewBinaryFromBuffer(targetAd),
22660
+ intro: id.pw.packet.NewBinaryFromBuffer(introBlob),
22661
+ entry_sig: id.pw.packet.NewBinaryFromBuffer(entrySig),
22662
+ text
22663
+ });
22664
+ return `"${entry.name}" was not a contact yet \u2014 connected via the local contact book and sent the message with the introduction. If "${entry.name}" requires approval for local introductions, delivery completes once they approve.`;
22665
+ }
22666
+ async function ensureRegistrar() {
22667
+ fs2.mkdirSync(bookDir(), { recursive: true });
22668
+ let seed;
22669
+ try {
22670
+ seed = fs2.readFileSync(registrarSeedPath(), "utf8").trim();
22671
+ } catch {
22672
+ seed = randomBytes(24).toString("hex");
22673
+ fs2.writeFileSync(registrarSeedPath(), seed, { mode: 384 });
22674
+ }
22675
+ registrar = await createPacket(BOOK_DIR_NAME, seed, bookDir(), false);
22676
+ registrarAdBlob = exportAdBlob(registrar);
22677
+ log(`contact-book registrar ready (${registrar.cid})`);
22678
+ }
22554
22679
  function hasSavedState(dir) {
22555
22680
  try {
22556
22681
  return fs2.existsSync(dataPath(dir)) && fs2.statSync(dataPath(dir)).size > 0;
@@ -22572,11 +22697,10 @@ function saveState(id) {
22572
22697
  log(`[${id.name}] failed to save state:`, String(err));
22573
22698
  }
22574
22699
  }
22575
- function appendNotifyLog(id, from, msgId, date3) {
22700
+ function appendNotifyLog(id, event) {
22576
22701
  try {
22577
22702
  fs2.mkdirSync(id.dir, { recursive: true });
22578
- const line = JSON.stringify({ event: "message_received", from, msg_id: msgId, date: date3 }) + "\n";
22579
- fs2.appendFileSync(notifyLogPath(id.dir), line);
22703
+ fs2.appendFileSync(notifyLogPath(id.dir), JSON.stringify(event) + "\n");
22580
22704
  } catch (err) {
22581
22705
  log(`[${id.name}] failed to append notifications.log:`, String(err));
22582
22706
  }
@@ -22660,7 +22784,7 @@ function wireHandlers(id) {
22660
22784
  const sender = payload.Reduce("sender_name").Visualize();
22661
22785
  const msgId = payload.Reduce("msg_id").Visualize();
22662
22786
  const date3 = payload.Reduce("date").Visualize();
22663
- appendNotifyLog(id, sender, msgId, date3);
22787
+ appendNotifyLog(id, { event: "message_received", from: sender, msg_id: msgId, date: date3 });
22664
22788
  refreshUnread(id);
22665
22789
  process.nextTick(
22666
22790
  () => pushNotification(id.name, `[${id.name}] new message from ${sender} (#${msgId})`)
@@ -22671,6 +22795,29 @@ function wireHandlers(id) {
22671
22795
  process.nextTick(
22672
22796
  () => pushNotification(id.name, `[${id.name}] contact "${name}" (${cid}) accepted your invite.`)
22673
22797
  );
22798
+ } else if (event === "local_contact_added") {
22799
+ const name = payload.Reduce("name").Visualize();
22800
+ const cid = payload.Reduce("container_id").Visualize();
22801
+ process.nextTick(
22802
+ () => pushNotification(id.name, `[${id.name}] local contact "${name}" (${cid}) connected via the contact book.`)
22803
+ );
22804
+ } else if (event === "local_contact_request") {
22805
+ const name = payload.Reduce("name").Visualize();
22806
+ const cid = payload.Reduce("container_id").Visualize();
22807
+ appendNotifyLog(id, { event: "local_contact_request", from: name });
22808
+ process.nextTick(
22809
+ () => pushNotification(
22810
+ id.name,
22811
+ `[${id.name}] pending local introduction from "${name}" (${cid}) \u2014 approve or reject with respond_to_introduction.`
22812
+ )
22813
+ );
22814
+ } else if (event === "pending_message") {
22815
+ const name = payload.Reduce("sender_name").Visualize();
22816
+ const queued = payload.Reduce("queued").Visualize();
22817
+ appendNotifyLog(id, { event: "pending_message", from: name, queued });
22818
+ process.nextTick(
22819
+ () => pushNotification(id.name, `[${id.name}] "${name}" queued a message awaiting introduction approval (${queued} queued).`)
22820
+ );
22674
22821
  }
22675
22822
  return;
22676
22823
  }
@@ -22689,7 +22836,7 @@ function wireHandlers(id) {
22689
22836
  }
22690
22837
  };
22691
22838
  }
22692
- function createPacket(name, seed, dir) {
22839
+ function createPacket(name, seed, dir, track = true) {
22693
22840
  const config2 = new PacketWrapperConfigurator();
22694
22841
  config2.process_arguments([
22695
22842
  "--unit_hash",
@@ -22718,7 +22865,7 @@ function createPacket(name, seed, dir) {
22718
22865
  lock: Promise.resolve()
22719
22866
  };
22720
22867
  wireHandlers(id);
22721
- identities.set(name, id);
22868
+ if (track) identities.set(name, id);
22722
22869
  log(`[${name}] packet created \u2014 container id ${id.cid}`);
22723
22870
  resolveCreate(id);
22724
22871
  },
@@ -22726,13 +22873,20 @@ function createPacket(name, seed, dir) {
22726
22873
  );
22727
22874
  });
22728
22875
  }
22729
- async function provisionIdentity(name) {
22876
+ async function provisionIdentity(name, opts = { exposeLocal: true, localAutoAccept: true }) {
22730
22877
  const dir = identityDir(name);
22731
22878
  fs2.mkdirSync(dir, { recursive: true });
22732
22879
  const seed = randomBytes(24).toString("hex");
22733
22880
  fs2.writeFileSync(seedPath(dir), seed, { mode: 384 });
22734
22881
  const id = await createPacket(name, seed, dir);
22735
22882
  await mutatingTx(id, "::actor::set_my_name", { name });
22883
+ await pinRegistrar(id);
22884
+ if (!opts.localAutoAccept) {
22885
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept: false });
22886
+ }
22887
+ if (opts.exposeLocal) {
22888
+ await publishToBook(id);
22889
+ }
22736
22890
  saveState(id);
22737
22891
  return id;
22738
22892
  }
@@ -22769,6 +22923,11 @@ async function bootWrapper() {
22769
22923
  wrapper = await adapt_wrapper.start(argv);
22770
22924
  wrapper.on_packet_created_cb = (cid) => log(`wrapper: packet ready ${cid.slice(0, 12)}\u2026`);
22771
22925
  wrapper.start();
22926
+ try {
22927
+ await ensureRegistrar();
22928
+ } catch (err) {
22929
+ log("failed to start the contact-book registrar (local contact book disabled):", String(err));
22930
+ }
22772
22931
  const names = listPersistedNames();
22773
22932
  if (names.length === 0) {
22774
22933
  log("no persisted identities \u2014 start with create_identity");
@@ -22776,7 +22935,10 @@ async function bootWrapper() {
22776
22935
  log(`restoring ${names.length} identit${names.length === 1 ? "y" : "ies"}: ${names.join(", ")}`);
22777
22936
  for (const name of names) {
22778
22937
  try {
22779
- await restoreIdentity(name);
22938
+ const id = await restoreIdentity(name);
22939
+ if (registrar) {
22940
+ await pinRegistrar(id);
22941
+ }
22780
22942
  } catch (err) {
22781
22943
  log(`failed to restore "${name}":`, String(err));
22782
22944
  }
@@ -22841,6 +23003,20 @@ function renderInbox(v) {
22841
23003
  }
22842
23004
  return out;
22843
23005
  }
23006
+ function renderPending(v) {
23007
+ const out = [];
23008
+ if (v.IsNil()) return out;
23009
+ for (const key of v.GetKeys()) {
23010
+ const p = v.Reduce(key);
23011
+ if (p.IsNil()) continue;
23012
+ out.push({
23013
+ container_id: typeof key === "string" ? key : key.Visualize(),
23014
+ name: p.Reduce("name").Visualize(),
23015
+ queued: parseInt(p.Reduce("queued").Visualize(), 10) || 0
23016
+ });
23017
+ }
23018
+ return out;
23019
+ }
22844
23020
  function textResult(text, isError = false) {
22845
23021
  return { content: [{ type: "text", text }], isError };
22846
23022
  }
@@ -22876,21 +23052,52 @@ function createMcpServer(getSessionId) {
22876
23052
  };
22877
23053
  server.tool(
22878
23054
  "create_identity",
22879
- "Create a new self-sovereign identity (an ADAPT node) with the given display name and bind it to this session. The name is what peers see for you in invites. Persisted permanently; reject if the name already exists.",
22880
- { name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".') },
22881
- async ({ name }) => {
23055
+ "Create a new self-sovereign identity (an ADAPT node) with the given display name and bind it to this session. The name is what peers see for you in invites. Persisted permanently; reject if the name already exists. By default the identity is published to the LOCAL contact book, so other identities on this host can message it by name without an invite; pass expose_local=false to opt out.",
23056
+ {
23057
+ name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".'),
23058
+ expose_local: external_exports.boolean().default(true).describe("Publish this identity in the host-local contact book."),
23059
+ local_auto_accept: external_exports.boolean().default(true).describe("Auto-accept local contact-book introductions (false = they queue for approval).")
23060
+ },
23061
+ async ({ name, expose_local, local_auto_accept }) => {
22882
23062
  const bad = validateName(name);
22883
23063
  if (bad) return textResult(`create_identity failed: ${bad}`, true);
22884
23064
  if (identities.has(name)) return textResult(`create_identity failed: an identity named "${name}" already exists.`, true);
22885
23065
  try {
22886
- const id = await provisionIdentity(name);
23066
+ const id = await provisionIdentity(name, { exposeLocal: expose_local, localAutoAccept: local_auto_accept });
22887
23067
  bindSession(getSessionId(), name);
22888
- return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.`);
23068
+ const exposure = expose_local ? ` Published to the local contact book${local_auto_accept ? "" : " (introductions require approval)"}.` : " Not exposed in the local contact book.";
23069
+ return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.${exposure}`);
22889
23070
  } catch (err) {
22890
23071
  return textResult(`create_identity failed: ${String(err)}`, true);
22891
23072
  }
22892
23073
  }
22893
23074
  );
23075
+ server.tool(
23076
+ "define_local_identity_file",
23077
+ "Write a `.a2adapt-identity` workspace-pin file that ties a directory to an identity, so a future Claude Code session here auto-binds it (and the SessionStart hook arms the right Monitor). Use this instead of hand-writing the file. Because this daemon is shared and its CWD is not the user's project, you MUST pass an absolute `path` (the target directory, or the full path ending in .a2adapt-identity). Refuses to overwrite unless overwrite=true.",
23078
+ {
23079
+ name: external_exports.string().min(1).describe("Identity name the workspace belongs to."),
23080
+ path: external_exports.string().min(1).describe("Absolute target: a directory (file is created inside it) or a full path ending in .a2adapt-identity."),
23081
+ force: external_exports.boolean().default(false).describe("Pin pre-authorizes force-binding (evicting another session) \u2014 no user prompt at bind time."),
23082
+ expose_local: external_exports.boolean().default(true).describe("Publish this identity in the host-local contact book."),
23083
+ local_auto_accept: external_exports.boolean().default(true).describe("Auto-accept local contact-book introductions (false = they queue for approval)."),
23084
+ overwrite: external_exports.boolean().default(false).describe("Replace an existing .a2adapt-identity file.")
23085
+ },
23086
+ async ({ name, path, force, expose_local, local_auto_accept, overwrite }) => {
23087
+ if (!isAbsolute(path)) {
23088
+ return textResult(`define_local_identity_file failed: path must be absolute (got "${path}").`, true);
23089
+ }
23090
+ const opts = { name, force, exposeLocal: expose_local, localAutoAccept: local_auto_accept };
23091
+ try {
23092
+ const written = writeIdentityFile(path, opts, overwrite);
23093
+ const json = JSON.stringify(buildIdentityFile(opts), null, 2);
23094
+ return textResult(`Wrote ${written}:
23095
+ ${json}`);
23096
+ } catch (err) {
23097
+ return textResult(`define_local_identity_file failed: ${String(err)}`, true);
23098
+ }
23099
+ }
23100
+ );
22894
23101
  server.tool(
22895
23102
  "choose_identity",
22896
23103
  "Bind an existing identity to this session so the messaging tools act as it. Binding is exclusive: if the identity is already in use by another session, this is declined unless force=true, which evicts the other session. Never pass force=true on your own initiative \u2014 ask the user and get an explicit confirmation first.",
@@ -22960,6 +23167,11 @@ ${lines.join("\n")}`);
22960
23167
  log(`remove_packet(${id.cid}) failed:`, String(err));
22961
23168
  }
22962
23169
  identities.delete(name);
23170
+ try {
23171
+ unpublishFromBook(name);
23172
+ } catch (err) {
23173
+ log(`failed to unpublish "${name}" from the contact book:`, String(err));
23174
+ }
22963
23175
  const holder = bindingOwner.get(name);
22964
23176
  if (holder) {
22965
23177
  bindingOwner.delete(name);
@@ -23040,24 +23252,113 @@ ${blob}`
23040
23252
  );
23041
23253
  server.tool(
23042
23254
  "list_contacts",
23043
- "List the contacts the bound identity knows about (name + container id).",
23255
+ "List the contacts the bound identity knows about (name + container id), plus any pending local-contact-book introductions awaiting approval.",
23044
23256
  {},
23045
23257
  async () => {
23046
23258
  const { id, err } = boundOr();
23047
23259
  if (err) return err;
23048
23260
  try {
23049
23261
  const contacts = renderContacts(readonlyTx(id, "::actor::list_contacts"));
23050
- if (contacts.length === 0) return textResult("No contacts yet.");
23051
- return textResult(`Contacts (${contacts.length}):
23052
- ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`);
23262
+ const pending = renderPending(readonlyTx(id, "::actor::list_pending_introductions"));
23263
+ const lines = [];
23264
+ lines.push(
23265
+ contacts.length === 0 ? "No contacts yet." : `Contacts (${contacts.length}):
23266
+ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`
23267
+ );
23268
+ if (pending.length > 0) {
23269
+ lines.push(
23270
+ `Pending local introductions (${pending.length}) \u2014 approve/reject with respond_to_introduction:
23271
+ ` + pending.map((p) => `\u2022 ${p.name} \u2014 ${p.container_id} (${p.queued} queued message${p.queued === 1 ? "" : "s"})`).join("\n")
23272
+ );
23273
+ }
23274
+ return textResult(lines.join("\n\n"));
23053
23275
  } catch (e) {
23054
23276
  return textResult(`list_contacts failed: ${String(e)}`, true);
23055
23277
  }
23056
23278
  }
23057
23279
  );
23280
+ server.tool(
23281
+ "list_local_contact_book",
23282
+ "List the host-local contact book: identities on THIS host that are exposed for inviteless connection. Any of them can be messaged directly with send_message.",
23283
+ {},
23284
+ async () => {
23285
+ const entries = Object.values(readBook());
23286
+ if (entries.length === 0) return textResult("The local contact book is empty.");
23287
+ const sid = getSessionId();
23288
+ const mine = sessionBinding.get(sid);
23289
+ const lines = entries.map((e) => {
23290
+ const tag = e.name === mine ? " \u2190 this session" : "";
23291
+ return `\u2022 ${e.name} \u2014 ${e.container_id} (published ${e.published_at})${tag}`;
23292
+ });
23293
+ return textResult(`Local contact book (${entries.length}):
23294
+ ${lines.join("\n")}`);
23295
+ }
23296
+ );
23297
+ server.tool(
23298
+ "set_local_book_policy",
23299
+ "Change the bound identity's local-contact-book settings: expose (publish/unpublish it in the book) and/or auto_accept (whether local introductions are accepted automatically or queue for approval).",
23300
+ {
23301
+ expose: external_exports.boolean().optional().describe("Publish (true) or remove (false) this identity in the local contact book."),
23302
+ auto_accept: external_exports.boolean().optional().describe("Auto-accept local introductions (false = queue them for approval).")
23303
+ },
23304
+ async ({ expose, auto_accept }) => {
23305
+ const { id, err } = boundOr();
23306
+ if (err) return err;
23307
+ if (expose === void 0 && auto_accept === void 0) {
23308
+ return textResult("set_local_book_policy: pass expose and/or auto_accept.", true);
23309
+ }
23310
+ const done = [];
23311
+ try {
23312
+ if (auto_accept !== void 0) {
23313
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept });
23314
+ done.push(`auto_accept=${auto_accept}`);
23315
+ }
23316
+ if (expose === true) {
23317
+ await publishToBook(id);
23318
+ done.push("published in the local contact book");
23319
+ } else if (expose === false) {
23320
+ unpublishFromBook(id.name);
23321
+ done.push("removed from the local contact book");
23322
+ }
23323
+ return textResult(`Updated "${id.name}": ${done.join("; ")}.`);
23324
+ } catch (e) {
23325
+ return textResult(`set_local_book_policy failed: ${String(e)}`, true);
23326
+ }
23327
+ }
23328
+ );
23329
+ server.tool(
23330
+ "respond_to_introduction",
23331
+ "Approve or reject a pending local-contact-book introduction (see list_contacts for the pending list). Approving registers the contact and delivers any messages it queued while waiting; rejecting drops the introduction and its queue.",
23332
+ {
23333
+ contact: external_exports.string().min(1).describe("Pending introduction to act on (name or container id)."),
23334
+ action: external_exports.enum(["approve", "reject"]).describe("approve or reject.")
23335
+ },
23336
+ async ({ contact, action }) => {
23337
+ const { id, err } = boundOr();
23338
+ if (err) return err;
23339
+ try {
23340
+ if (action === "approve") {
23341
+ const data2 = await mutatingTx(id, "::actor::approve_introduction", { contact });
23342
+ const name2 = data2.Reduce("approved").Visualize();
23343
+ const cid = data2.Reduce("container_id").Visualize();
23344
+ const flushed = data2.Reduce("flushed").Visualize();
23345
+ refreshUnread(id);
23346
+ return textResult(
23347
+ `Approved "${name2}" (${cid}) \u2014 now a contact. ${flushed} queued message(s) moved to the inbox (read them with get_messages).`
23348
+ );
23349
+ }
23350
+ const data = await mutatingTx(id, "::actor::reject_introduction", { contact });
23351
+ const name = data.Reduce("rejected").Visualize();
23352
+ const dropped = data.Reduce("dropped_messages").Visualize();
23353
+ return textResult(`Rejected the introduction from "${name}" and dropped ${dropped} queued message(s).`);
23354
+ } catch (e) {
23355
+ return textResult(`respond_to_introduction failed: ${String(e)}`, true);
23356
+ }
23357
+ }
23358
+ );
23058
23359
  server.tool(
23059
23360
  "send_message",
23060
- "Send an end-to-end-encrypted message to a known contact (by name or container id). Requires a bound identity.",
23361
+ "Send an end-to-end-encrypted message to a known contact (by name or container id). If the recipient is not a contact yet but is published in the host-local contact book, the connection is established automatically (no invite needed) and the message is delivered with the introduction. Requires a bound identity.",
23061
23362
  {
23062
23363
  contact: external_exports.string().min(1).describe("Contact name or container id to send to."),
23063
23364
  text: external_exports.string().min(1).describe("The message text.")
@@ -23069,13 +23370,21 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
23069
23370
  await mutatingTx(id, "::actor::send_message", { contact, text });
23070
23371
  return textResult(`Message sent to "${contact}".`);
23071
23372
  } catch (e) {
23072
- return textResult(`send_message failed: ${String(e)}`, true);
23373
+ if (!/Unknown contact/.test(String(e))) {
23374
+ return textResult(`send_message failed: ${String(e)}`, true);
23375
+ }
23376
+ try {
23377
+ const sent = await sendViaLocalBook(id, contact, text);
23378
+ return textResult(sent);
23379
+ } catch (e2) {
23380
+ return textResult(`send_message failed: ${String(e2)}`, true);
23381
+ }
23073
23382
  }
23074
23383
  }
23075
23384
  );
23076
23385
  server.tool(
23077
23386
  "remove_contact",
23078
- "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.",
23387
+ "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. Note: if the removed peer is still published in the host-local contact book, a later send_message to it will reconnect through the book. Requires a bound identity.",
23079
23388
  { contact: external_exports.string().min(1).describe("Contact name or container id to remove.") },
23080
23389
  async ({ contact }) => {
23081
23390
  const { id, err } = boundOr();
@@ -17,9 +17,22 @@
17
17
  // defer_messages — flip processed/ready_to_delete messages back to unread
18
18
  // gc — two-generation GC of handled messages (host-fired, not a tool)
19
19
  //
20
+ // Local contact book (host-fired transactions; see the design notes above
21
+ // local_introduce below):
22
+ // export_address_document — (readonly) my signed address document as a blob
23
+ // pin_registrar — pin the host registrar's signing keys (pin-once)
24
+ // set_local_policy — toggle auto-accept of local introductions
25
+ // mint_introduction — registrar-only in practice: sign an introduction credential
26
+ // sign_book_entry — registrar-only in practice: sign a contact-book entry
27
+ // connect_local — register a book contact + send local_introduce (+ first message)
28
+ // approve_introduction — accept a pending local introduction (flushes its queue)
29
+ // reject_introduction — drop a pending local introduction
30
+ // list_pending_introductions — (readonly) pending introductions (names + queue sizes)
31
+ //
20
32
  // External transactions (inbound, not exposed as tools):
21
33
  // accept_contact — inviter learns the joiner's identity + name
22
34
  // receive_message — store a decrypted inbound message
35
+ // local_introduce — same-host peer connects via the local contact book
23
36
  //
24
37
  // Naming model (personal invites): generate_invite('Bob') tags a pending invite
25
38
  // with the peer-name "Bob"; whoever redeems it is registered under "Bob" (the
@@ -67,6 +80,38 @@ application actor loads libraries
67
80
  // migrates blobs in this shape forward — see below.
68
81
  metadef legacy_message_t: ($sender_id -> global_id, $sender_name -> str, $text -> str, $date -> time).
69
82
 
83
+ // ---- local contact book wire shapes ---------------------------------
84
+ // Introduction credential, minted PER CONNECT ATTEMPT by the host's
85
+ // registrar packet (never stored in the book). It binds the joiner's
86
+ // identity AND address document to one target, with freshness + a nonce,
87
+ // so possession of book material alone authorizes nothing: only the
88
+ // registrar (whose key never leaves the host) can mint one, which is
89
+ // what makes "local" a cryptographic property rather than a convention.
90
+ metadef intro_t: (
91
+ $version -> int,
92
+ $joiner_cid -> global_id,
93
+ $joiner_ad_hash -> hash_code,
94
+ $target_cid -> global_id,
95
+ $iat -> time,
96
+ $nonce -> global_id
97
+ ).
98
+ metadef signed_intro_t: ($i -> intro_t, $s -> crypto_signature).
99
+ // What the registrar signs for a contact-book entry (tamper-evidence for
100
+ // the host-side book file; verified by the SENDER in connect_local).
101
+ metadef book_entry_t: ($version -> int, $name -> str, $ad_hash -> hash_code).
102
+ // A not-yet-approved local introduction, with its bounded message queue.
103
+ metadef pending_msg_t: ($text -> str, $date -> time).
104
+ metadef pending_intro_t: ($name -> str, $ad -> address_document_types::t_address_document, $messages -> pending_msg_t[]).
105
+ metadef pending_view_t: ($name -> str, $queued -> int).
106
+
107
+ // Acceptance window for an introduction credential (seconds since mint;
108
+ // small negative slack for clock oddities) and the matching nonce-table
109
+ // retention horizon (window + slack, so a nonce outlives its credential).
110
+ intro_max_age_seconds is int = 300.
111
+ intro_max_skew_seconds is int = 30.
112
+ seen_nonce_cap is int = 1024.
113
+ pending_queue_cap is int = 50.
114
+
70
115
  // Wire the deserialization primitive into the libraries that need it.
71
116
  _read_or_abort = grab( _read_or_abort ).
72
117
  key_storage::init ($_read_or_abort -> _read_or_abort).
@@ -94,6 +139,20 @@ application actor loads libraries
94
139
  // every peer's keys in key_storage so encrypted channels survive the upgrade
95
140
  // with no re-handshake. Only peer PUBLIC keys travel here, never secrets.
96
141
  peer_ads is (global_id ->> address_document_types::t_address_document) = (,).
142
+ // The host registrar's address document (pinned once at identity
143
+ // creation / injected on upgrade) — its $identity $key_list is what
144
+ // introduction credentials are verified against. NIL means this identity
145
+ // accepts no local-book introductions at all.
146
+ registrar_ad is address_document_types::t_address_document+ = NIL.
147
+ // Whether a verified local introduction registers the joiner immediately
148
+ // (TRUE) or parks it in pending_introductions for explicit approval.
149
+ local_auto_accept is bool = TRUE.
150
+ // Replay guard for introduction credentials: nonce -> when it was seen.
151
+ // Lazily purged past the freshness horizon, hard-capped at seen_nonce_cap.
152
+ seen_nonces is (global_id ->> time) = (,).
153
+ // Verified-but-unapproved local introductions (when auto-accept is off),
154
+ // each with a bounded queue of messages awaiting approval.
155
+ pending_introductions is (global_id ->> pending_intro_t) = (,).
97
156
 
98
157
  // Signal the host to persist the packet. Only emitted at the end of a
99
158
  // complete procedure — intermediate states (e.g. channel handshake) are
@@ -114,6 +173,35 @@ application actor loads libraries
114
173
  abort "Unknown contact: " + ref when found == NIL.
115
174
  return found?.
116
175
  }
176
+
177
+ // Append a message to the inbox under a fresh id; returns the id.
178
+ fn deposit_message (sender_id: global_id, sender_name: str, text: str, msg_date: time) -> int
179
+ {
180
+ mid = next_msg_seq.
181
+ next_msg_seq -> next_msg_seq + 1.
182
+ inbox (_count inbox|) -> (
183
+ $msg_id -> mid,
184
+ $sender_id -> sender_id,
185
+ $sender_name -> sender_name,
186
+ $text -> text,
187
+ $date -> msg_date,
188
+ $status -> "unread"
189
+ ).
190
+ return mid.
191
+ }
192
+
193
+ // Resolve a pending introduction by joiner name or stringified container
194
+ // id; aborts when nothing matches.
195
+ fn resolve_pending (ref: str) -> global_id
196
+ {
197
+ found is global_id+ = NIL.
198
+ sc pending_introductions -- (cid -> p) ?? found == NIL && ((p $name) == ref || (_str cid) == ref)
199
+ {
200
+ found -> cid.
201
+ }
202
+ abort "No pending introduction matches: " + ref when found == NIL.
203
+ return found?.
204
+ }
117
205
  }
118
206
 
119
207
  // ---- user transactions --------------------------------------------------
@@ -394,6 +482,180 @@ application actor loads libraries
394
482
  ].
395
483
  }
396
484
 
485
+ // ---- local contact book ---------------------------------------------------
486
+ // The book itself lives HOST-SIDE (wrapper-local file, remote peers have no
487
+ // path to it) and stores only public address material — essentially a stored
488
+ // multi-use invite. It bypasses invite generation/delivery, NOT the key
489
+ // exchange: connecting still runs the normal encrypted_channel handshake.
490
+ // Authorization is per attempt: the host's registrar packet mints a fresh,
491
+ // short-lived, registrar-signed introduction credential for each connect, and
492
+ // the target verifies it against its pinned registrar keys. An external peer
493
+ // can never produce one, so the local boundary holds cryptographically.
494
+
495
+ trn readonly export_address_document _
496
+ {
497
+ return (_write address_document::get_my_address_document()).
498
+ }
499
+
500
+ trn pin_registrar _:($registrar_ad -> registrar_ad_blob: bin, $replace -> replace: bool+)
501
+ {
502
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
503
+
504
+ ad = (_read_or_abort registrar_ad_blob) safe address_document_types::t_address_document.
505
+ if registrar_ad != NIL
506
+ {
507
+ // Idempotent re-pin of the same keys is a no-op; CHANGING the pinned
508
+ // keys is a deliberate act and must be requested explicitly, so no
509
+ // future internal-path code can substitute a registrar silently.
510
+ if (_value_id (registrar_ad? $identity $key_list)) == (_value_id (ad $identity $key_list))
511
+ {
512
+ return transaction::success [
513
+ _return_data ($pinned -> TRUE, $changed -> FALSE)
514
+ ].
515
+ }
516
+ abort "A different registrar key list is already pinned; pass $replace -> TRUE to overwrite." when replace == NIL || replace? != TRUE.
517
+ }
518
+ registrar_ad -> ad.
519
+ return transaction::success [
520
+ _return_data ($pinned -> TRUE, $changed -> TRUE),
521
+ _save_state NIL
522
+ ].
523
+ }
524
+
525
+ trn set_local_policy _:($auto_accept -> auto_accept: bool)
526
+ {
527
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
528
+ local_auto_accept -> auto_accept.
529
+ return transaction::success [
530
+ _return_data ($auto_accept -> auto_accept),
531
+ _save_state NIL
532
+ ].
533
+ }
534
+
535
+ // Mint an introduction credential. Only meaningful on the host's REGISTRAR
536
+ // packet: targets verify the signature against their pinned registrar keys,
537
+ // so a credential minted by any other packet simply fails verification.
538
+ // Stateless — nothing to save. iat is stamped with transaction time so mint
539
+ // and verify use the same clock domain.
540
+ trn mint_introduction _:($joiner_ad -> joiner_ad_blob: bin, $target_ad -> target_ad_blob: bin)
541
+ {
542
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
543
+
544
+ joiner_ad = (_read_or_abort joiner_ad_blob) safe address_document_types::t_address_document.
545
+ target_ad = (_read_or_abort target_ad_blob) safe address_document_types::t_address_document.
546
+ intro is intro_t = (
547
+ $version -> 1,
548
+ $joiner_cid -> joiner_ad $identity $container_id,
549
+ $joiner_ad_hash -> _value_id joiner_ad,
550
+ $target_cid -> target_ad $identity $container_id,
551
+ $iat -> (current_transaction_info::get_transaction_time())?,
552
+ $nonce -> _new_id "a2adapt local introduction"
553
+ ).
554
+ signed is signed_intro_t = ($i -> intro, $s -> key_storage::default_sign (_value_id intro)).
555
+ return transaction::success [
556
+ _return_data ($intro -> (_write signed))
557
+ ].
558
+ }
559
+
560
+ // Sign a contact-book entry (registrar packet only, same caveat as above).
561
+ // Makes the host-side book file tamper-evident: senders re-derive this record
562
+ // from the entry they read and verify the signature before connecting.
563
+ trn sign_book_entry _:($name -> name: str, $ad -> ad_blob: bin)
564
+ {
565
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
566
+
567
+ ad = (_read_or_abort ad_blob) safe address_document_types::t_address_document.
568
+ entry is book_entry_t = ($version -> 1, $name -> name, $ad_hash -> _value_id ad).
569
+ return transaction::success [
570
+ _return_data ($sig -> (_write (key_storage::default_sign (_value_id entry))))
571
+ ].
572
+ }
573
+
574
+ // Connect to a same-host peer found in the local contact book: verify the
575
+ // book entry's registrar signature, register the peer as a contact, then
576
+ // introduce myself over the encrypted channel — carrying the credential the
577
+ // host just minted for this attempt, plus (optionally) the first message so
578
+ // introduction + first delivery are one atomic transaction on the target
579
+ // (no introduce-vs-message ordering race).
580
+ trn connect_local _:($name -> name: str, $target_ad -> target_ad_blob: bin, $intro -> intro_blob: bin, $entry_sig -> entry_sig_blob: bin, $text -> text: str+)
581
+ {
582
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
583
+ abort "No registrar pinned — the local contact book is unavailable for this identity." when registrar_ad == NIL.
584
+
585
+ target_ad = (_read_or_abort target_ad_blob) safe address_document_types::t_address_document.
586
+ target_id = target_ad $identity $container_id.
587
+ abort "This contact-book entry is your own identity." when target_id == _get_container_id().
588
+
589
+ entry is book_entry_t = ($version -> 1, $name -> name, $ad_hash -> _value_id target_ad).
590
+ entry_sig = (_read_or_abort entry_sig_blob) safe crypto_signature.
591
+ abort "Contact-book entry failed registrar verification." when key_storage::check_signature_new_container (_value_id entry) entry_sig (registrar_ad? $identity $key_list) != TRUE.
592
+
593
+ contacts target_id -> ($name -> name, $container_id -> target_id).
594
+ peer_ads target_id -> target_ad.
595
+
596
+ my_self_name = my_name.
597
+ my_ad = address_document::get_my_address_document().
598
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
599
+ return transaction::success [
600
+ encrypted_channel::send_encrypted_tx target_id (
601
+ $name -> "::actor::local_introduce",
602
+ $targ -> ($joiner_name -> my_self_name, $joiner_ad -> my_ad, $intro -> intro_blob, $text -> text)
603
+ ),
604
+ _return_data ($connected -> name, $container_id -> target_id),
605
+ _save_state NIL
606
+ ].
607
+ }).
608
+ }
609
+
610
+ trn approve_introduction _:($contact -> ref: str)
611
+ {
612
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
613
+
614
+ pid = resolve_pending ref.
615
+ entry = (pending_introductions pid)?.
616
+ contacts pid -> ($name -> entry $name, $container_id -> pid).
617
+ peer_ads pid -> entry $ad.
618
+
619
+ queued = entry $messages.
620
+ flushed is int = 0.
621
+ sc queued -- ( -> m)
622
+ {
623
+ deposit_message pid (entry $name) (m $text) (m $date).
624
+ flushed -> flushed + 1.
625
+ }
626
+ delete pending_introductions pid.
627
+
628
+ return transaction::success [
629
+ _return_data ($approved -> entry $name, $container_id -> pid, $flushed -> flushed),
630
+ _save_state NIL
631
+ ].
632
+ }
633
+
634
+ trn reject_introduction _:($contact -> ref: str)
635
+ {
636
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
637
+
638
+ pid = resolve_pending ref.
639
+ entry = (pending_introductions pid)?.
640
+ dropped = _count (entry $messages)|.
641
+ delete pending_introductions pid.
642
+
643
+ return transaction::success [
644
+ _return_data ($rejected -> entry $name, $container_id -> pid, $dropped_messages -> dropped),
645
+ _save_state NIL
646
+ ].
647
+ }
648
+
649
+ trn readonly list_pending_introductions _
650
+ {
651
+ out is (global_id ->> pending_view_t) = (,).
652
+ sc pending_introductions -- (cid -> p)
653
+ {
654
+ out cid -> ($name -> p $name, $queued -> _count (p $messages)|).
655
+ }
656
+ return out.
657
+ }
658
+
397
659
  // ---- upgrade: state export / import -------------------------------------
398
660
  // The host persists state by calling export_state (readonly) and serializing
399
661
  // the returned value to a code-independent blob. On a code upgrade it recreates
@@ -406,12 +668,18 @@ application actor loads libraries
406
668
  trn readonly export_state _
407
669
  {
408
670
  return (
409
- $my_name -> my_name,
410
- $contacts -> contacts,
411
- $pending_invites -> pending_invites,
412
- $inbox -> inbox,
413
- $next_msg_seq -> next_msg_seq,
414
- $peer_ads -> peer_ads
671
+ $my_name -> my_name,
672
+ $contacts -> contacts,
673
+ $pending_invites -> pending_invites,
674
+ $inbox -> inbox,
675
+ $next_msg_seq -> next_msg_seq,
676
+ $peer_ads -> peer_ads,
677
+ $registrar_ad -> registrar_ad,
678
+ $local_auto_accept -> local_auto_accept,
679
+ // Nonces are exported so a restart does not reopen the replay window
680
+ // for still-fresh credentials; stale ones are purged lazily anyway.
681
+ $seen_nonces -> seen_nonces,
682
+ $pending_introductions -> pending_introductions
415
683
  ).
416
684
  }
417
685
 
@@ -476,6 +744,25 @@ application actor loads libraries
476
744
  next_msg_seq -> (data $next_msg_seq) safe int.
477
745
  }
478
746
 
747
+ // Local-contact-book state arrived after the original schema — every
748
+ // field is optional in old blobs and defaults stay in place when absent.
749
+ if (data $registrar_ad) != NIL
750
+ {
751
+ registrar_ad -> (data $registrar_ad) safe address_document_types::t_address_document.
752
+ }
753
+ if (data $local_auto_accept) != NIL
754
+ {
755
+ local_auto_accept -> (data $local_auto_accept) safe bool.
756
+ }
757
+ if (data $seen_nonces) != NIL
758
+ {
759
+ seen_nonces -> (data $seen_nonces) safe (global_id ->> time).
760
+ }
761
+ if (data $pending_introductions) != NIL
762
+ {
763
+ pending_introductions -> (data $pending_introductions) safe (global_id ->> pending_intro_t).
764
+ }
765
+
479
766
  // Re-register every peer's keys so encrypted channels keep working after
480
767
  // the upgrade — no handshake needed (my own keys are unchanged, and the
481
768
  // peers' self-signed address documents re-authorize on this fresh packet).
@@ -483,6 +770,12 @@ application actor loads libraries
483
770
  {
484
771
  address_document::process_address_document ad TRUE.
485
772
  }
773
+ // Pending introducers' keys too: their channel to me predates approval,
774
+ // so it must survive an upgrade exactly like an approved contact's.
775
+ sc pending_introductions -- ( -> p)
776
+ {
777
+ address_document::process_address_document (p $ad) TRUE.
778
+ }
486
779
 
487
780
  return transaction::success [
488
781
  _return_data ($imported -> TRUE, $contacts -> _count contacts|, $peers -> _count peer_ads|),
@@ -499,15 +792,18 @@ application actor loads libraries
499
792
 
500
793
  sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
501
794
 
502
- // The name I assigned when I generated the invite wins; fall back to the
503
- // joiner's self-name if this invite is unknown (shouldn't happen).
795
+ // Only an invite I generated (and have not yet consumed) authorizes a
796
+ // contact registration. Without this gate any invite blob would be a
797
+ // multi-use bearer credential: anyone who ever saw one could register
798
+ // themselves as my contact with a self-chosen name.
504
799
  assigned_name = pending_invites invite_id.
505
- contact_name = (assigned_name == NIL ?? joiner_name ; assigned_name?).
800
+ abort "Unknown or already-redeemed invite." when assigned_name == NIL.
801
+ contact_name = assigned_name?.
506
802
 
507
803
  contacts sender_id -> ($name -> contact_name, $container_id -> sender_id).
508
804
  // Remember the joiner's address document for upgrade-time re-registration.
509
805
  peer_ads sender_id -> joiner_ad.
510
- if pending_invites invite_id != NIL { delete pending_invites invite_id. }
806
+ delete pending_invites invite_id.
511
807
 
512
808
  return transaction::success [
513
809
  _notify_agent ($event -> $contact_accepted, $name -> contact_name, $container_id -> sender_id),
@@ -521,23 +817,30 @@ application actor loads libraries
521
817
  encrypted_channel::check_encrypted_or_abort().
522
818
 
523
819
  sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
820
+ msg_date = (current_transaction_info::get_transaction_time())?.
524
821
  sender = contacts sender_id.
525
- abort "Message from an unknown sender was rejected." when sender == NIL.
822
+
823
+ if sender == NIL
824
+ {
825
+ // A verified-but-unapproved local introduction may message me before
826
+ // approval: queue (bounded) inside its pending entry. Approval
827
+ // flushes the queue into the inbox in order; anything else from an
828
+ // unknown sender is rejected as before.
829
+ p = pending_introductions sender_id.
830
+ abort "Message from an unknown sender was rejected." when p == NIL.
831
+ entry = p?.
832
+ queued = entry $messages.
833
+ abort "Pending-introduction message queue is full; awaiting approval." when (_count queued|) >= pending_queue_cap.
834
+ queued (_count queued|) -> ($text -> text, $date -> msg_date).
835
+ pending_introductions sender_id -> ($name -> entry $name, $ad -> entry $ad, $messages -> queued).
836
+ return transaction::success [
837
+ _notify_agent ($event -> $pending_message, $sender_name -> entry $name, $queued -> _count queued|),
838
+ _save_state NIL
839
+ ].
840
+ }
526
841
 
527
842
  sender_name = sender? $name.
528
- msg_date = current_transaction_info::get_transaction_time().
529
-
530
- mid = next_msg_seq.
531
- next_msg_seq -> next_msg_seq + 1.
532
-
533
- inbox (_count inbox|) -> (
534
- $msg_id -> mid,
535
- $sender_id -> sender_id,
536
- $sender_name -> sender_name,
537
- $text -> text,
538
- $date -> msg_date,
539
- $status -> "unread"
540
- ).
843
+ mid = deposit_message sender_id sender_name text msg_date.
541
844
 
542
845
  // The notification deliberately carries NO message body — only that a
543
846
  // message arrived, from whom, and its id. The body stays in the packet
@@ -547,4 +850,97 @@ application actor loads libraries
547
850
  _save_state NIL
548
851
  ].
549
852
  }
853
+
854
+ // A same-host peer connects via the local contact book. The credential must
855
+ // have been minted by THIS HOST's registrar for THIS sender and THIS target,
856
+ // recently, and never seen before — five checks that together make the book
857
+ // path unusable from outside the host:
858
+ // 1. registrar signature over the credential (pinned key list)
859
+ // 2. envelope $from == credential.joiner_cid (no splicing someone
860
+ // else's credential onto your own channel)
861
+ // 3. hash(joiner_ad) == credential.joiner_ad_hash (no AD substitution)
862
+ // 4. credential.target_cid == me (no cross-target reuse)
863
+ // 5. freshness window + unseen nonce (no replay)
864
+ // The encrypted channel itself authenticates that the sender controls its
865
+ // keys, so no extra challenge-response is needed.
866
+ trn local_introduce _:($joiner_name -> joiner_name: str, $joiner_ad -> joiner_ad: address_document_types::t_address_document, $intro -> intro_blob: bin, $text -> text: str+)
867
+ {
868
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
869
+ encrypted_channel::check_encrypted_or_abort().
870
+
871
+ sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
872
+ abort "This identity does not accept local-contact-book introductions." when registrar_ad == NIL.
873
+
874
+ signed = (_read_or_abort intro_blob) safe signed_intro_t.
875
+ intro = signed $i.
876
+ abort "Unsupported introduction credential version." when (intro $version) != 1.
877
+ abort "Introduction credential was not signed by this host's registrar." when key_storage::check_signature_new_container (_value_id intro) (signed $s) (registrar_ad? $identity $key_list) != TRUE.
878
+ abort "Introduction credential was minted for a different sender." when (intro $joiner_cid) != sender_id.
879
+ abort "Introduction credential does not match the sender's address document." when (intro $joiner_ad_hash) != _value_id joiner_ad.
880
+ abort "Introduction credential targets a different identity." when (intro $target_cid) != _get_container_id().
881
+
882
+ now = (current_transaction_info::get_transaction_time())?.
883
+ age = _substract_seconds now (intro $iat).
884
+ abort "Introduction credential is outside its freshness window." when age > intro_max_age_seconds || age < (0 - intro_max_skew_seconds).
885
+
886
+ // Lazy nonce GC (drop everything past the retention horizon), then the
887
+ // replay check, then a hard cap so a misbehaving local peer cannot bloat
888
+ // packet state inside the window.
889
+ horizon = intro_max_age_seconds + intro_max_skew_seconds.
890
+ fresh_nonces is (global_id ->> time) = (,).
891
+ sc seen_nonces -- (n -> t)
892
+ {
893
+ if (_substract_seconds now t) <= horizon { fresh_nonces n -> t. }
894
+ }
895
+ seen_nonces -> fresh_nonces.
896
+ abort "Replayed introduction credential." when seen_nonces (intro $nonce) != NIL.
897
+ abort "Too many concurrent introductions; try again shortly." when (_count seen_nonces|) >= seen_nonce_cap.
898
+ seen_nonces (intro $nonce) -> now.
899
+
900
+ existing = contacts sender_id.
901
+ if existing != NIL
902
+ {
903
+ // Already a contact (idempotent re-introduction): keep my assigned
904
+ // name, refresh the stored address document, deliver any payload.
905
+ peer_ads sender_id -> joiner_ad.
906
+ if text != NIL
907
+ {
908
+ mid = deposit_message sender_id (existing? $name) text? now.
909
+ return transaction::success [
910
+ _notify_agent ($event -> $message_received, $sender_name -> existing? $name, $msg_id -> mid, $date -> now),
911
+ _save_state NIL
912
+ ].
913
+ }
914
+ return transaction::success [ _save_state NIL ].
915
+ }
916
+
917
+ if local_auto_accept
918
+ {
919
+ contacts sender_id -> ($name -> joiner_name, $container_id -> sender_id).
920
+ peer_ads sender_id -> joiner_ad.
921
+ if text != NIL
922
+ {
923
+ mid = deposit_message sender_id joiner_name text? now.
924
+ return transaction::success [
925
+ _notify_agent ($event -> $local_contact_added, $name -> joiner_name, $container_id -> sender_id),
926
+ _notify_agent ($event -> $message_received, $sender_name -> joiner_name, $msg_id -> mid, $date -> now),
927
+ _save_state NIL
928
+ ].
929
+ }
930
+ return transaction::success [
931
+ _notify_agent ($event -> $local_contact_added, $name -> joiner_name, $container_id -> sender_id),
932
+ _save_state NIL
933
+ ].
934
+ }
935
+
936
+ // Pending-approval policy: park the introduction (with its optional
937
+ // first message) until approve_introduction / reject_introduction.
938
+ queued is pending_msg_t[] = [].
939
+ if text != NIL { queued 0 -> ($text -> text?, $date -> now). }
940
+ pending_introductions sender_id -> ($name -> joiner_name, $ad -> joiner_ad, $messages -> queued).
941
+ return transaction::success [
942
+ _notify_agent ($event -> $local_contact_request, $name -> joiner_name, $container_id -> sender_id, $queued -> _count queued|),
943
+ _save_state NIL
944
+ ].
945
+ }
550
946
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adapt-toolkit/a2adapt",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc -p tsconfig.json --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@adapt-toolkit/sdk": "^0.2.4",
53
- "@adapt-toolkit/sdk-native": "^0.2.3"
52
+ "@adapt-toolkit/sdk": "^0.4.0",
53
+ "@adapt-toolkit/sdk-native": "^0.4.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@modelcontextprotocol/sdk": "^1.0.4",