@ouro.bot/cli 0.1.0-alpha.918 → 0.1.0-alpha.919

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/changelog.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.919",
6
+ "changes": [
7
+ "Shepherd now sends the agent's owner a short Teams message for every coding-agent return it judges, saying what it did and why; set senses.shepherd.notify to needs-me or off to hear less.",
8
+ "Link a new account, such as Teams, to yourself by sending your agent a one-time code from it, and merge duplicate friend records with ouro friend dedupe.",
9
+ "Your agent can now type an instruction or press a key in one of your coding agents' terminals when you ask it to, so you can steer them from your phone."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.918",
6
14
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.918",
2
+ "runtimeVersion": "0.1.0-alpha.919",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.918</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.919</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -62,6 +62,8 @@ const semver = __importStar(require("semver"));
62
62
  const identity_1 = require("../identity");
63
63
  const runtime_1 = require("../../nerves/runtime");
64
64
  const friends_1 = require("@ouro.bot/friends");
65
+ const friend_merge_1 = require("../../mind/friend-merge");
66
+ const friend_link_codes_1 = require("../../mind/friend-link-codes");
65
67
  const runtime_metadata_1 = require("./runtime-metadata");
66
68
  const runtime_mode_1 = require("./runtime-mode");
67
69
  const platform_1 = require("../platform");
@@ -506,6 +508,9 @@ function agentResolutionFailureMode(command) {
506
508
  case "friend.show":
507
509
  case "friend.create":
508
510
  case "friend.update":
511
+ case "friend.linkCode":
512
+ case "friend.merge":
513
+ case "friend.dedupe":
509
514
  case "habit.list":
510
515
  case "habit.create":
511
516
  case "habit.runs":
@@ -6145,7 +6150,30 @@ async function performSystemSetup(deps) {
6145
6150
  await registerOuroBundleTypeNonBlocking(deps);
6146
6151
  }
6147
6152
  // ── Friend command execution ──
6148
- async function executeFriendCommand(command, store) {
6153
+ async function executeFriendCommand(command, store, paths) {
6154
+ if (command.kind === "friend.linkCode")
6155
+ return executeFriendLinkCode(command, store, paths.agentRoot);
6156
+ if (command.kind === "friend.merge") {
6157
+ const result = await (0, friend_merge_1.mergeFriends)({
6158
+ store,
6159
+ friendsDir: paths.friendsDir,
6160
+ targetId: command.targetId,
6161
+ sourceIds: command.sourceIds,
6162
+ actor: "cli:friend-merge",
6163
+ reason: "owner merged records from the CLI",
6164
+ dryRun: !command.apply,
6165
+ });
6166
+ if (!result.ok)
6167
+ return `error: ${result.message}`;
6168
+ return renderFriendMerge(result.target, result.retired.map((retired) => retired.friendId), result.preservedNoteKeys, result.addedExternalIds, result.dryRun);
6169
+ }
6170
+ if (command.kind === "friend.dedupe") {
6171
+ const result = await (0, friend_merge_1.dedupeFriends)({ store, friendsDir: paths.friendsDir, actor: "cli:friend-dedupe", dryRun: !command.apply });
6172
+ if (result.groups.length === 0)
6173
+ return "no duplicate friend records found";
6174
+ const blocks = result.groups.map((group) => renderFriendMerge(group.canonical, group.duplicateIds, group.preservedNoteKeys, group.addedExternalIds, result.dryRun));
6175
+ return [...blocks, ...(result.dryRun ? ["", "dry run: nothing changed. Re-run with --apply to merge."] : [])].join("\n");
6176
+ }
6149
6177
  if (command.kind === "friend.list") {
6150
6178
  const listAll = store.listAll;
6151
6179
  if (!listAll)
@@ -6229,6 +6257,50 @@ async function executeFriendCommand(command, store) {
6229
6257
  return `identity not linked: ${command.provider}:${command.externalId}`;
6230
6258
  return `unlinked ${command.provider}:${command.externalId} from ${command.friendId}`;
6231
6259
  }
6260
+ function renderFriendMerge(target, retiredIds, preservedNoteKeys, addedExternalIds, dryRun) {
6261
+ const verb = dryRun ? "would merge" : "merged";
6262
+ const lines = [`${verb} ${retiredIds.length} record(s) into ${target.id} (${target.name}, ${target.trustLevel})`];
6263
+ for (const id of retiredIds)
6264
+ lines.push(` ${dryRun ? "would retire" : "retired"} ${id}`);
6265
+ for (const externalId of addedExternalIds)
6266
+ lines.push(` ${dryRun ? "would link" : "linked"} ${externalId.provider}:${externalId.externalId}${externalId.tenantId ? ` (tenant ${externalId.tenantId})` : ""}`);
6267
+ if (preservedNoteKeys.length > 0)
6268
+ lines.push(` kept conflicting notes as: ${preservedNoteKeys.join(", ")}`);
6269
+ return lines.join("\n");
6270
+ }
6271
+ async function executeFriendLinkCode(command, store, agentRoot) {
6272
+ let friend;
6273
+ if (command.friendId) {
6274
+ friend = await store.get(command.friendId);
6275
+ if (!friend || friend.id !== command.friendId)
6276
+ return `friend not found: ${command.friendId}`;
6277
+ }
6278
+ else {
6279
+ // The person at this terminal owns the agent: their family record carries a local identity for this OS user.
6280
+ const owner = (0, friends_1.machineOwnerUsername)();
6281
+ const all = store.listAll ? await store.listAll() : [];
6282
+ const candidates = all.filter((record) => record.trustLevel === "family"
6283
+ && record.externalIds.some((externalId) => (0, friends_1.isLocalMachineOwnerIdentity)(externalId.provider, externalId.externalId, owner)));
6284
+ if (candidates.length === 0)
6285
+ return "error: no family record names this machine's owner. Pass --friend <friend-id>.";
6286
+ if (candidates.length > 1)
6287
+ return `error: ${candidates.length} family records name this machine's owner. Run \`ouro friend dedupe --apply\` first, or pass --friend <friend-id>.`;
6288
+ friend = candidates[0];
6289
+ }
6290
+ const issued = (0, friend_link_codes_1.issueLinkCode)({
6291
+ agentRoot,
6292
+ friend,
6293
+ ...(command.channel ? { channel: command.channel } : {}),
6294
+ ...(command.ttlMinutes ? { ttlMs: command.ttlMinutes * 60 * 1000 } : {}),
6295
+ issuedBy: "cli",
6296
+ });
6297
+ const minutes = Math.round((Date.parse(issued.expiresAt) - Date.now()) / 60000);
6298
+ const where = issued.channel ? `from your ${issued.channel} account` : "from the account you want to link";
6299
+ return [
6300
+ `link code for ${friend.name} (${friend.trustLevel}): ${issued.code}`,
6301
+ `Send exactly this code to ${path.basename(agentRoot, ".ouro")} ${where}, in a 1:1 chat, within ${minutes} minutes. It works once.`,
6302
+ ].join("\n");
6303
+ }
6232
6304
  async function executeA2AClientCommand(command, deps) {
6233
6305
  const { loadOrMintA2AIdentityFile } = await Promise.resolve().then(() => __importStar(require("../../a2a/identity")));
6234
6306
  /* v8 ignore next -- production CLI keeps the client key under the home dir; tests pass an explicit file @preserve */
@@ -8107,19 +8179,16 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
8107
8179
  }
8108
8180
  // ── friend subcommands (local, no daemon socket needed) ──
8109
8181
  if (command.kind === "friend.list" || command.kind === "friend.show" || command.kind === "friend.create" ||
8110
- command.kind === "friend.update" || command.kind === "friend.link" || command.kind === "friend.unlink") {
8111
- /* v8 ignore start -- production default: requires full identity setup @preserve */
8112
- let store = deps.friendStore;
8113
- if (!store) {
8114
- // Derive agent-scoped friends dir from --agent flag or link/unlink's agent field
8115
- const agentName = ("agent" in command && command.agent) ? command.agent : undefined;
8116
- const friendsDir = agentName
8117
- ? path.join(deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(), `${agentName}.ouro`, "friends")
8118
- : path.join(deps.bundlesRoot ?? (0, identity_1.getAgentBundlesRoot)(), "friends");
8119
- store = new friends_1.FileFriendStore(friendsDir);
8120
- }
8121
- /* v8 ignore stop */
8122
- const message = await executeFriendCommand(command, store);
8182
+ command.kind === "friend.update" || command.kind === "friend.link" || command.kind === "friend.unlink" ||
8183
+ command.kind === "friend.linkCode" || command.kind === "friend.merge" || command.kind === "friend.dedupe") {
8184
+ // Derive agent-scoped friends dir from --agent flag or link/unlink's agent field
8185
+ const agentName = ("agent" in command && command.agent) ? command.agent : undefined;
8186
+ const bundlesRoot = deps.bundlesRoot ?? /* v8 ignore next -- production default @preserve */ (0, identity_1.getAgentBundlesRoot)();
8187
+ const agentRoot = agentName ? path.join(bundlesRoot, `${agentName}.ouro`) : bundlesRoot;
8188
+ const friendsDir = path.join(agentRoot, "friends");
8189
+ /* v8 ignore next -- production default: requires full identity setup @preserve */
8190
+ const store = deps.friendStore ?? new friends_1.FileFriendStore(friendsDir);
8191
+ const message = await executeFriendCommand(command, store, { agentRoot, friendsDir });
8123
8192
  deps.writeStdout(message);
8124
8193
  return message;
8125
8194
  }
@@ -240,9 +240,9 @@ exports.COMMAND_REGISTRY = {
240
240
  friend: {
241
241
  category: "Friends",
242
242
  description: "Manage agent friends and identity links",
243
- usage: "ouro friend <subcommand> [--agent <name>]",
244
- example: "ouro friend list",
245
- subcommands: ["list", "show", "create", "update", "link", "unlink"],
243
+ usage: "ouro friend <subcommand> [--agent <name>] (link --channel <channel> issues a one-time code; merge/dedupe preview unless --apply)",
244
+ example: "ouro friend link --agent ouroboros --channel teams",
245
+ subcommands: ["list", "show", "create", "update", "link", "unlink", "merge", "dedupe"],
246
246
  },
247
247
  link: {
248
248
  category: "Friends",
@@ -1631,12 +1631,76 @@ function parseFriendCommand(args) {
1631
1631
  ...(agent ? { agent } : {}),
1632
1632
  };
1633
1633
  }
1634
- if (sub === "link")
1635
- return parseLinkCommand(rest, "friend.link");
1634
+ if (sub === "link") {
1635
+ // Without an explicit identity, `link` issues a code the person sends from the new account.
1636
+ if (!rest.includes("--provider") && !rest.includes("--external-id") && (agent || rest[0]?.startsWith("--"))) {
1637
+ return parseFriendLinkCodeCommand(rest, agent);
1638
+ }
1639
+ return parseLinkCommand(agent ? [agent, ...rest] : rest, "friend.link");
1640
+ }
1636
1641
  if (sub === "unlink")
1637
- return parseLinkCommand(rest, "friend.unlink");
1642
+ return parseLinkCommand(agent ? [agent, ...rest] : rest, "friend.unlink");
1643
+ if (sub === "merge") {
1644
+ const usage = "Usage: ouro friend merge --into <friend-id> <friend-id>... [--agent <name>] [--apply]";
1645
+ let targetId;
1646
+ let apply = false;
1647
+ const sourceIds = [];
1648
+ for (let i = 0; i < rest.length; i += 1) {
1649
+ const token = rest[i];
1650
+ if (token === "--into") {
1651
+ targetId = rest[i + 1];
1652
+ i += 1;
1653
+ }
1654
+ else if (token === "--apply") {
1655
+ apply = true;
1656
+ }
1657
+ else if (token.startsWith("--")) {
1658
+ throw new Error(usage);
1659
+ }
1660
+ else {
1661
+ sourceIds.push(token);
1662
+ }
1663
+ }
1664
+ if (!targetId || sourceIds.length === 0)
1665
+ throw new Error(usage);
1666
+ return { kind: "friend.merge", targetId, sourceIds, apply, ...(agent ? { agent } : {}) };
1667
+ }
1668
+ if (sub === "dedupe") {
1669
+ const usage = "Usage: ouro friend dedupe [--agent <name>] [--apply]";
1670
+ if (rest.some((token) => token !== "--apply"))
1671
+ throw new Error(usage);
1672
+ return { kind: "friend.dedupe", apply: rest.includes("--apply"), ...(agent ? { agent } : {}) };
1673
+ }
1638
1674
  throw new Error(`Usage\n${usage()}`);
1639
1675
  }
1676
+ const FRIEND_LINK_CODE_USAGE = "Usage: ouro friend link --agent <name> [--channel <teams|bluebubbles|telegram|mail|voice|mcp|cli>] [--friend <friend-id>] [--ttl-minutes <n>]";
1677
+ const LINK_CODE_CHANNELS = ["cli", "teams", "bluebubbles", "mail", "voice", "a2a", "mcp", "telegram"];
1678
+ function parseFriendLinkCodeCommand(rest, agent) {
1679
+ let friendId;
1680
+ let channel;
1681
+ let ttlMinutes;
1682
+ for (let i = 0; i < rest.length; i += 2) {
1683
+ const token = rest[i];
1684
+ const value = rest[i + 1];
1685
+ if (!value)
1686
+ throw new Error(FRIEND_LINK_CODE_USAGE);
1687
+ if (token === "--friend")
1688
+ friendId = value;
1689
+ else if (token === "--channel" && LINK_CODE_CHANNELS.includes(value))
1690
+ channel = value;
1691
+ else if (token === "--ttl-minutes" && /^[1-9][0-9]*$/.test(value))
1692
+ ttlMinutes = Number(value);
1693
+ else
1694
+ throw new Error(FRIEND_LINK_CODE_USAGE);
1695
+ }
1696
+ return {
1697
+ kind: "friend.linkCode",
1698
+ ...(agent ? { agent } : {}),
1699
+ ...(friendId ? { friendId } : {}),
1700
+ ...(channel ? { channel } : {}),
1701
+ ...(ttlMinutes ? { ttlMinutes } : {}),
1702
+ };
1703
+ }
1640
1704
  function parseA2ACommand(args) {
1641
1705
  const { agent, rest: cleaned } = extractAgentFlag(args);
1642
1706
  const [sub, ...rest] = cleaned;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.HARNESS_CANONICAL_REPO_URL = exports.DEFAULT_AGENT_SENSES = exports.LEGACY_VAULT_SERVER_URL_ALIASES = exports.DEFAULT_VAULT_SERVER_URL = exports.DEFAULT_AGENT_PHRASES = exports.DEFAULT_AGENT_CONTEXT = exports.PROVIDER_CREDENTIALS = exports.normalizeHabitPaidTurnsPerDay = void 0;
36
+ exports.HARNESS_CANONICAL_REPO_URL = exports.DEFAULT_AGENT_SENSES = exports.LEGACY_VAULT_SERVER_URL_ALIASES = exports.DEFAULT_VAULT_SERVER_URL = exports.DEFAULT_AGENT_PHRASES = exports.DEFAULT_AGENT_CONTEXT = exports.SHEPHERD_NOTICE_POLICIES = exports.PROVIDER_CREDENTIALS = exports.normalizeHabitPaidTurnsPerDay = void 0;
37
37
  exports.normalizeVaultServerUrl = normalizeVaultServerUrl;
38
38
  exports.getVaultServerUrlCandidates = getVaultServerUrlCandidates;
39
39
  exports.defaultStableVaultEmail = defaultStableVaultEmail;
@@ -75,6 +75,7 @@ exports.PROVIDER_CREDENTIALS = {
75
75
  "openai-compatible": { required: ["apiKey", "baseUrl"], envVars: {}, promptLabels: { apiKey: "Z.ai API key", baseUrl: "Z.ai canonical base URL" } },
76
76
  "openai-compatible-gemini": { required: ["apiKey", "baseUrl"], envVars: {}, promptLabels: { apiKey: "Gemini API key", baseUrl: "Gemini canonical base URL" } },
77
77
  };
78
+ exports.SHEPHERD_NOTICE_POLICIES = ["every", "needs-me", "off"];
78
79
  exports.DEFAULT_AGENT_CONTEXT = {
79
80
  maxTokens: 80000,
80
81
  contextMargin: 20,
@@ -215,6 +216,21 @@ function normalizeSenses(value, configFile) {
215
216
  defaults[senseName] = { enabled, hostUsername: hostUsername.trim() };
216
217
  continue;
217
218
  }
219
+ const notify = rawSense.notify;
220
+ if (senseName === "shepherd" && notify !== undefined) {
221
+ if (!exports.SHEPHERD_NOTICE_POLICIES.includes(notify)) {
222
+ (0, runtime_1.emitNervesEvent)({
223
+ level: "error",
224
+ event: "config_identity.error",
225
+ component: "config/identity",
226
+ message: "agent config has invalid Shepherd notice policy",
227
+ meta: { path: configFile, sense: senseName },
228
+ });
229
+ throw new Error(`agent.json at ${configFile}: senses.shepherd.notify must be one of ${exports.SHEPHERD_NOTICE_POLICIES.join(", ")}.`);
230
+ }
231
+ defaults[senseName] = { enabled, notify: notify };
232
+ continue;
233
+ }
218
234
  defaults[senseName] = { enabled };
219
235
  }
220
236
  return defaults;
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_LINK_CODE_TTL_MS = void 0;
37
+ exports.parseLinkCode = parseLinkCode;
38
+ exports.formatLinkCode = formatLinkCode;
39
+ exports.issueLinkCode = issueLinkCode;
40
+ exports.redeemLinkCode = redeemLinkCode;
41
+ // Identity link codes: how a person proves that a new channel identity is theirs.
42
+ //
43
+ // A friend on a surface the agent already trusts (the owner's CLI, or a family chat)
44
+ // asks for a short code. When that exact code arrives as a 1:1 message from an
45
+ // identity the agent does not yet trust, that identity's record merges into the
46
+ // friend who asked, and the new identity is treated as them from then on.
47
+ //
48
+ // Codes are single use and expire. Only a SHA-256 digest of each code is stored, under
49
+ // the agent's `state/` directory, which bundle sync does not carry. A code is consumed
50
+ // by its first presentation, whatever the outcome, by an atomic rename, so a replay or a
51
+ // race between two senders finds nothing to redeem.
52
+ const fs = __importStar(require("node:fs"));
53
+ const path = __importStar(require("node:path"));
54
+ const node_crypto_1 = require("node:crypto");
55
+ const runtime_1 = require("../nerves/runtime");
56
+ const friend_merge_1 = require("./friend-merge");
57
+ const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
58
+ const CODE_BODY_LENGTH = 8;
59
+ exports.DEFAULT_LINK_CODE_TTL_MS = 10 * 60 * 1000;
60
+ const MAX_LINK_CODE_TTL_MS = 24 * 60 * 60 * 1000;
61
+ const CODE_SHAPE = new RegExp(`^OURO-?([${CODE_ALPHABET}]{4})-?([${CODE_ALPHABET}]{4})$`);
62
+ function codesRoot(agentRoot) {
63
+ return path.join(agentRoot, "state", "friend-link-codes");
64
+ }
65
+ function digest(codeBody) {
66
+ return (0, node_crypto_1.createHash)("sha256").update(codeBody).digest("hex");
67
+ }
68
+ /**
69
+ * The code body when `text` is exactly one link code (case, spacing, dashes and simple
70
+ * markup ignored), otherwise null. Anything else in the message means it is not a code.
71
+ */
72
+ function parseLinkCode(text) {
73
+ const plain = text
74
+ .replace(/<at>[^<]*<\/at>/gi, " ")
75
+ .replace(/<[^>]*>/g, " ")
76
+ .replace(/&nbsp;/g, " ")
77
+ .replace(/\s+/g, "")
78
+ .toUpperCase();
79
+ const match = CODE_SHAPE.exec(plain);
80
+ return match ? `${match[1]}${match[2]}` : null;
81
+ }
82
+ function formatLinkCode(codeBody) {
83
+ return `OURO-${codeBody.slice(0, 4)}-${codeBody.slice(4)}`;
84
+ }
85
+ function issueLinkCode(input) {
86
+ const now = (input.now ?? (() => new Date()))();
87
+ const ttlMs = Math.min(Math.max(input.ttlMs ?? exports.DEFAULT_LINK_CODE_TTL_MS, 60 * 1000), MAX_LINK_CODE_TTL_MS);
88
+ let codeBody = "";
89
+ for (let index = 0; index < CODE_BODY_LENGTH; index += 1)
90
+ codeBody += CODE_ALPHABET[(0, node_crypto_1.randomInt)(CODE_ALPHABET.length)];
91
+ const record = {
92
+ schemaVersion: 1,
93
+ friendId: input.friend.id,
94
+ friendName: input.friend.name,
95
+ ...(input.channel ? { channel: input.channel } : {}),
96
+ issuedAt: now.toISOString(),
97
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
98
+ issuedBy: input.issuedBy,
99
+ };
100
+ const pendingDir = path.join(codesRoot(input.agentRoot), "pending");
101
+ fs.mkdirSync(pendingDir, { recursive: true, mode: 0o700 });
102
+ fs.writeFileSync(path.join(pendingDir, `${digest(codeBody)}.json`), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" });
103
+ (0, runtime_1.emitNervesEvent)({
104
+ component: "friends",
105
+ event: "friends.link_code_issued",
106
+ message: "issued an identity link code",
107
+ meta: { friendId: input.friend.id, channel: input.channel ?? "any", expiresAt: record.expiresAt, issuedBy: input.issuedBy },
108
+ });
109
+ return { code: formatLinkCode(codeBody), friendId: record.friendId, friendName: record.friendName, ...(record.channel ? { channel: record.channel } : {}), expiresAt: record.expiresAt };
110
+ }
111
+ const REJECTION_REPLIES = {
112
+ unknown_or_used: "That link code isn't valid. Ask for a new one.",
113
+ expired: "That link code has expired. Ask for a new one.",
114
+ group_chat: "Link codes only work in a 1:1 chat with me. Ask for a new code and send it to me directly.",
115
+ wrong_channel: "That link code was issued for a different channel. Ask for a new one.",
116
+ already_linked: "This account is already linked to you.",
117
+ claimant_trusted: "This account is already known to me as someone I trust, so a code can't relink it. The owner can merge the records instead.",
118
+ friend_missing: "The person this code was issued for no longer exists. Ask for a new code.",
119
+ };
120
+ function reject(reason, meta) {
121
+ (0, runtime_1.emitNervesEvent)({
122
+ level: "warn",
123
+ component: "friends",
124
+ event: "friends.link_code_rejected",
125
+ message: "rejected an identity link code",
126
+ meta: { reason, ...meta },
127
+ });
128
+ return { status: "rejected", reason, reply: REJECTION_REPLIES[reason] };
129
+ }
130
+ /** Atomically take the code out of `pending/`. Null when no pending code has this body. */
131
+ function consumeCode(agentRoot, codeBody, nowIso) {
132
+ const root = codesRoot(agentRoot);
133
+ const name = `${digest(codeBody)}.json`;
134
+ const usedDir = path.join(root, "used");
135
+ fs.mkdirSync(usedDir, { recursive: true, mode: 0o700 });
136
+ const usedPath = path.join(usedDir, name);
137
+ try {
138
+ fs.renameSync(path.join(root, "pending", name), usedPath);
139
+ }
140
+ catch (error) {
141
+ /* v8 ignore next -- only ENOENT is expected; other filesystem failures propagate @preserve */
142
+ if (error.code !== "ENOENT")
143
+ throw error;
144
+ return null;
145
+ }
146
+ const record = JSON.parse(fs.readFileSync(usedPath, "utf-8"));
147
+ fs.writeFileSync(usedPath, `${JSON.stringify({ ...record, consumedAt: nowIso }, null, 2)}\n`, { mode: 0o600 });
148
+ return { record, usedPath };
149
+ }
150
+ function recordOutcome(usedPath, outcome) {
151
+ const current = JSON.parse(fs.readFileSync(usedPath, "utf-8"));
152
+ fs.writeFileSync(usedPath, `${JSON.stringify({ ...current, ...outcome }, null, 2)}\n`, { mode: 0o600 });
153
+ }
154
+ /**
155
+ * Redeem a link code sent by `claimant`. Returns null when `text` is not a link code, so
156
+ * callers can run this on every inbound message and fall through to a normal turn.
157
+ */
158
+ async function redeemLinkCode(input) {
159
+ const codeBody = parseLinkCode(input.text);
160
+ if (!codeBody)
161
+ return null;
162
+ const now = (input.now ?? (() => new Date()))();
163
+ const nowIso = now.toISOString();
164
+ const baseMeta = { claimantId: input.claimant.id, channel: input.channel };
165
+ const consumed = consumeCode(input.agentRoot, codeBody, nowIso);
166
+ if (!consumed)
167
+ return reject("unknown_or_used", baseMeta);
168
+ const { record, usedPath } = consumed;
169
+ const meta = { ...baseMeta, friendId: record.friendId };
170
+ const refuse = (reason) => {
171
+ recordOutcome(usedPath, { outcome: "rejected", reason, claimantId: input.claimant.id, channel: input.channel });
172
+ return reject(reason, meta);
173
+ };
174
+ if (Date.parse(record.expiresAt) <= now.getTime())
175
+ return refuse("expired");
176
+ if (input.isGroupChat)
177
+ return refuse("group_chat");
178
+ if (record.channel && record.channel !== input.channel)
179
+ return refuse("wrong_channel");
180
+ if (input.claimant.id === record.friendId)
181
+ return refuse("already_linked");
182
+ // A code can only bring in an identity the agent does not already trust; trusted
183
+ // records are merged deliberately by the owner, not by whoever holds a code.
184
+ if (input.claimant.trustLevel !== "stranger" && input.claimant.trustLevel !== "acquaintance")
185
+ return refuse("claimant_trusted");
186
+ const merged = await (0, friend_merge_1.mergeFriends)({
187
+ store: input.store,
188
+ friendsDir: input.friendsDir,
189
+ targetId: record.friendId,
190
+ sourceIds: [input.claimant.id],
191
+ actor: `link-code:${record.issuedBy}`,
192
+ reason: `identity link code redeemed on ${input.channel}`,
193
+ now: () => now,
194
+ });
195
+ if (!merged.ok)
196
+ return refuse("friend_missing");
197
+ recordOutcome(usedPath, { outcome: "linked", claimantId: input.claimant.id, channel: input.channel, linkedExternalIds: merged.addedExternalIds.map((externalId) => externalId.provider) });
198
+ (0, runtime_1.emitNervesEvent)({
199
+ component: "friends",
200
+ event: "friends.link_code_redeemed",
201
+ message: "linked a new identity through a link code",
202
+ meta: { ...meta, trustLevel: merged.target.trustLevel, linkedExternalIds: merged.addedExternalIds.map((externalId) => externalId.provider) },
203
+ });
204
+ return {
205
+ status: "linked",
206
+ friend: merged.target,
207
+ reply: `Linked. I'll recognize this ${input.channel} account as ${merged.target.name} from now on.`,
208
+ };
209
+ }