omp-onlyne 0.1.0 → 0.2.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/src/runtime.ts CHANGED
@@ -1,9 +1,12 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { dirname, join } from "node:path";
2
- import { OnlyneError, trustPhase, type DiscoveryCandidate, type DiscoveryRecord, type KeyExchange, type KeyHook, type MaintenanceClock, type NotifyEvent, type OnlyneNode, type Outbound, type Page } from "@onlyne/lib";
3
+ import { OnlyneError, trustPhase, type DiscoveryCandidate, type DiscoveryRecord, type KeyExchange, type KeyHook, type MaintenanceClock, type NotifyEvent, type OnlyneNode, type Outbound, type Page, type TrustEntry } from "@onlyne/lib";
3
4
  import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
4
5
  import { ensureKeyDir } from "@onlyne/node-host/keys";
5
6
  import { NetworkDiscovery } from "@onlyne/node-host/network";
6
7
  import { resolveLocale, setLocale } from "@onlyne/node-host/i18n";
8
+ import { acquisitionOf } from "./acquire.ts";
9
+ import { AcquisitionLog, approvePeer, beginPeer, handshakeEntry, isTrustThread, judgeApproval, judgementFacts, revokePeer, trustThread, TRUST_THREAD, type ApprovalDeps, type ApprovalJudgement, type ApprovalRequest } from "./handshake.ts";
7
10
  import { commandText as t } from "./ui/command-text.ts";
8
11
  import { registerMsgTool } from "./tool.ts";
9
12
  import { currentDecision, JudgeDecider } from "./decider.ts";
@@ -14,6 +17,9 @@ import { canAnswer, ContextRegistry, SqliteStorage } from "./store.ts";
14
17
  import { resourceQuery } from "./resources.ts";
15
18
  import type { OnlyneConfig, OnlyneStatus, QueueItem, ViewerContact, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "./types.ts";
16
19
 
20
+ /** The trust transition of RFC 0017 §5, as the host receives it. */
21
+ type TrustNotify = Extract<NotifyEvent, { event: "trust" }>;
22
+
17
23
  export interface PluginRuntime {
18
24
  readonly config: OnlyneConfig;
19
25
  readonly endpoint: string;
@@ -32,9 +38,16 @@ export interface PluginRuntime {
32
38
  contact(endpoint: string): Promise<void>;
33
39
  /** Stage one of one exchange; the returned session carries the fingerprint the peer proved. */
34
40
  trustBegin(node: string): Promise<KeyExchange>;
41
+ /** A human's confirmation in the panel: RFC 0018 §2 keeps that gate outside the policy. */
35
42
  trustAccept(node: string, fingerprint: string): Promise<void>;
36
43
  trustRevoke(node: string): Promise<void>;
37
44
  trustRotate(): Promise<{ fingerprint: string }>;
45
+ /** RFC 0018 §2/§3.1: an agent's approval, judged against the acquisition policy first. */
46
+ acquireAccept(node: string, fingerprint: string): Promise<TrustEntry>;
47
+ /** RFC 0018 §2/§3.1: an agent's revocation. The policy never applies to dropping a peer. */
48
+ acquireRevoke(node: string): Promise<void>;
49
+ /** RFC 0018 §2/§3.1: an agent's initiation, judged before the key read that would begin it. */
50
+ acquireBegin(node: string): Promise<TrustEntry>;
38
51
  refreshDiscovery(): Promise<void>;
39
52
  updateConfig(next: OnlyneConfig): Promise<void>;
40
53
  close(): Promise<void>;
@@ -61,11 +74,35 @@ export async function restartRuntime(config: OnlyneConfig, pi: ExtensionAPI, ctx
61
74
  registerMsgTool(pi);
62
75
  return runtime;
63
76
  }
64
- export async function stopRuntime(): Promise<void> { await active?.close(); }
77
+ export async function stopRuntime(): Promise<void> {
78
+ const closing = active;
79
+ active = undefined;
80
+ await closing?.close();
81
+ }
65
82
 
66
83
  /** How often the discovery catalog is re-observed; well inside the library's 120 s freshness window. */
67
84
  const DISCOVERY_REFRESH_MS = 30_000;
68
85
 
86
+ /** The alias the context registry gives this node's main session (`ContextRegistry.ensureMain`). */
87
+ const MAIN_CONTEXT = "main";
88
+
89
+ /** One mutation's identity for the library: a fresh key and a window that outlives this call. */
90
+ function mutation(): { idempotency_key: string; retry_until: string } {
91
+ return { idempotency_key: crypto.randomUUID(), retry_until: new Date(Date.now() + 60_000).toISOString() };
92
+ }
93
+
94
+ /** The port a configured public_url names, defaulting by scheme; undefined when it names none. */
95
+ function publicPort(value: string | undefined): number | undefined {
96
+ if (value === undefined) return undefined;
97
+ try {
98
+ const url = new URL(value);
99
+ if (url.port !== "") return Number(url.port);
100
+ return url.protocol === "https:" ? 443 : 80;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
69
106
  class OmpRuntime implements PluginRuntime {
70
107
  readonly contexts: ContextRegistry;
71
108
  readonly storage: SqliteStorage;
@@ -73,6 +110,11 @@ class OmpRuntime implements PluginRuntime {
73
110
  node!: OnlyneNode;
74
111
  decider!: JudgeDecider;
75
112
  endpoint = "";
113
+ /** RFC 0018 §2.1: where the connection being served came from, for the library's own call into `notify`. */
114
+ private readonly connection = new AsyncLocalStorage<string | undefined>();
115
+ private readonly log: AcquisitionLog;
116
+ /** The host side of one approval, built once with the node in `start`. */
117
+ private peers!: ApprovalDeps;
76
118
  private server?: ReturnType<typeof Bun.serve>;
77
119
  private keyDir?: string;
78
120
  private network?: NetworkDiscovery;
@@ -88,12 +130,45 @@ class OmpRuntime implements PluginRuntime {
88
130
  this.mainAgentId = main.agent.id;
89
131
  this.storage = new SqliteStorage(join(dirname(config.path), "omp.db"));
90
132
  this.contexts = new ContextRegistry(this.storage, config.node.id, main.agent.id);
133
+ this.log = new AcquisitionLog(this.storage);
91
134
  }
92
135
  async start(): Promise<void> {
93
136
  let handler: (request: Request) => Promise<Response> = async () => new Response(t("initializing"), { status: 503 });
94
- this.server = Bun.serve({ hostname: this.config.server.bind, port: 0, fetch: request => handler(request) });
137
+ // RFC 0018 §2.1: the address a peer statement arrived from is known only here. The
138
+ // library calls `notify` synchronously from inside the request it is serving, so the
139
+ // connection's own store is still in scope when the trust event is handled.
140
+ const serve = (port: number) => Bun.serve({
141
+ hostname: this.config.server.bind, port,
142
+ fetch: (request, server) => this.connection.run(server.requestIP(request)?.address, () => handler(request)),
143
+ });
144
+ // A configured public_url is a promise about where this node answers. Advertising it
145
+ // without listening there leaves every peer that pinned the address calling a port
146
+ // nobody serves, so the port is taken unless it is already in use. Without a public_url
147
+ // the port this node last bound is reused: an address that changes on every start is one
148
+ // every peer has to be taught again, and the operator's only recourse is to pin it by hand.
149
+ const wanted = publicPort(this.config.server.public_url) ?? this.config.server.port;
150
+ let fellBack = false;
151
+ // `server.port` is optional because Bun omits it for a unix socket; this listener is TCP.
152
+ let bound: number;
153
+ try {
154
+ this.server = serve(wanted ?? 0);
155
+ bound = this.server.port!;
156
+ } catch (error) {
157
+ if (wanted === undefined) throw error;
158
+ fellBack = true;
159
+ this.server = serve(0);
160
+ bound = this.server.port!;
161
+ this.pi.logger.warn("onlyne.port.taken", { wanted, bound, error: errorText(error) });
162
+ }
95
163
  const host = advertisedHost(this.config.server.bind);
96
- this.endpoint = `http://${host.includes(":") ? `[${host}]` : host}:${this.server.port}/onlyne/v3`;
164
+ const actual = `http://${host.includes(":") ? `[${host}]` : host}:${bound}/onlyne/v3`;
165
+ this.endpoint = this.config.server.public_url === undefined || fellBack ? actual : normalizeServiceUrl(this.config.server.public_url);
166
+ // The port the system chose is this node's address for as long as it keeps it. Writing it
167
+ // back is what makes the next start answer where the peers already look, and it costs one
168
+ // file write on the first start and none afterwards.
169
+ if (this.config.server.public_url === undefined && this.config.server.port !== bound) {
170
+ await this.rememberPort(bound);
171
+ }
97
172
  const instanceId = crypto.randomUUID();
98
173
  // The decider probes peers for their execution contexts, so it must use the node's own
99
174
  // transport: an unsigned probe is refused by every keyed peer.
@@ -107,6 +182,9 @@ class OmpRuntime implements PluginRuntime {
107
182
  else this.pi.logger.debug(message, fields);
108
183
  },
109
184
  listPeers: async () => (await this.node.discovery.listRecords()).filter(record => record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now()),
185
+ listTrust: async () => (await this.node.trust.list(this.node.auth)).map(row => ({
186
+ node_id: row.node_id, phase: trustPhase(row), base_url: row.base_url,
187
+ })),
110
188
  listReceiverContexts: async id => id === this.config.node.id
111
189
  ? [
112
190
  { alias: "main", summary: t("main"), state: this.main.isIdle() ? "idle" : "running" },
@@ -117,8 +195,16 @@ class OmpRuntime implements PluginRuntime {
117
195
  .map(record => ({ alias: record.alias, summary: record.summary, state: record.state }))),
118
196
  ]
119
197
  : transport!.request(id, "GET", "/contexts", undefined, this.controller.signal),
120
- listThreads: () => allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor })),
198
+ // RFC 0018 §3.2: the handshake thread is this host's own record, never a candidate
199
+ // for a conversation the agent routes or reads by name.
200
+ listThreads: async () => (await allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor })))
201
+ .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
202
+ listJoinedThreads: async () => (await allPages(cursor => this.node.threads.joined(this.node.auth, { page_size: 100, cursor })))
203
+ .filter(thread => !isTrustThread(this.config.node.id, thread.thread_ref)),
121
204
  listMessages: (thread, cursor) => this.node.messages.list(this.node.auth, thread, { attention: "thread", page_size: 100, cursor }),
205
+ listInbox: async () => (await allPages(cursor => this.node.messages.inbox(this.node.auth, { page_size: 100, cursor })))
206
+ .map(row => ({ context: row.context, attention: row.attention })),
207
+ threadMembers: async thread => (await this.node.threads.describe(this.node.auth, thread)).members.map(member => member.peer),
122
208
  });
123
209
  const bridge = createRuntimeBridge({ pi: this.pi, main: this.main, decider: this.decider, contexts: this.contexts, nodeId: this.config.node.id });
124
210
  const projectRoot = dirname(dirname(this.config.path));
@@ -145,9 +231,19 @@ class OmpRuntime implements PluginRuntime {
145
231
  },
146
232
  });
147
233
  transport = this.node.transport;
234
+ this.peers = {
235
+ policy: acquisitionOf(this.config),
236
+ log: this.log,
237
+ observed: async (nodeId, fingerprint) => (await this.log.observation(nodeId, fingerprint))?.observed,
238
+ declared: async nodeId => (await this.node.trust.entry(nodeId))?.base_url,
239
+ recorded: nodeId => this.recorded(nodeId),
240
+ audit: judgement => this.pi.logger.info("onlyne.acquire.decided", judgementFacts(judgement)),
241
+ accept: (nodeId, fingerprint) => this.node.trust.accept(this.node.auth, nodeId, fingerprint),
242
+ revoke: nodeId => this.node.trust.revoke(this.node.auth, nodeId),
243
+ now: () => new Date().toISOString(),
244
+ };
148
245
  const memberships = (await this.node.domains.list(this.node.auth)).items;
149
246
  const current = memberships[0];
150
- const mutation = () => ({ idempotency_key: crypto.randomUUID(), retry_until: new Date(Date.now() + 60_000).toISOString() });
151
247
  if (current && current.domain_id !== this.config.region) await this.node.domains.leave(this.node.auth, current.domain_id, { ...mutation(), expected_revision: current.revision });
152
248
  if (!current || current.domain_id !== this.config.region) {
153
249
  let expected_revision: string | undefined;
@@ -191,11 +287,110 @@ class OmpRuntime implements PluginRuntime {
191
287
  this.pi.logger.info(`onlyne.trust.${event.cause}`, {
192
288
  node: event.entry.node_id, phase: event.phase, fingerprint: event.entry.fingerprint,
193
289
  });
290
+ // RFC 0018 §2.1: the address this statement arrived from exists only inside the
291
+ // call the library is making right now, and nothing after it can recover it.
292
+ this.trustEvent(event, this.connection.getStore());
194
293
  }
195
294
  this.changed();
196
295
  }
197
296
  onChange(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
198
297
  private changed(): void { for (const listener of this.listeners) listener(); }
298
+ /**
299
+ * RFC 0018 §2/§3: one trust transition's host work — the observation its statement's
300
+ * connection left, the judgement of an invitation this host may approve itself, and the
301
+ * handshake entry the agent reads. Everything is reported by the library's own call, so it
302
+ * runs beside it; a failure here is logged and is never a reason to doubt the committed row.
303
+ */
304
+ private trustEvent(event: TrustNotify, observed: string | undefined): void {
305
+ void this.handleTrust(event, observed).catch(error => {
306
+ this.pi.logger.warn("onlyne.trust.handling_failed", { node: event.entry.node_id, phase: event.phase, error: errorText(error) });
307
+ });
308
+ }
309
+ private async handleTrust(event: TrustNotify, observed: string | undefined): Promise<void> {
310
+ const node = event.entry.node_id;
311
+ const fingerprint = event.entry.fingerprint;
312
+ // The address is recorded for every peer statement, before its verdict. Local
313
+ // transitions never replace it with the address of the approval call (which has none),
314
+ // so an approval decided later still reads the statement's original connection.
315
+ const at = new Date().toISOString();
316
+ if (event.cause === "peer-statement") {
317
+ await this.log.observe(node, fingerprint, {
318
+ ...(observed === undefined ? {} : { observed }), at, cause: event.cause,
319
+ });
320
+ } else await this.log.transition(node, fingerprint, at, event.cause);
321
+ // RFC 0018 §2.2: the verdict reads the catalog as it stood when the statement arrived.
322
+ // The record this host derives from that statement is not part of the judgement, or the
323
+ // two conditions would collapse into one.
324
+ const recorded = await this.recorded(node);
325
+ // The statement's own address is also a discovery candidate here, as the tern host's
326
+ // bridge records it.
327
+ if (event.entry.base_url) {
328
+ await this.node.discovery.observe(event.entry.base_url, "trust-statement", node).catch(() => undefined);
329
+ }
330
+ // §2.2: only an invitation whose statement covered a connection this host observed, and
331
+ // whose declared and recorded addresses all stay inside the allow set, is committed
332
+ // without a human deciding first.
333
+ const judgement = event.phase === "invited" && event.cause === "peer-statement"
334
+ ? await judgeApproval(this.peers, { node_id: node, fingerprint, observed, recorded })
335
+ : undefined;
336
+ if (judgement?.verdict.allowed === true) this.approveDeferred(node, fingerprint);
337
+ await this.deliverHandshake(event, observed, judgement);
338
+ }
339
+ /**
340
+ * RFC 0018 §2: the approval an invitation earned is committed outside the library's call,
341
+ * so the admission that notified this host is never re-entered.
342
+ */
343
+ private approveDeferred(node: string, fingerprint: string): void {
344
+ queueMicrotask(() => {
345
+ void this.peers.accept(node, fingerprint)
346
+ .catch(error => this.pi.logger.warn("onlyne.acquire.approve_failed", { node, error: errorText(error) }));
347
+ });
348
+ }
349
+ /**
350
+ * RFC 0018 §3: one handshake entry per `(node, fingerprint, phase)`, written into this
351
+ * node's own thread under a plan bound to the main session. The thread has this node as its
352
+ * only member, so the entry never leaves the node.
353
+ */
354
+ private async deliverHandshake(event: TrustNotify, observed: string | undefined, judgement: ApprovalJudgement | undefined): Promise<void> {
355
+ const node = event.entry.node_id;
356
+ const fingerprint = event.entry.fingerprint;
357
+ if (await this.log.delivered(node, fingerprint, event.phase)) return;
358
+ const handshake = handshakeEntry({
359
+ node_id: node, fingerprint, phase: event.phase, declared: event.entry.base_url, observed,
360
+ policy: this.peers.policy, judgement, approved: judgement?.verdict.allowed === true,
361
+ });
362
+ await this.ensureTrustThread();
363
+ const outcome = await this.node.client.send(this.config.node.id, {
364
+ id: crypto.randomUUID(),
365
+ sender: this.config.node.id,
366
+ delivery_kind: "trust",
367
+ msg: handshake.msg,
368
+ // The mention is what puts the entry in this node's own inbox: the delivery plan
369
+ // names the main context, and the bridge reads the facts for its todo hook.
370
+ meta: { thread: trustThread(this.config.node.id), mentions: [this.config.node.id], data: { trust: handshake.trust } },
371
+ }, { response: "none", execution: "existing", context: MAIN_CONTEXT });
372
+ if (outcome.admission !== "accepted") {
373
+ this.pi.logger.warn("onlyne.trust.delivery_failed", { node, phase: event.phase, error: outcome.error?.message });
374
+ return;
375
+ }
376
+ await this.log.markDelivered(node, fingerprint, event.phase, new Date().toISOString());
377
+ }
378
+ /** RFC 0018 §3.2: the handshake thread, created on first use. Its id is fixed, so it is one thread for the node's lifetime. */
379
+ private async ensureTrustThread(): Promise<void> {
380
+ const ref = trustThread(this.config.node.id);
381
+ try {
382
+ await this.node.threads.describe(this.node.auth, ref);
383
+ return;
384
+ } catch (error) {
385
+ if (!(error instanceof OnlyneError && error.error.code === "not_found")) throw error;
386
+ }
387
+ await this.node.threads.create(this.node.auth, { title: TRUST_THREAD, id: ref.id, ...mutation() });
388
+ }
389
+ /** RFC 0018 §2.2: every address discovery holds for this node is an outbound candidate the policy must allow. */
390
+ private async recorded(node: string): Promise<string[]> {
391
+ const records = await this.node.discovery.listRecords();
392
+ return [...new Set(records.filter(record => record.node_id === node).map(record => record.base_url))];
393
+ }
199
394
  /**
200
395
  * A user-typed address. It is normalized once, read for its own identity and everything it
201
396
  * declares, and only a successful read becomes a seed. The node keeps running: a seed is a
@@ -242,6 +437,16 @@ class OmpRuntime implements PluginRuntime {
242
437
  const saved = await saveConfig({ ...this.config, discovery: { seeds: [...seeds, seed] } });
243
438
  this.config.discovery = saved.discovery;
244
439
  }
440
+ /**
441
+ * The listening port is configuration for the same reason a seed is: a peer holds the
442
+ * address it last reached, and a node that answers somewhere new has moved without telling
443
+ * anyone. One write on the first start, and one more only after a port conflict moved it.
444
+ */
445
+ private async rememberPort(port: number): Promise<void> {
446
+ const saved = await saveConfig({ ...this.config, server: { ...this.config.server, port } });
447
+ this.config.server = saved.server;
448
+ this.pi.logger.info("onlyne.port.persisted", { port, path: this.config.path });
449
+ }
245
450
  /** Stage one of one exchange: the peer's proved fingerprint, and the session that commits it. */
246
451
  async trustBegin(node: string): Promise<KeyExchange> {
247
452
  return this.node.trust.begin(this.node.auth, node);
@@ -252,6 +457,31 @@ class OmpRuntime implements PluginRuntime {
252
457
  async trustRevoke(node: string): Promise<void> {
253
458
  await this.node.trust.revoke(this.node.auth, node);
254
459
  }
460
+ /** RFC 0018 §2/§3.1: the agent's approval, refused with `policy_denied` when the policy does not allow it. */
461
+ async acquireAccept(node: string, fingerprint: string): Promise<TrustEntry> {
462
+ return approvePeer(this.peers, node, fingerprint);
463
+ }
464
+ /** RFC 0018 §2/§3.1: the agent's revocation, which no policy judges. */
465
+ async acquireRevoke(node: string): Promise<void> {
466
+ await revokePeer(this.peers, node);
467
+ }
468
+ /**
469
+ * RFC 0018 §2/§3.1: the agent's initiation of one exchange. The judgement reads only what this
470
+ * host already stores; the peer's key is read after it, so a refusal sends nothing.
471
+ */
472
+ async acquireBegin(node: string): Promise<TrustEntry> {
473
+ const row = await this.node.trust.entry(node);
474
+ const statement = row === undefined ? undefined : (await this.log.observation(node, row.fingerprint))?.observed;
475
+ const records = (await this.node.discovery.listRecords()).filter(record => record.node_id === node);
476
+ const observed = outboundObserved({ statement, records, declared: row?.base_url });
477
+ const request: ApprovalRequest = {
478
+ node_id: node,
479
+ ...(row === undefined ? {} : { fingerprint: row.fingerprint }),
480
+ ...(observed === undefined ? {} : { observed }),
481
+ };
482
+ const committed = await beginPeer(this.peers, target => this.node.trust.begin(this.node.auth, target), request);
483
+ return committed.entry;
484
+ }
255
485
  async trustRotate(): Promise<{ fingerprint: string }> {
256
486
  const identity = await this.node.trust.rotate(this.node.auth);
257
487
  return { fingerprint: identity.fingerprint };
@@ -307,6 +537,9 @@ class OmpRuntime implements PluginRuntime {
307
537
  for (const thread of await allPages(cursor => this.node.threads.list(this.node.auth, { page_size: 100, cursor }))) {
308
538
  const ref = thread.thread_ref;
309
539
  hosted.add(`${ref.owner}/${ref.id}`);
540
+ // RFC 0018 §3.2: the handshake entries stay out of the conversation tree. The
541
+ // thread is still recorded above, so its own inbox rows are not re-rendered here.
542
+ if (isTrustThread(this.config.node.id, ref)) continue;
310
543
  const entries = await allPages(cursor => this.node.messages.list(this.node.auth, ref, { attention: "thread", page_size: 100, cursor }));
311
544
  views.push({ ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: thread.title, state: thread.state,
312
545
  unread: inbox.filter(item => !item.injected && item.attention.thread_ref.owner === ref.owner && item.attention.thread_ref.id === ref.id).length,
@@ -319,11 +552,20 @@ class OmpRuntime implements PluginRuntime {
319
552
  // sees the reply and not the question that started it.
320
553
  const outbound = await this.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
321
554
  const seen = new Map<string, ViewerThread>();
555
+ // A grant can arrive before any message, and a thread this node was invited into is a row
556
+ // the viewer must show. The local joined listing supplies those rows; the owner is never
557
+ // asked here, so the viewer stays a read of this node's own records.
558
+ for (const row of await allPages(cursor => this.node.threads.joined(this.node.auth, { page_size: 100, cursor }))) {
559
+ const ref = row.thread_ref;
560
+ const key = `${ref.owner}/${ref.id}`;
561
+ if (hosted.has(key) || seen.has(key)) continue;
562
+ seen.set(key, { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: row.last_seq, messages: [], read_only: true });
563
+ }
322
564
  for (const item of inbox) {
323
565
  const ref = item.attention.thread_ref;
324
566
  const key = `${ref.owner}/${ref.id}`;
325
567
  if (hosted.has(key)) continue;
326
- const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [] };
568
+ const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [], read_only: true };
327
569
  seen.set(key, view);
328
570
  if (!item.injected) view.unread++;
329
571
  const entry = item.attention.entry;
@@ -338,7 +580,7 @@ class OmpRuntime implements PluginRuntime {
338
580
  const ref = receipt.thread_ref;
339
581
  const key = `${ref.owner}/${ref.id}`;
340
582
  if (hosted.has(key)) continue;
341
- const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [] };
583
+ const view = seen.get(key) ?? { ref, alias: `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`, title: t("thread", { node: shortNodeId(ref.owner) }), state: "open", unread: 0, last_seq: "0", messages: [], read_only: true };
342
584
  seen.set(key, view);
343
585
  const message = row.value.request.message;
344
586
  view.messages.push({ ref: { sender: message.sender, id: message.id }, seq: receipt.seq, direction: "out", author: message.sender, at: row.value.created_at ?? "", text: message.msg, relation: message.meta?.relation });
@@ -407,6 +649,24 @@ export async function allPages<T>(read: (cursor?: string) => Promise<Page<T>>):
407
649
 
408
650
  function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }
409
651
 
652
+ /**
653
+ * RFC 0018 §2.1–2.2: the address an outbound judgement reads for a peer this host may initiate
654
+ * with. The arrival address this host recorded for the peer's last statement is the strongest
655
+ * fact it holds; a peer that never sent one is judged at an address discovery verified by
656
+ * probing, and with no record at all at the address the library's own fallback would connect to.
657
+ */
658
+ export function outboundObserved(facts: {
659
+ statement?: string;
660
+ records: readonly DiscoveryRecord[];
661
+ declared?: string;
662
+ }): string | undefined {
663
+ if (facts.statement !== undefined && facts.statement !== "") return facts.statement;
664
+ const verified = facts.records
665
+ .filter(record => record.verified_at !== undefined)
666
+ .sort((left, right) => right.received_at.localeCompare(left.received_at));
667
+ return verified[0]?.base_url ?? (facts.declared === "" ? undefined : facts.declared);
668
+ }
669
+
410
670
  /** A verified record beats an unverified one; within each class the newest observation wins. */
411
671
  function newerRecord(candidate: DiscoveryRecord, kept: DiscoveryRecord): boolean {
412
672
  const candidateVerified = candidate.verified_at !== undefined;
package/src/tool-text.ts CHANGED
@@ -7,7 +7,7 @@ const en = {
7
7
  "param.meta": "Explicit references and operation fields",
8
8
  description: `The only surface for Onlyne messages, threads and resources. There is no CLI and nothing else to look up: call this tool with your intent instead of reading docs or source first.
9
9
 
10
- Shape: { delivery_kind, msg, meta }. delivery_kind names the intent in plain language ("reply to dpdp's greeting", "show this thread", "read that message"). meta carries references you already know: to, thread, context, in_reply_to {thread,seq} or {sender,id}, relation (reply|followup), mentions, message {sender,id} or {thread,seq}, resource {owner,id}, operation, expected_revision, op, title, scope, seq, attention, cursor, page_size, data, domain_id, owner, since, event_id, operation_id.
10
+ Shape: { delivery_kind, msg, meta }. delivery_kind names the intent in plain language ("reply to dpdp's greeting", "show this thread", "read that message"). meta carries references you already know: to, thread, context, in_reply_to {thread,seq} (preferred) or {sender,id}, relation (reply|followup), mentions, message {sender,id} or {thread,seq}, resource {owner,id}, operation, expected_revision, op, title, scope, seq, attention, cursor, page_size, data, domain_id, owner, since, event_id, operation_id.
11
11
 
12
12
  A send is one call. The result is the delivery fact: admission "accepted" means the receiver recorded the message; wait "timed_out" only means no reply arrived before the wait bound, the message is still delivered; reply_body "withheld" means this context already showed that body. Report the outcome and stop there.
13
13
 
@@ -32,27 +32,36 @@ meta.to takes one node or a list of nodes. A list is delivered one target at a t
32
32
 
33
33
  Use cases, each a complete call:
34
34
 
35
- - Reply to a message you received. Do not name a thread; the receiver lands the reply where the original lives:
36
- { delivery_kind: "answer dpdp's question", msg: "Yes, the second set is fine.", meta: { in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" }, relation: "reply" } }
35
+ - Reply to a message you received. Reference it as {thread, seq}, copied from the delivery card — never an id from memory; the receiver lands the reply where the original lives:
36
+ { delivery_kind: "answer dpdp's question", msg: "Yes, the second set is fine.", meta: { in_reply_to: { thread: "<thread id>", seq: "<seq>" }, relation: "reply" } }
37
37
  - Send a fresh message to one peer: { delivery_kind: "greet lazuli", msg: "Hello from the workbench.", meta: { to: "lazuli" } }
38
38
  - Send the same note to two peers: { delivery_kind: "notify both", msg: "Report is up.", meta: { to: ["caerulea", "lazuli"] } } — you get one result per target, and a failure on one does not hide the other.
39
39
  - Read the whole exchange with one peer: read \`onlyne://conversations/<sender>/<id>\` from a list you got at \`onlyne://conversations\`. Read one message with \`onlyne://messages/<sender>/<id>\`.
40
40
  - Check what happened to a message you sent: read \`onlyne://outbound/<message-id>\`; it shows the landing the receiver reported, whether it is accepted, and the reply if one arrived.
41
41
  - Look at what a resource offers before operating it: read \`onlyne://resources\` or \`onlyne://resources/<owner>/<id>\`, then send the operation as { delivery_kind: "list the operations of this resource", msg: "show me what it can do", meta: { resource: { owner: "<node>", id: "<resource>" }, operation: "<name>" } }. meta.data carries the arguments; meta.expected_revision requires the revision you read.
42
- - In a subagent, answer without sending: { delivery_kind: "draft the reply", msg: "Here is the answer.", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" } } } returns { draft_id }. The main session sends exactly that draft: { delivery_kind: "send the drafted reply", msg: "send draft", meta: { draft_id: "<draft id>" } }.
43
- - When a send fails with no trusted key, do not retry and do not try to pair: the error names both nodes, the responsible holder and the entry in the host surface. Report it and stop.
44
-
45
- The judge role chooses the action and plan from your intent, so never invent message ids, idempotency keys or revisions. A reply or followup states its relation; omitting it is rejected rather than assumed. A reply targets the original reply authority, so a conflicting to is rejected. A subagent session reads its own slice (onlyne://threads, onlyne://inbox, onlyne://contexts) and returns the result to the main session, which does the sending.`,
42
+ - In a subagent, answer without sending: { delivery_kind: "draft the reply", msg: "Here is the answer.", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { thread: "<thread id>", seq: "<seq>" } } } returns { draft_id }. The main session sends exactly that draft: { delivery_kind: "send the drafted reply", msg: "send draft", meta: { draft_id: "<draft id>" } }.
43
+ - Approve a peer whose invitation waits for you: { delivery_kind: "accept the trust handshake", msg: "accept the peer", meta: { op: "trust_accept", node_id: "<node>", fingerprint: "<fingerprint>" } }. The acquisition policy judges it first, and a refusal names the rule it matched and leaves the peer as it was.
44
+ - Drop an approval: { delivery_kind: "revoke the trust handshake", msg: "revoke the peer", meta: { op: "trust_revoke", node_id: "<node>" } }. Revocation is never judged by the policy and names no fingerprint.
45
+ - Start an exchange with a discovered peer you are not paired with yet: { delivery_kind: "start the handshake with lazuli", msg: "start the exchange", meta: { op: "trust_begin", node_id: "lazuli" } }. The acquisition policy judges it before anything is sent: a refusal names the rule it matched and leaves the peer as it was, and an allow commits the local approval, sends the statement and reports the phase that followed.
46
+ - Put a peer into a conversation, so the thread carries several members: { delivery_kind: "add dpdp to the plan thread", msg: "add dpdp", meta: { op: "member_add", thread: "<thread id or title>", to: "dpdp" } }; one call per member when meta.to is a list. Take one out with op "member_remove". Adding a member puts them in that thread, so a later meta.to array WITH meta.thread addresses one thread, while without meta.thread each target gets its own.
47
+ - When a send fails with no trusted key, do not retry: the error names both nodes, and a pending invitation is confirmed by the acquisition policy or by that node's holder, not by another send. Report it and stop.
48
+
49
+ The judge role chooses the action and plan from your intent, so never invent message ids, idempotency keys or revisions. Reference a message as {thread, seq}; when a call states neither a reference nor a relation and this context has unanswered inbound messages, the judge asks which one it answers. A reply targets the original reply authority, so a conflicting to is rejected. A subagent session reads its own slice (onlyne://threads, onlyne://inbox, onlyne://contexts) and returns the result to the main session, which does the sending.`,
46
50
  "summary.no_result": "Onlyne returned no structured result.",
47
- "summary.where": " to {owner}",
48
- "summary.thread_ref": " (thread {thread} seq {seq}{answering})",
51
+ "summary.addressee": "Sent to {to}",
52
+ "summary.addressee_self": "Sent to yourself",
53
+ "summary.stored_here": "thread {thread} is hosted on your node, seq {seq}{answering}",
54
+ "summary.stored_there": "thread {thread} is hosted on {owner}, seq {seq}{answering}",
49
55
  "summary.answering": ", answering {sender}/{id}",
50
- "summary.outbound": " · read back: onlyne://outbound/{id}",
51
- "summary.delivered": "Delivered{where}{facts}{trail} and recorded durably.",
52
- "summary.delivered_answered": "Delivered{where}{facts}{trail}. {author} answered at seq {seq}.",
53
- "summary.delivered_withheld": "Delivered{where}{facts}{trail}. {author} answered at seq {seq}, but that body was already shown in this context, so only its arrival is reported.",
54
- "summary.delivered_timed_out": "Delivered{where}{facts}{trail} and recorded durably. No reply arrived within the wait; it will arrive on its own as an inbound message.",
55
- "summary.delivered_cancelled": "Delivered{where}{facts}{trail} and recorded durably. The wait was cancelled; a reply will arrive on its own as an inbound message.",
56
+ "summary.sep": " · ",
57
+ "summary.resolved": "resolved: {node}",
58
+ "summary.outbound": "read back: onlyne://outbound/{id}",
59
+ "summary.grant_notice": "; the entry lives here because you host the thread, and {to} reads it as a member once the grant notice reaches it",
60
+ "summary.delivered": "{facts} and recorded durably{mechanism}.",
61
+ "summary.delivered_answered": "{facts} and recorded durably{mechanism}. {author} answered at seq {seq}.",
62
+ "summary.delivered_withheld": "{facts} and recorded durably{mechanism}. {author} answered at seq {seq}, but that body was already shown in this context, so only its arrival is reported.",
63
+ "summary.delivered_timed_out": "{facts} and recorded durably{mechanism}. No reply arrived within the wait; it will arrive on its own as an inbound message.",
64
+ "summary.delivered_cancelled": "{facts} and recorded durably{mechanism}. The wait was cancelled; a reply will arrive on its own as an inbound message.",
56
65
  "summary.not_confirmed": "Not confirmed: {failure}. {next}",
57
66
  "summary.retry": "The record stays open and the runtime retries it; sending again would commit a second intent.",
58
67
  "summary.recover": "The record stays open for recovery; do not re-send the same intent.",
@@ -63,6 +72,14 @@ The judge role chooses the action and plan from your intent, so never invent mes
63
72
  "draft.ready": "Draft {draft_id} is ready; the main session sends it with meta.draft_id.",
64
73
  "multi.delivered": "→ {to}: {summary}",
65
74
  "multi.failed": "→ {to}: not sent — {error}",
75
+ "trust.accepted": "Approved {node} ({fingerprint}); the trust phase is now {phase}.",
76
+ "trust.revoked": "Revoked {node}; local approval is dropped.",
77
+ "trust.begun": "Started the exchange with {node} ({fingerprint}); the trust phase is now {phase}. The local approval is committed and the statement is sent, so this peer can exchange messages once it approves in turn.",
78
+ "thread.member_added": "Added {member} to thread {thread}. That member now reads and posts in it, and a later meta.to array with meta.thread {thread} puts every target in this one thread; the same array without meta.thread gives each target its own thread.",
79
+ "thread.member_removed": "Removed {member} from thread {thread}. The thread stays open and its earlier entries are still its history; a later bare send to {member} opens its own two-party thread.",
80
+ "failure.trust_node_unknown": "{action} names no known node {node}. Name the node id, or a short alias that exactly one known peer carries.",
81
+ "failure.trust_fields": "A trust action needs a non-empty string {field}.",
82
+ "failure.trust_main_only": "Only the main session approves or revokes a peer; a subagent drafts instead.",
66
83
  } as const;
67
84
 
68
85
  const zh: Record<keyof typeof en, string> = {
@@ -72,7 +89,7 @@ const zh: Record<keyof typeof en, string> = {
72
89
  "param.meta": "显式引用与操作字段",
73
90
  description: `Onlyne 消息、thread 与资源的唯一入口。没有 CLI,也没有别处要查:把意图交给这个工具,不要先去读文档或源码。
74
91
 
75
- 形状:{ delivery_kind, msg, meta }。delivery_kind 用日常语言说出意图(“回复 dpdp 的问候”“看看这个 thread”“读那条消息”)。meta 放你已经知道的引用:to、thread、context、in_reply_to {thread,seq} 或 {sender,id}、relation(reply|followup)、mentions、message {sender,id} 或 {thread,seq}、resource {owner,id}、operation、expected_revision、op、title、scope、seq、attention、cursor、page_size、data、domain_id、owner、since、event_id、operation_id。
92
+ 形状:{ delivery_kind, msg, meta }。delivery_kind 用日常语言说出意图(“回复 dpdp 的问候”“看看这个 thread”“读那条消息”)。meta 放你已经知道的引用:to、thread、context、in_reply_to {thread,seq}(首选)或 {sender,id}、relation(reply|followup)、mentions、message {sender,id} 或 {thread,seq}、resource {owner,id}、operation、expected_revision、op、title、scope、seq、attention、cursor、page_size、data、domain_id、owner、since、event_id、operation_id。
76
93
 
77
94
  发送是一次调用。结果就是投递事实:admission "accepted" 表示接收方已经记录这条消息;wait "timed_out" 只表示等待期内没有回信,消息本身已经投递;reply_body "withheld" 表示这个上下文已经展示过那段正文。报告结果,到此为止。
78
95
 
@@ -97,27 +114,36 @@ meta.to 接受一个节点或一组节点。一组会逐个目标投递,每个
97
114
 
98
115
  用例,每个都是完整调用:
99
116
 
100
- - 回复你收到的消息。不要指名 thread;接收方会把回信放到原消息所在的 thread:
101
- { delivery_kind: "回答 dpdp 的问题", msg: "可以,第二组没问题。", meta: { in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" }, relation: "reply" } }
117
+ - 回复你收到的消息。引用写成 {thread, seq},从投递卡片上照抄——绝不凭记忆写 id;接收方会把回信放到原消息所在的 thread:
118
+ { delivery_kind: "回答 dpdp 的问题", msg: "可以,第二组没问题。", meta: { in_reply_to: { thread: "<thread id>", seq: "<seq>" }, relation: "reply" } }
102
119
  - 给一个 peer 发新消息:{ delivery_kind: "向 lazuli 问好", msg: "来自工作台的问候。", meta: { to: "lazuli" } }
103
120
  - 把同一段话发给两个 peer:{ delivery_kind: "通知双方", msg: "报告已上传。", meta: { to: ["caerulea", "lazuli"] } } —— 每个目标各得一条结果,某个目标失败不会掩盖另一个。
104
121
  - 读与某个 peer 的整段往来:先读 \`onlyne://conversations\` 拿到一条,再读 \`onlyne://conversations/<sender>/<id>\`。读单条消息用 \`onlyne://messages/<sender>/<id>\`。
105
122
  - 查你发出的消息后来怎样了:读 \`onlyne://outbound/<message-id>\`;它显示接收方报告的落点、是否被接纳,以及回信(如果到了)。
106
123
  - 操作资源前先看它提供什么:读 \`onlyne://resources\` 或 \`onlyne://resources/<owner>/<id>\`,然后发操作 { delivery_kind: "列出该资源的操作", msg: "看看它能做什么", meta: { resource: { owner: "<node>", id: "<resource>" }, operation: "<name>" } }。meta.data 带参数;meta.expected_revision 要求版本与你读到的一致。
107
- - 在子代理里,只写不发:{ delivery_kind: "起草回复", msg: "这是答案。", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { sender: "urn:onlyne:agent:dpdp", id: "<message id>" } } } 返回 { draft_id }。主会话原样发出这份草稿:{ delivery_kind: "发出起草的回复", msg: "发送草稿", meta: { draft_id: "<draft id>" } }。
108
- - 发送失败且没有可信密钥时,不要重试也不要尝试配对:错误里写明了两个节点、负责的持有者和宿主界面上的入口。报告它,然后停下。
109
-
110
- 动作和执行计划由 judge 角色根据你的意图选择,所以永远不要自己编 message id、幂等键或版本号。回信或 followup 必须说明关系;省略会被拒绝,而不是被假定。回信只针对原来的回信权限方,所以冲突的 to 会被拒绝。子代理会话读自己的切片(onlyne://threads、onlyne://inbox、onlyne://contexts),把结果交回主会话,由主会话发送。`,
124
+ - 在子代理里,只写不发:{ delivery_kind: "起草回复", msg: "这是答案。", meta: { op: "draft", to: "dpdp", relation: "reply", in_reply_to: { thread: "<thread id>", seq: "<seq>" } } } 返回 { draft_id }。主会话原样发出这份草稿:{ delivery_kind: "发出起草的回复", msg: "发送草稿", meta: { draft_id: "<draft id>" } }。
125
+ - 批准一个在等你决定的对端:{ delivery_kind: "批准信任握手", msg: "批准这个对端", meta: { op: "trust_accept", node_id: "<node>", fingerprint: "<fingerprint>" } }。先由获取策略判定;被拒时错误里写明命中的规则,对端保持原样。
126
+ - 撤回一次批准:{ delivery_kind: "撤销信任握手", msg: "撤销这个对端", meta: { op: "trust_revoke", node_id: "<node>" } }。撤销永远不受策略限制,也不需要指纹。
127
+ - 与一个已发现但尚未配对的对端发起交换:{ delivery_kind: "与 lazuli 开始握手", msg: "开始交换", meta: { op: "trust_begin", node_id: "lazuli" } }。获取策略会在发出任何请求之前判定:被拒时错误里写明命中的规则,对端保持原样;放行时提交本地批准、发出陈述,并报告其后的信任阶段。
128
+ - 把一个对端加进一段对话,让这个 thread 带多个成员:{ delivery_kind: "把 dpdp 加进 plan thread", msg: "加上 dpdp", meta: { op: "member_add", thread: "<thread id 或标题>", to: "dpdp" } };meta.to 是列表时每个成员各一次调用。用 op "member_remove" 移出。加入成员就是把他放进那个 thread,所以之后带 meta.thread 的 meta.to 数组只指向一个 thread,不带 meta.thread 时每个目标各得一个。
129
+ - 发送失败且没有可信密钥时,不要重试:错误里写明了两个节点;待确认的邀请由获取策略或那一侧的持有者确认,再发一次不会。报告它,然后停下。
130
+
131
+ 动作和执行计划由 judge 角色根据你的意图选择,所以永远不要自己编 message id、幂等键或版本号。引用消息一律写成 {thread, seq};当一次调用既没有引用也没有 relation、而本上下文还有未答复的入站消息时,由 judge 判断它在回答哪一条。回信只针对原来的回信权限方,所以冲突的 to 会被拒绝。子代理会话读自己的切片(onlyne://threads、onlyne://inbox、onlyne://contexts),把结果交回主会话,由主会话发送。`,
111
132
  "summary.no_result": "Onlyne 没有返回结构化结果。",
112
- "summary.where": ",投递给 {owner}",
113
- "summary.thread_ref": "(thread {thread} seq {seq}{answering})",
133
+ "summary.addressee": "已发给 {to}",
134
+ "summary.addressee_self": "已发给自己",
135
+ "summary.stored_here": "thread {thread} 建在你这侧,seq {seq}{answering}",
136
+ "summary.stored_there": "thread {thread} 建在 {owner} 那侧,seq {seq}{answering}",
114
137
  "summary.answering": ",回复 {sender}/{id}",
115
- "summary.outbound": ";读回: onlyne://outbound/{id}",
116
- "summary.delivered": "已送达{where}{facts},并已持久记录{trail}。",
117
- "summary.delivered_answered": "已送达{where}{facts},并已持久记录{trail}。{author} 在 seq {seq} 回复了。",
118
- "summary.delivered_withheld": "已送达{where}{facts},并已持久记录{trail}。{author} 在 seq {seq} 回复了,但那段正文已经在这个上下文里展示过,所以只报告它到达。",
119
- "summary.delivered_timed_out": "已送达{where}{facts},并已持久记录{trail}。等待期内没有回信;回信会作为入站消息自己到达。",
120
- "summary.delivered_cancelled": "已送达{where}{facts},并已持久记录{trail}。等待被取消;回信会作为入站消息自己到达。",
138
+ "summary.sep": ";",
139
+ "summary.resolved": "resolved: {node}",
140
+ "summary.outbound": "读回: onlyne://outbound/{id}",
141
+ "summary.grant_notice": ";条目留在你这里是因为 thread 由你持有,{to} 收到 grant 通知后作为成员读它",
142
+ "summary.delivered": "{facts},并已持久记录{mechanism}。",
143
+ "summary.delivered_answered": "{facts},并已持久记录{mechanism}。{author} 在 seq {seq} 回复了。",
144
+ "summary.delivered_withheld": "{facts},并已持久记录{mechanism}。{author} 在 seq {seq} 回复了,但那段正文已经在这个上下文里展示过,所以只报告它到达。",
145
+ "summary.delivered_timed_out": "{facts},并已持久记录{mechanism}。等待期内没有回信;回信会作为入站消息自己到达。",
146
+ "summary.delivered_cancelled": "{facts},并已持久记录{mechanism}。等待被取消;回信会作为入站消息自己到达。",
121
147
  "summary.not_confirmed": "未确认:{failure}。{next}",
122
148
  "summary.retry": "记录保持打开,运行时自己重试;再发一次会提交第二个意图。",
123
149
  "summary.recover": "记录保持打开,等待恢复;不要再发同一个意图。",
@@ -128,6 +154,14 @@ meta.to 接受一个节点或一组节点。一组会逐个目标投递,每个
128
154
  "draft.ready": "草稿 {draft_id} 已就绪;主会话用 meta.draft_id 发出它。",
129
155
  "multi.delivered": "→ {to}: {summary}",
130
156
  "multi.failed": "→ {to}: 未发送 —— {error}",
157
+ "trust.accepted": "已批准 {node}({fingerprint}),信任阶段现在是 {phase}。",
158
+ "trust.revoked": "已撤销 {node};本地批准已撤回。",
159
+ "trust.begun": "已与 {node}({fingerprint})发起交换,信任阶段现在是 {phase}。本地批准已提交、陈述已发出,等对端反向批准后即可互通消息。",
160
+ "thread.member_added": "已把 {member} 加入 thread {thread}。该成员现在可以读写这个 thread;之后带 meta.thread {thread} 的 meta.to 数组会把每个目标都放进这一个 thread,而不带 meta.thread 的同一数组会让每个目标各得一个 thread。",
161
+ "thread.member_removed": "已把 {member} 移出 thread {thread}。thread 本身保留、此前的条目仍是它的历史;之后单独发给 {member} 的消息会另开一个两人 thread。",
162
+ "failure.trust_node_unknown": "{action} 没有指名任何已知节点 {node}。写节点 id,或写只有一个已知对端持有的短别名。",
163
+ "failure.trust_fields": "信任动作需要非空字符串字段 {field}。",
164
+ "failure.trust_main_only": "只有主会话可以批准或撤销对端;子代理只能起草。",
131
165
  };
132
166
 
133
167
  export const toolText = createTranslator({ en, "zh-CN": zh });