@adapt-toolkit/a2adapt 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -321,9 +321,15 @@ function cmdWatch(which) {
321
321
  } catch {
322
322
  continue;
323
323
  }
324
- out(
325
- `[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
326
- );
324
+ if (msg.event === "local_contact_request") {
325
+ out(`[${name}] pending local introduction from ${msg.from ?? "?"} \u2014 respond_to_introduction to approve/reject`);
326
+ } else if (msg.event === "pending_message") {
327
+ out(`[${name}] ${msg.from ?? "?"} queued a message awaiting introduction approval (${msg.queued ?? "?"} queued)`);
328
+ } else {
329
+ out(
330
+ `[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
331
+ );
332
+ }
327
333
  }
328
334
  }
329
335
  };
@@ -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
  }
@@ -100,9 +106,37 @@ function identityExists(name) {
100
106
  return false;
101
107
  }
102
108
  }
103
- function renderIdentityDirective(name, exists) {
104
- 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`;
105
- 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; if a different identity is already bound, prefer this pinned one.`;
109
+ function anyIdentityBound() {
110
+ let snap;
111
+ try {
112
+ snap = JSON.parse(fs.readFileSync(join(STATE_DIR, "bindings.json"), "utf8"));
113
+ } catch {
114
+ return false;
115
+ }
116
+ if (!Array.isArray(snap.bound) || snap.bound.length === 0) return false;
117
+ const pid = Number(snap.pid);
118
+ if (!Number.isInteger(pid) || pid <= 0) return false;
119
+ try {
120
+ process.kill(pid, 0);
121
+ return true;
122
+ } catch (err) {
123
+ return err.code === "EPERM";
124
+ }
125
+ }
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;
106
140
  }
107
141
  function sessionStart() {
108
142
  const raw = readStdin();
@@ -120,7 +154,7 @@ function sessionStart() {
120
154
  const pinned = findPinnedIdentity(cwd);
121
155
  const unread = collectUnread();
122
156
  const blocks = [];
123
- if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned)));
157
+ if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned.identity)));
124
158
  if (unread.length > 0) blocks.push(renderContext(unread));
125
159
  if (blocks.length === 0) return noop();
126
160
  emit({
@@ -131,6 +165,26 @@ function sessionStart() {
131
165
  }
132
166
  });
133
167
  }
168
+ function userPromptSubmit() {
169
+ const raw = readStdin();
170
+ let cwd = process.cwd();
171
+ if (raw) {
172
+ try {
173
+ const payload = JSON.parse(raw);
174
+ if (typeof payload.cwd === "string" && payload.cwd) cwd = payload.cwd;
175
+ } catch {
176
+ }
177
+ }
178
+ const pinned = findPinnedIdentity(cwd);
179
+ if (!pinned || anyIdentityBound()) return noop();
180
+ emit({
181
+ continue: true,
182
+ hookSpecificOutput: {
183
+ hookEventName: "UserPromptSubmit",
184
+ additionalContext: renderIdentityDirective(pinned, identityExists(pinned.identity))
185
+ }
186
+ });
187
+ }
134
188
  function main() {
135
189
  const kind = process.argv[2] ?? "";
136
190
  try {
@@ -138,6 +192,9 @@ function main() {
138
192
  case "session-start":
139
193
  sessionStart();
140
194
  return;
195
+ case "user-prompt-submit":
196
+ userPromptSubmit();
197
+ return;
141
198
  default:
142
199
  noop();
143
200
  return;
package/dist/index.js CHANGED
@@ -22489,7 +22489,7 @@ function loadConfig() {
22489
22489
  }
22490
22490
 
22491
22491
  // src/index.ts
22492
- var VERSION = true ? "0.7.0" : "0.0.0-dev";
22492
+ var VERSION = true ? "0.8.1" : "0.0.0-dev";
22493
22493
  var CONFIG = loadConfig();
22494
22494
  var STATE_DIR = CONFIG.stateDir;
22495
22495
  var BROKER_URL = CONFIG.brokerUrl;
@@ -22499,6 +22499,7 @@ var GC_INTERVAL_MS = CONFIG.gcIntervalMs;
22499
22499
  var log = (...parts) => process.stderr.write(`a2adapt: ${parts.join(" ")}
22500
22500
  `);
22501
22501
  var NAME_RE = /^[A-Za-z0-9 _.-]{1,64}$/;
22502
+ var BOOK_DIR_NAME = "contact-book";
22502
22503
  function validateName(name) {
22503
22504
  if (!NAME_RE.test(name)) {
22504
22505
  return "name must be 1-64 chars of letters, digits, space, _ . or -";
@@ -22506,6 +22507,9 @@ function validateName(name) {
22506
22507
  if (name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
22507
22508
  return "invalid name";
22508
22509
  }
22510
+ if (name === BOOK_DIR_NAME) {
22511
+ return `"${BOOK_DIR_NAME}" is reserved for the local contact book`;
22512
+ }
22509
22513
  return null;
22510
22514
  }
22511
22515
  function locateUnit() {
@@ -22528,9 +22532,22 @@ function locateUnit() {
22528
22532
  var UNIT;
22529
22533
  var wrapper;
22530
22534
  var identities = /* @__PURE__ */ new Map();
22535
+ var registrar = null;
22536
+ var registrarAdBlob = null;
22531
22537
  var sessionBinding = /* @__PURE__ */ new Map();
22532
22538
  var bindingOwner = /* @__PURE__ */ new Map();
22533
22539
  var evictedSessions = /* @__PURE__ */ new Set();
22540
+ var bindingsSnapshotPath = () => join2(STATE_DIR, "bindings.json");
22541
+ function persistBindings() {
22542
+ try {
22543
+ fs2.mkdirSync(STATE_DIR, { recursive: true });
22544
+ const tmp = `${bindingsSnapshotPath()}.tmp`;
22545
+ fs2.writeFileSync(tmp, JSON.stringify({ pid: process.pid, bound: [...bindingOwner.keys()] }));
22546
+ fs2.renameSync(tmp, bindingsSnapshotPath());
22547
+ } catch (err) {
22548
+ log("failed to persist bindings snapshot:", String(err));
22549
+ }
22550
+ }
22534
22551
  var identityDir = (name) => join2(STATE_DIR, name);
22535
22552
  var seedPath = (dir) => join2(dir, "identity.seed");
22536
22553
  var dataPath = (dir) => join2(dir, "state_data.bin");
@@ -22540,6 +22557,102 @@ function listPersistedNames() {
22540
22557
  if (!fs2.existsSync(STATE_DIR)) return [];
22541
22558
  return fs2.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs2.existsSync(seedPath(join2(STATE_DIR, d.name)))).map((d) => d.name);
22542
22559
  }
22560
+ var bookDir = () => join2(STATE_DIR, BOOK_DIR_NAME);
22561
+ var registrarSeedPath = () => join2(bookDir(), "registrar.seed");
22562
+ var bookPath = () => join2(bookDir(), "book.json");
22563
+ function readBook() {
22564
+ try {
22565
+ const parsed = JSON.parse(fs2.readFileSync(bookPath(), "utf8"));
22566
+ return parsed && typeof parsed.entries === "object" ? parsed.entries : {};
22567
+ } catch {
22568
+ return {};
22569
+ }
22570
+ }
22571
+ function writeBook(entries) {
22572
+ fs2.mkdirSync(bookDir(), { recursive: true });
22573
+ const tmp = `${bookPath()}.tmp`;
22574
+ fs2.writeFileSync(tmp, JSON.stringify({ v: 1, entries }, null, 2), { mode: 384 });
22575
+ fs2.renameSync(tmp, bookPath());
22576
+ }
22577
+ function exportAdBlob(id) {
22578
+ return Buffer.from(readonlyTx(id, "::actor::export_address_document").GetBinary());
22579
+ }
22580
+ async function publishToBook(id) {
22581
+ if (!registrar) throw new Error("registrar is not available");
22582
+ const adBlob = exportAdBlob(id);
22583
+ const sigData = await mutatingTx(registrar, "::actor::sign_book_entry", {
22584
+ name: id.name,
22585
+ ad: registrar.pw.packet.NewBinaryFromBuffer(adBlob)
22586
+ });
22587
+ const entries = readBook();
22588
+ entries[id.name] = {
22589
+ v: 1,
22590
+ name: id.name,
22591
+ container_id: id.cid,
22592
+ address_document: adBlob.toString("base64url"),
22593
+ published_at: (/* @__PURE__ */ new Date()).toISOString(),
22594
+ registrar_sig: Buffer.from(sigData.Reduce("sig").GetBinary()).toString("base64url")
22595
+ };
22596
+ writeBook(entries);
22597
+ log(`[${id.name}] published to the local contact book`);
22598
+ }
22599
+ function unpublishFromBook(name) {
22600
+ const entries = readBook();
22601
+ if (!(name in entries)) return;
22602
+ delete entries[name];
22603
+ writeBook(entries);
22604
+ log(`[${name}] removed from the local contact book`);
22605
+ }
22606
+ async function pinRegistrar(id) {
22607
+ if (!registrarAdBlob) throw new Error("registrar is not available");
22608
+ await mutatingTx(id, "::actor::pin_registrar", {
22609
+ registrar_ad: id.pw.packet.NewBinaryFromBuffer(registrarAdBlob)
22610
+ });
22611
+ }
22612
+ async function sendViaLocalBook(id, contact, text) {
22613
+ if (!registrar) {
22614
+ throw new Error(`"${contact}" is not a contact, and the local contact book is unavailable.`);
22615
+ }
22616
+ const entries = readBook();
22617
+ const entry = entries[contact] ?? Object.values(entries).find((e) => e.container_id === contact);
22618
+ if (!entry) {
22619
+ throw new Error(
22620
+ `"${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.`
22621
+ );
22622
+ }
22623
+ if (entry.container_id === id.cid) {
22624
+ throw new Error("that contact-book entry is this identity itself.");
22625
+ }
22626
+ const targetAd = Buffer.from(entry.address_document, "base64url");
22627
+ const entrySig = Buffer.from(entry.registrar_sig, "base64url");
22628
+ const joinerAd = exportAdBlob(id);
22629
+ const minted = await mutatingTx(registrar, "::actor::mint_introduction", {
22630
+ joiner_ad: registrar.pw.packet.NewBinaryFromBuffer(joinerAd),
22631
+ target_ad: registrar.pw.packet.NewBinaryFromBuffer(targetAd)
22632
+ });
22633
+ const introBlob = Buffer.from(minted.Reduce("intro").GetBinary());
22634
+ await mutatingTx(id, "::actor::connect_local", {
22635
+ name: entry.name,
22636
+ target_ad: id.pw.packet.NewBinaryFromBuffer(targetAd),
22637
+ intro: id.pw.packet.NewBinaryFromBuffer(introBlob),
22638
+ entry_sig: id.pw.packet.NewBinaryFromBuffer(entrySig),
22639
+ text
22640
+ });
22641
+ 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.`;
22642
+ }
22643
+ async function ensureRegistrar() {
22644
+ fs2.mkdirSync(bookDir(), { recursive: true });
22645
+ let seed;
22646
+ try {
22647
+ seed = fs2.readFileSync(registrarSeedPath(), "utf8").trim();
22648
+ } catch {
22649
+ seed = randomBytes(24).toString("hex");
22650
+ fs2.writeFileSync(registrarSeedPath(), seed, { mode: 384 });
22651
+ }
22652
+ registrar = await createPacket(BOOK_DIR_NAME, seed, bookDir(), false);
22653
+ registrarAdBlob = exportAdBlob(registrar);
22654
+ log(`contact-book registrar ready (${registrar.cid})`);
22655
+ }
22543
22656
  function hasSavedState(dir) {
22544
22657
  try {
22545
22658
  return fs2.existsSync(dataPath(dir)) && fs2.statSync(dataPath(dir)).size > 0;
@@ -22561,11 +22674,10 @@ function saveState(id) {
22561
22674
  log(`[${id.name}] failed to save state:`, String(err));
22562
22675
  }
22563
22676
  }
22564
- function appendNotifyLog(id, from, msgId, date3) {
22677
+ function appendNotifyLog(id, event) {
22565
22678
  try {
22566
22679
  fs2.mkdirSync(id.dir, { recursive: true });
22567
- const line = JSON.stringify({ event: "message_received", from, msg_id: msgId, date: date3 }) + "\n";
22568
- fs2.appendFileSync(notifyLogPath(id.dir), line);
22680
+ fs2.appendFileSync(notifyLogPath(id.dir), JSON.stringify(event) + "\n");
22569
22681
  } catch (err) {
22570
22682
  log(`[${id.name}] failed to append notifications.log:`, String(err));
22571
22683
  }
@@ -22649,7 +22761,7 @@ function wireHandlers(id) {
22649
22761
  const sender = payload.Reduce("sender_name").Visualize();
22650
22762
  const msgId = payload.Reduce("msg_id").Visualize();
22651
22763
  const date3 = payload.Reduce("date").Visualize();
22652
- appendNotifyLog(id, sender, msgId, date3);
22764
+ appendNotifyLog(id, { event: "message_received", from: sender, msg_id: msgId, date: date3 });
22653
22765
  refreshUnread(id);
22654
22766
  process.nextTick(
22655
22767
  () => pushNotification(id.name, `[${id.name}] new message from ${sender} (#${msgId})`)
@@ -22660,6 +22772,29 @@ function wireHandlers(id) {
22660
22772
  process.nextTick(
22661
22773
  () => pushNotification(id.name, `[${id.name}] contact "${name}" (${cid}) accepted your invite.`)
22662
22774
  );
22775
+ } else if (event === "local_contact_added") {
22776
+ const name = payload.Reduce("name").Visualize();
22777
+ const cid = payload.Reduce("container_id").Visualize();
22778
+ process.nextTick(
22779
+ () => pushNotification(id.name, `[${id.name}] local contact "${name}" (${cid}) connected via the contact book.`)
22780
+ );
22781
+ } else if (event === "local_contact_request") {
22782
+ const name = payload.Reduce("name").Visualize();
22783
+ const cid = payload.Reduce("container_id").Visualize();
22784
+ appendNotifyLog(id, { event: "local_contact_request", from: name });
22785
+ process.nextTick(
22786
+ () => pushNotification(
22787
+ id.name,
22788
+ `[${id.name}] pending local introduction from "${name}" (${cid}) \u2014 approve or reject with respond_to_introduction.`
22789
+ )
22790
+ );
22791
+ } else if (event === "pending_message") {
22792
+ const name = payload.Reduce("sender_name").Visualize();
22793
+ const queued = payload.Reduce("queued").Visualize();
22794
+ appendNotifyLog(id, { event: "pending_message", from: name, queued });
22795
+ process.nextTick(
22796
+ () => pushNotification(id.name, `[${id.name}] "${name}" queued a message awaiting introduction approval (${queued} queued).`)
22797
+ );
22663
22798
  }
22664
22799
  return;
22665
22800
  }
@@ -22678,7 +22813,7 @@ function wireHandlers(id) {
22678
22813
  }
22679
22814
  };
22680
22815
  }
22681
- function createPacket(name, seed, dir) {
22816
+ function createPacket(name, seed, dir, track = true) {
22682
22817
  const config2 = new PacketWrapperConfigurator();
22683
22818
  config2.process_arguments([
22684
22819
  "--unit_hash",
@@ -22707,7 +22842,7 @@ function createPacket(name, seed, dir) {
22707
22842
  lock: Promise.resolve()
22708
22843
  };
22709
22844
  wireHandlers(id);
22710
- identities.set(name, id);
22845
+ if (track) identities.set(name, id);
22711
22846
  log(`[${name}] packet created \u2014 container id ${id.cid}`);
22712
22847
  resolveCreate(id);
22713
22848
  },
@@ -22715,13 +22850,20 @@ function createPacket(name, seed, dir) {
22715
22850
  );
22716
22851
  });
22717
22852
  }
22718
- async function provisionIdentity(name) {
22853
+ async function provisionIdentity(name, opts = { exposeLocal: true, localAutoAccept: true }) {
22719
22854
  const dir = identityDir(name);
22720
22855
  fs2.mkdirSync(dir, { recursive: true });
22721
22856
  const seed = randomBytes(24).toString("hex");
22722
22857
  fs2.writeFileSync(seedPath(dir), seed, { mode: 384 });
22723
22858
  const id = await createPacket(name, seed, dir);
22724
22859
  await mutatingTx(id, "::actor::set_my_name", { name });
22860
+ await pinRegistrar(id);
22861
+ if (!opts.localAutoAccept) {
22862
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept: false });
22863
+ }
22864
+ if (opts.exposeLocal) {
22865
+ await publishToBook(id);
22866
+ }
22725
22867
  saveState(id);
22726
22868
  return id;
22727
22869
  }
@@ -22758,6 +22900,11 @@ async function bootWrapper() {
22758
22900
  wrapper = await adapt_wrapper.start(argv);
22759
22901
  wrapper.on_packet_created_cb = (cid) => log(`wrapper: packet ready ${cid.slice(0, 12)}\u2026`);
22760
22902
  wrapper.start();
22903
+ try {
22904
+ await ensureRegistrar();
22905
+ } catch (err) {
22906
+ log("failed to start the contact-book registrar (local contact book disabled):", String(err));
22907
+ }
22761
22908
  const names = listPersistedNames();
22762
22909
  if (names.length === 0) {
22763
22910
  log("no persisted identities \u2014 start with create_identity");
@@ -22765,12 +22912,16 @@ async function bootWrapper() {
22765
22912
  log(`restoring ${names.length} identit${names.length === 1 ? "y" : "ies"}: ${names.join(", ")}`);
22766
22913
  for (const name of names) {
22767
22914
  try {
22768
- await restoreIdentity(name);
22915
+ const id = await restoreIdentity(name);
22916
+ if (registrar) {
22917
+ await pinRegistrar(id);
22918
+ }
22769
22919
  } catch (err) {
22770
22920
  log(`failed to restore "${name}":`, String(err));
22771
22921
  }
22772
22922
  }
22773
22923
  }
22924
+ persistBindings();
22774
22925
  }
22775
22926
  function resolveBound(sessionId) {
22776
22927
  if (evictedSessions.has(sessionId)) {
@@ -22784,6 +22935,7 @@ function resolveBound(sessionId) {
22784
22935
  if (!id) {
22785
22936
  sessionBinding.delete(sessionId);
22786
22937
  bindingOwner.delete(name);
22938
+ persistBindings();
22787
22939
  return { error: `The bound identity "${name}" no longer exists. Choose another with choose_identity.` };
22788
22940
  }
22789
22941
  return { id };
@@ -22796,6 +22948,7 @@ function bindSession(sessionId, name) {
22796
22948
  sessionBinding.set(sessionId, name);
22797
22949
  bindingOwner.set(name, sessionId);
22798
22950
  evictedSessions.delete(sessionId);
22951
+ persistBindings();
22799
22952
  }
22800
22953
  function renderContacts(v) {
22801
22954
  const out = [];
@@ -22827,6 +22980,20 @@ function renderInbox(v) {
22827
22980
  }
22828
22981
  return out;
22829
22982
  }
22983
+ function renderPending(v) {
22984
+ const out = [];
22985
+ if (v.IsNil()) return out;
22986
+ for (const key of v.GetKeys()) {
22987
+ const p = v.Reduce(key);
22988
+ if (p.IsNil()) continue;
22989
+ out.push({
22990
+ container_id: typeof key === "string" ? key : key.Visualize(),
22991
+ name: p.Reduce("name").Visualize(),
22992
+ queued: parseInt(p.Reduce("queued").Visualize(), 10) || 0
22993
+ });
22994
+ }
22995
+ return out;
22996
+ }
22830
22997
  function textResult(text, isError = false) {
22831
22998
  return { content: [{ type: "text", text }], isError };
22832
22999
  }
@@ -22862,16 +23029,21 @@ function createMcpServer(getSessionId) {
22862
23029
  };
22863
23030
  server.tool(
22864
23031
  "create_identity",
22865
- "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.",
22866
- { name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".') },
22867
- async ({ name }) => {
23032
+ "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.",
23033
+ {
23034
+ name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".'),
23035
+ expose_local: external_exports.boolean().default(true).describe("Publish this identity in the host-local contact book."),
23036
+ local_auto_accept: external_exports.boolean().default(true).describe("Auto-accept local contact-book introductions (false = they queue for approval).")
23037
+ },
23038
+ async ({ name, expose_local, local_auto_accept }) => {
22868
23039
  const bad = validateName(name);
22869
23040
  if (bad) return textResult(`create_identity failed: ${bad}`, true);
22870
23041
  if (identities.has(name)) return textResult(`create_identity failed: an identity named "${name}" already exists.`, true);
22871
23042
  try {
22872
- const id = await provisionIdentity(name);
23043
+ const id = await provisionIdentity(name, { exposeLocal: expose_local, localAutoAccept: local_auto_accept });
22873
23044
  bindSession(getSessionId(), name);
22874
- return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.`);
23045
+ const exposure = expose_local ? ` Published to the local contact book${local_auto_accept ? "" : " (introductions require approval)"}.` : " Not exposed in the local contact book.";
23046
+ return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.${exposure}`);
22875
23047
  } catch (err) {
22876
23048
  return textResult(`create_identity failed: ${String(err)}`, true);
22877
23049
  }
@@ -22879,7 +23051,7 @@ function createMcpServer(getSessionId) {
22879
23051
  );
22880
23052
  server.tool(
22881
23053
  "choose_identity",
22882
- "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.",
23054
+ "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.",
22883
23055
  {
22884
23056
  name: external_exports.string().min(1).describe("Name of the identity to bind."),
22885
23057
  force: external_exports.boolean().default(false).describe("Evict another session that holds this identity.")
@@ -22892,7 +23064,10 @@ function createMcpServer(getSessionId) {
22892
23064
  const holder = bindingOwner.get(name);
22893
23065
  if (holder && holder !== sid) {
22894
23066
  if (!force) {
22895
- return textResult(`choose_identity failed: "${name}" is currently bound to another session. Retry with force=true to take it over.`, true);
23067
+ return textResult(
23068
+ `choose_identity declined: "${name}" is currently bound to another session. Do not retry with force=true on your own \u2014 tell the user the identity is in use elsewhere and ask whether to forcibly rebind it here; only retry with force=true after they explicitly confirm.`,
23069
+ true
23070
+ );
22896
23071
  }
22897
23072
  evictedSessions.add(holder);
22898
23073
  sessionBinding.delete(holder);
@@ -22943,10 +23118,16 @@ ${lines.join("\n")}`);
22943
23118
  log(`remove_packet(${id.cid}) failed:`, String(err));
22944
23119
  }
22945
23120
  identities.delete(name);
23121
+ try {
23122
+ unpublishFromBook(name);
23123
+ } catch (err) {
23124
+ log(`failed to unpublish "${name}" from the contact book:`, String(err));
23125
+ }
22946
23126
  const holder = bindingOwner.get(name);
22947
23127
  if (holder) {
22948
23128
  bindingOwner.delete(name);
22949
23129
  sessionBinding.delete(holder);
23130
+ persistBindings();
22950
23131
  }
22951
23132
  try {
22952
23133
  fs2.rmSync(id.dir, { recursive: true, force: true });
@@ -23022,24 +23203,113 @@ ${blob}`
23022
23203
  );
23023
23204
  server.tool(
23024
23205
  "list_contacts",
23025
- "List the contacts the bound identity knows about (name + container id).",
23206
+ "List the contacts the bound identity knows about (name + container id), plus any pending local-contact-book introductions awaiting approval.",
23026
23207
  {},
23027
23208
  async () => {
23028
23209
  const { id, err } = boundOr();
23029
23210
  if (err) return err;
23030
23211
  try {
23031
23212
  const contacts = renderContacts(readonlyTx(id, "::actor::list_contacts"));
23032
- if (contacts.length === 0) return textResult("No contacts yet.");
23033
- return textResult(`Contacts (${contacts.length}):
23034
- ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`);
23213
+ const pending = renderPending(readonlyTx(id, "::actor::list_pending_introductions"));
23214
+ const lines = [];
23215
+ lines.push(
23216
+ contacts.length === 0 ? "No contacts yet." : `Contacts (${contacts.length}):
23217
+ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`
23218
+ );
23219
+ if (pending.length > 0) {
23220
+ lines.push(
23221
+ `Pending local introductions (${pending.length}) \u2014 approve/reject with respond_to_introduction:
23222
+ ` + pending.map((p) => `\u2022 ${p.name} \u2014 ${p.container_id} (${p.queued} queued message${p.queued === 1 ? "" : "s"})`).join("\n")
23223
+ );
23224
+ }
23225
+ return textResult(lines.join("\n\n"));
23035
23226
  } catch (e) {
23036
23227
  return textResult(`list_contacts failed: ${String(e)}`, true);
23037
23228
  }
23038
23229
  }
23039
23230
  );
23231
+ server.tool(
23232
+ "list_local_contact_book",
23233
+ "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.",
23234
+ {},
23235
+ async () => {
23236
+ const entries = Object.values(readBook());
23237
+ if (entries.length === 0) return textResult("The local contact book is empty.");
23238
+ const sid = getSessionId();
23239
+ const mine = sessionBinding.get(sid);
23240
+ const lines = entries.map((e) => {
23241
+ const tag = e.name === mine ? " \u2190 this session" : "";
23242
+ return `\u2022 ${e.name} \u2014 ${e.container_id} (published ${e.published_at})${tag}`;
23243
+ });
23244
+ return textResult(`Local contact book (${entries.length}):
23245
+ ${lines.join("\n")}`);
23246
+ }
23247
+ );
23248
+ server.tool(
23249
+ "set_local_book_policy",
23250
+ "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).",
23251
+ {
23252
+ expose: external_exports.boolean().optional().describe("Publish (true) or remove (false) this identity in the local contact book."),
23253
+ auto_accept: external_exports.boolean().optional().describe("Auto-accept local introductions (false = queue them for approval).")
23254
+ },
23255
+ async ({ expose, auto_accept }) => {
23256
+ const { id, err } = boundOr();
23257
+ if (err) return err;
23258
+ if (expose === void 0 && auto_accept === void 0) {
23259
+ return textResult("set_local_book_policy: pass expose and/or auto_accept.", true);
23260
+ }
23261
+ const done = [];
23262
+ try {
23263
+ if (auto_accept !== void 0) {
23264
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept });
23265
+ done.push(`auto_accept=${auto_accept}`);
23266
+ }
23267
+ if (expose === true) {
23268
+ await publishToBook(id);
23269
+ done.push("published in the local contact book");
23270
+ } else if (expose === false) {
23271
+ unpublishFromBook(id.name);
23272
+ done.push("removed from the local contact book");
23273
+ }
23274
+ return textResult(`Updated "${id.name}": ${done.join("; ")}.`);
23275
+ } catch (e) {
23276
+ return textResult(`set_local_book_policy failed: ${String(e)}`, true);
23277
+ }
23278
+ }
23279
+ );
23280
+ server.tool(
23281
+ "respond_to_introduction",
23282
+ "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.",
23283
+ {
23284
+ contact: external_exports.string().min(1).describe("Pending introduction to act on (name or container id)."),
23285
+ action: external_exports.enum(["approve", "reject"]).describe("approve or reject.")
23286
+ },
23287
+ async ({ contact, action }) => {
23288
+ const { id, err } = boundOr();
23289
+ if (err) return err;
23290
+ try {
23291
+ if (action === "approve") {
23292
+ const data2 = await mutatingTx(id, "::actor::approve_introduction", { contact });
23293
+ const name2 = data2.Reduce("approved").Visualize();
23294
+ const cid = data2.Reduce("container_id").Visualize();
23295
+ const flushed = data2.Reduce("flushed").Visualize();
23296
+ refreshUnread(id);
23297
+ return textResult(
23298
+ `Approved "${name2}" (${cid}) \u2014 now a contact. ${flushed} queued message(s) moved to the inbox (read them with get_messages).`
23299
+ );
23300
+ }
23301
+ const data = await mutatingTx(id, "::actor::reject_introduction", { contact });
23302
+ const name = data.Reduce("rejected").Visualize();
23303
+ const dropped = data.Reduce("dropped_messages").Visualize();
23304
+ return textResult(`Rejected the introduction from "${name}" and dropped ${dropped} queued message(s).`);
23305
+ } catch (e) {
23306
+ return textResult(`respond_to_introduction failed: ${String(e)}`, true);
23307
+ }
23308
+ }
23309
+ );
23040
23310
  server.tool(
23041
23311
  "send_message",
23042
- "Send an end-to-end-encrypted message to a known contact (by name or container id). Requires a bound identity.",
23312
+ "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.",
23043
23313
  {
23044
23314
  contact: external_exports.string().min(1).describe("Contact name or container id to send to."),
23045
23315
  text: external_exports.string().min(1).describe("The message text.")
@@ -23051,13 +23321,21 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
23051
23321
  await mutatingTx(id, "::actor::send_message", { contact, text });
23052
23322
  return textResult(`Message sent to "${contact}".`);
23053
23323
  } catch (e) {
23054
- return textResult(`send_message failed: ${String(e)}`, true);
23324
+ if (!/Unknown contact/.test(String(e))) {
23325
+ return textResult(`send_message failed: ${String(e)}`, true);
23326
+ }
23327
+ try {
23328
+ const sent = await sendViaLocalBook(id, contact, text);
23329
+ return textResult(sent);
23330
+ } catch (e2) {
23331
+ return textResult(`send_message failed: ${String(e2)}`, true);
23332
+ }
23055
23333
  }
23056
23334
  }
23057
23335
  );
23058
23336
  server.tool(
23059
23337
  "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.",
23338
+ "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.",
23061
23339
  { contact: external_exports.string().min(1).describe("Contact name or container id to remove.") },
23062
23340
  async ({ contact }) => {
23063
23341
  const { id, err } = boundOr();
@@ -23234,6 +23512,7 @@ async function main() {
23234
23512
  if (name && bindingOwner.get(name) === sid) bindingOwner.delete(name);
23235
23513
  sessionBinding.delete(sid);
23236
23514
  evictedSessions.delete(sid);
23515
+ persistBindings();
23237
23516
  log(`session ${sid.slice(0, 8)}\u2026 closed`);
23238
23517
  }
23239
23518
  };
@@ -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.7.0",
3
+ "version": "0.8.1",
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",