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/tool.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import { type ExtensionAPI, type ExtensionContext, type SessionEntry, z } from "@oh-my-pi/pi-coding-agent";
3
- import type { Json, MsgInput, SendOutcome } from "@onlyne/lib";
3
+ import { OnlyneError, trustPhase, type Json, type MsgInput, type SendOutcome } from "@onlyne/lib";
4
4
  import { runDecision } from "./decider.ts";
5
5
  import { createDraft, sendDraft } from "./drafts.ts";
6
6
  import { observeToolResult } from "./messages.ts";
7
- import { getRuntime } from "./runtime.ts";
7
+ import { getRuntime, type PluginRuntime } from "./runtime.ts";
8
8
  import { toolText } from "./tool-text.ts";
9
9
 
10
10
  /** Custom entry naming a reply an `await` returned, left pending for the host bridge to confirm. */
@@ -57,10 +57,33 @@ export function registerMsgTool(pi: ExtensionAPI): void {
57
57
  const draft = result as { draft_id: string };
58
58
  return { content: [{ type: "text", text: toolText("draft.ready", { draft_id: draft.draft_id }) }], details: { result } };
59
59
  }
60
+ // RFC 0018 §3.1: a trust action is mechanical — no judging, and no action kind
61
+ // invented for it, because the library executes exactly five and a trust action is
62
+ // not one of them (client.ts admits send, read, thread, resource and domain).
63
+ if (op === "trust_accept" || op === "trust_revoke" || op === "trust_begin") {
64
+ if (ctx.agent.kind === "sub") throw new OnlyneError("forbidden", toolText("failure.trust_main_only"));
65
+ const node = await trustNode(runtime, op, parsed.data.meta);
66
+ if (op === "trust_revoke") {
67
+ await runtime.acquireRevoke(node);
68
+ return { content: [{ type: "text", text: toolText("trust.revoked", { node }) }], details: { result: { op, node_id: node } } };
69
+ }
70
+ if (op === "trust_begin") {
71
+ const entry = await runtime.acquireBegin(node);
72
+ return {
73
+ content: [{ type: "text", text: toolText("trust.begun", { node, fingerprint: entry.fingerprint, phase: trustPhase(entry) }) }],
74
+ details: { result: entry },
75
+ };
76
+ }
77
+ const entry = await runtime.acquireAccept(node, trustField(parsed.data.meta, "fingerprint"));
78
+ return {
79
+ content: [{ type: "text", text: toolText("trust.accepted", { node, fingerprint: entry.fingerprint, phase: trustPhase(entry) }) }],
80
+ details: { result: entry },
81
+ };
82
+ }
60
83
  if (typeof parsed.data.meta?.draft_id === "string") {
61
84
  const result = await sendDraft(runtime, ctx, parsed.data.meta.draft_id, signal);
62
85
  recordAwaitResult(pi, toolCallId, result, ctx);
63
- return { content: [{ type: "text", text: `${summary(result)}\n\n${JSON.stringify(visibleResult(result), null, 2)}` }], details: { result: visibleResult(result) } };
86
+ return { content: [{ type: "text", text: `${summary(result, spelling(parsed.data.meta?.to))}\n\n${JSON.stringify(visibleResult(result), null, 2)}` }], details: { result: visibleResult(result) } };
64
87
  }
65
88
  const targets = parsed.data.meta?.to;
66
89
  if (Array.isArray(targets)) {
@@ -78,7 +101,7 @@ export function registerMsgTool(pi: ExtensionAPI): void {
78
101
  }
79
102
  }
80
103
  const text = rows.map(row => row.error === undefined
81
- ? toolText("multi.delivered", { to: String(row.to), summary: summary(row.outcome) })
104
+ ? toolText("multi.delivered", { to: String(row.to), summary: summary(row.outcome, spelling(row.to)) })
82
105
  : toolText("multi.failed", { to: String(row.to), error: row.error })).join("\n");
83
106
  pi.logger.info("onlyne.msg.multi", { targets: rows.length, failed: rows.filter(row => row.error !== undefined).length });
84
107
  return { content: [{ type: "text", text: `${text}\n\n${JSON.stringify(rows, null, 2)}` }], details: { result: rows } };
@@ -92,8 +115,11 @@ export function registerMsgTool(pi: ExtensionAPI): void {
92
115
  recordAwaitResult(pi, toolCallId, result, ctx);
93
116
  const visible = visibleResult(result);
94
117
  pi.logger.info("onlyne.msg.result", { kind: input.delivery_kind, ms: Date.now() - started, ...outcomeFacts(result) });
118
+ const text = op === "member_add" || op === "member_remove"
119
+ ? membershipText(op, visible, parsed.data.meta)
120
+ : summary(result, spelling(parsed.data.meta?.to));
95
121
  return {
96
- content: [{ type: "text", text: `${summary(result)}\n\n${JSON.stringify(visible, null, 2)}` }],
122
+ content: [{ type: "text", text: `${text}\n\n${JSON.stringify(visible, null, 2)}` }],
97
123
  details: { result: visible },
98
124
  };
99
125
  } catch (error) {
@@ -112,22 +138,47 @@ export function registerMsgTool(pi: ExtensionAPI): void {
112
138
  * `wait: timed_out` beside `admission: accepted` reads as a contradiction until the
113
139
  * reader knows the contract — so the delivery fact is stated first and the JSON below
114
140
  * it is for the rare case that needs a machine-readable detail.
141
+ *
142
+ * `addressed` is the target as the caller wrote it. A receipt names the thread's owner, which
143
+ * is this node whenever it started the conversation, so leading with it made a message to a
144
+ * peer read as a message to itself; the addressee comes first and the storage second.
115
145
  */
116
- export function summary(result: unknown): string {
146
+ export function summary(result: unknown, addressed?: string): string {
117
147
  if (result === null || typeof result !== "object") return toolText("summary.no_result");
118
148
  const outcome = result as Partial<SendOutcome>;
119
- const owner = outcome.receipt?.thread_ref?.owner ?? outcome.reply?.thread_ref.owner;
120
- const where = owner === undefined ? "" : toolText("summary.where", { owner });
149
+ const receipt = outcome.receipt;
150
+ // The message's own sender is this node: `send` refuses a message another node authors.
151
+ const self = receipt?.message_ref.sender;
152
+ const to = outcome.resolved ?? outcome.requested ?? addressed;
121
153
  // The answer is part of the delivery fact: a reply the receipt cannot attribute is one the
122
154
  // sender cannot prove landed on the message it meant (cyanea report 2, problem 2).
123
- const answers = outcome.receipt?.in_reply_to;
155
+ const answers = receipt?.in_reply_to;
124
156
  const answering = answers === undefined ? "" : toolText("summary.answering", { sender: answers.sender, id: answers.id });
125
- const facts = outcome.receipt === undefined
126
- ? ""
127
- : toolText("summary.thread_ref", { thread: outcome.receipt.thread_ref.id, seq: outcome.receipt.seq, answering });
128
- // The receipt names a thread the receiver owns, which is not a local object; the outbound
129
- // record is. Point the reader at the handle that opens (dpdp report, problem 1).
130
- const trail = outcome.receipt === undefined ? "" : toolText("summary.outbound", { id: outcome.receipt.message_ref.id });
157
+ // The receipt names the thread owner, which is this node when it started the conversation;
158
+ // a peer's thread is stored at the peer, so the two cases are stated apart.
159
+ const storedHere = receipt !== undefined && receipt.thread_ref.owner === self;
160
+ const storage = receipt === undefined ? "" : toolText(storedHere ? "summary.stored_here" : "summary.stored_there", {
161
+ owner: receipt.thread_ref.owner, thread: receipt.thread_ref.id, seq: receipt.seq, answering,
162
+ });
163
+ const facts = [
164
+ // The self case is the one the reader has to be told about: this node's own id reads as a
165
+ // peer name, and the thread is hosted here either way.
166
+ to === undefined ? "" : toolText(to === self ? "summary.addressee_self" : "summary.addressee", { to }),
167
+ storage,
168
+ // The resolved addressee is what keeps an alias send from reading as a self-send.
169
+ outcome.resolved === undefined || outcome.resolved === outcome.requested ? "" : toolText("summary.resolved", { node: outcome.resolved }),
170
+ // The receipt names a thread the receiver owns, which is not a local object; the outbound
171
+ // record is. Point the reader at the handle that opens (dpdp report, problem 1).
172
+ receipt === undefined ? "" : toolText("summary.outbound", { id: receipt.message_ref.id }),
173
+ ].filter(part => part.length > 0).join(toolText("summary.sep"));
174
+ // A thread this node hosts and a peer it addressed: the entry sits here because this node
175
+ // hosts the thread, so the sentence names the addressee that reads it. Without that, "hosted
176
+ // on your node" beside "Sent to <peer>" reads as a message this node wrote to itself.
177
+ const mechanism = storedHere && to !== undefined && to !== self
178
+ ? toolText("summary.grant_notice", { to })
179
+ : "";
180
+ // With no addressee the sentence opens on the storage fact, so it is capitalized like a lead.
181
+ const body = to === undefined && facts.length > 0 ? facts.charAt(0).toUpperCase() + facts.slice(1) : facts;
131
182
  if (outcome.admission !== undefined) {
132
183
  if (outcome.admission !== "accepted") {
133
184
  const error = outcome.error;
@@ -139,23 +190,31 @@ export function summary(result: unknown): string {
139
190
  : toolText("summary.recover");
140
191
  return toolText("summary.not_confirmed", { failure, next });
141
192
  }
193
+ // No receipt means no addressee and no storage to state; the JSON below carries the rest.
194
+ if (body.length === 0) return toolText("summary.plain");
195
+ const vars = { facts: body, mechanism };
142
196
  if (outcome.wait === "answered" && outcome.reply !== undefined) {
143
- const answer = { author: outcome.reply.authenticated_author, seq: outcome.reply.seq, where, facts, trail };
197
+ const answer = { ...vars, author: outcome.reply.authenticated_author, seq: outcome.reply.seq };
144
198
  return outcome.reply_body === "withheld"
145
199
  ? toolText("summary.delivered_withheld", answer)
146
200
  : toolText("summary.delivered_answered", answer);
147
201
  }
148
202
  if (outcome.wait === "timed_out") {
149
- return toolText("summary.delivered_timed_out", { where, facts, trail });
203
+ return toolText("summary.delivered_timed_out", vars);
150
204
  }
151
205
  if (outcome.wait === "cancelled") {
152
- return toolText("summary.delivered_cancelled", { where, facts, trail });
206
+ return toolText("summary.delivered_cancelled", vars);
153
207
  }
154
- return toolText("summary.delivered", { where, facts, trail });
208
+ return toolText("summary.delivered", vars);
155
209
  }
156
210
  return toolText("summary.plain");
157
211
  }
158
212
 
213
+ /** The caller's spelling of one target, when it is a plain name rather than a structured ref. */
214
+ function spelling(value: unknown): string | undefined {
215
+ return typeof value === "string" && value.length > 0 ? value : undefined;
216
+ }
217
+
159
218
  /** The facts one `msg` call produced, without the reply body. */
160
219
  function outcomeFacts(result: unknown): Record<string, unknown> {
161
220
  if (result === null || typeof result !== "object") return {};
@@ -234,3 +293,50 @@ function failure(text: string): AgentToolResult<{ result: unknown }> {
234
293
  isError: true,
235
294
  };
236
295
  }
296
+
297
+ /**
298
+ * RFC 0018 §3.1: a trust op names its peer mechanically. The fields are read before any
299
+ * judgement or storage write, so a malformed call is a caller error, never a policy answer.
300
+ */
301
+ function trustField(meta: Record<string, unknown> | undefined, field: string): string {
302
+ const value = meta?.[field];
303
+ if (typeof value !== "string" || value.length === 0) throw new OnlyneError("invalid_request", toolText("failure.trust_fields", { field }));
304
+ return value;
305
+ }
306
+
307
+ /**
308
+ * RFC 0018 §3.1: the peer a trust op names, resolved through the same peer set a send resolves
309
+ * `meta.to` from. An ambiguous alias names its matches; an unknown one is `not_found` and says
310
+ * which action asked, because the same alias may be valid for another one.
311
+ */
312
+ async function trustNode(runtime: PluginRuntime, op: string, meta: Record<string, unknown> | undefined): Promise<string> {
313
+ const node = trustField(meta, "node_id");
314
+ try {
315
+ return await runtime.decider.peerId(node);
316
+ } catch (error) {
317
+ if (error instanceof OnlyneError && error.error.code === "not_found") {
318
+ throw new OnlyneError("not_found", toolText("failure.trust_node_unknown", { action: op, node }), error.error.details);
319
+ }
320
+ throw error;
321
+ }
322
+ }
323
+
324
+ /** The sentence one membership op earns: the member, the thread, and where a later array send lands. */
325
+ function membershipText(op: "member_add" | "member_remove", result: unknown, meta: Record<string, unknown> | undefined): string {
326
+ const named = result !== null && typeof result === "object" && "peer" in result && typeof result.peer === "string"
327
+ ? result.peer
328
+ : threadLabel(meta?.to);
329
+ return toolText(op === "member_add" ? "thread.member_added" : "thread.member_removed", {
330
+ member: named,
331
+ thread: threadLabel(meta?.thread),
332
+ });
333
+ }
334
+
335
+ /** A reference as the caller wrote it: one name, or `owner/id` for the record form. */
336
+ function threadLabel(value: unknown): string {
337
+ if (typeof value === "string") return value;
338
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
339
+ const owner = "owner" in value ? value.owner : undefined;
340
+ const id = "id" in value ? value.id : undefined;
341
+ return typeof owner === "string" && typeof id === "string" ? `${owner}/${id}` : "";
342
+ }
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { MessageRef, NodeId, ThreadRef, TrustPhase, TrustSeed } from "@onlyne/lib";
2
2
  import type { Locale } from "@onlyne/node-host/i18n";
3
+ import type { AcquisitionPolicy } from "./acquire.ts";
3
4
 
4
5
  /**
5
6
  * Project-scoped configuration in `<project>/.onlyne/config.toml`.
@@ -20,6 +21,12 @@ export interface OnlyneConfig {
20
21
  };
21
22
  server: {
22
23
  bind: string;
24
+ /**
25
+ * The port this node answers on. Absent asks the operating system for one, and the
26
+ * chosen port is written back here: a node whose address changes on every start
27
+ * leaves every peer that recorded it calling a port nobody serves.
28
+ */
29
+ port?: number;
23
30
  /** The externally reachable base URL when a reverse proxy fronts the node. */
24
31
  public_url?: string;
25
32
  };
@@ -32,6 +39,8 @@ export interface OnlyneConfig {
32
39
  };
33
40
  /** Pinned peer fingerprints; an arriving key that matches is trusted without a prompt. */
34
41
  trust?: TrustSeed[];
42
+ /** RFC 0018 §2: peers this node may approve without a human confirming first. */
43
+ acquisition?: AcquisitionPolicy;
35
44
  }
36
45
 
37
46
  /** One execution context the plugin registered for a delivered message. */
@@ -79,6 +88,11 @@ export interface ViewerThread {
79
88
  unread: number;
80
89
  last_seq: string;
81
90
  messages: ViewerMessage[];
91
+ /**
92
+ * A thread another node hosts: this node was invited into it, so the viewer can show it and
93
+ * cannot offer the writes that belong to its owner.
94
+ */
95
+ read_only?: boolean;
82
96
  }
83
97
 
84
98
  /** One discovery record row the viewer lists as an automatic-discovery candidate. */
@@ -172,10 +172,15 @@ export class OnlyneBootstrap implements Component {
172
172
  // The form does not edit these; saving must not erase them.
173
173
  ...(previous?.node.key_ref === undefined ? {} : { key_ref: previous.node.key_ref }),
174
174
  },
175
- server: { bind, ...(previous?.server.public_url === undefined ? {} : { public_url: previous.server.public_url }) },
175
+ server: {
176
+ bind,
177
+ ...(previous?.server.port === undefined ? {} : { port: previous.server.port }),
178
+ ...(previous?.server.public_url === undefined ? {} : { public_url: previous.server.public_url }),
179
+ },
176
180
  threads: { page_size: Number(this.value("pageSize")) },
177
181
  ...(seeds.length === 0 ? {} : { discovery: { seeds } }),
178
182
  ...(previous?.trust === undefined ? {} : { trust: previous.trust }),
183
+ ...(previous?.acquisition === undefined ? {} : { acquisition: previous.acquisition }),
179
184
  ...(previous?.language === undefined ? {} : { language: previous.language }),
180
185
  };
181
186
  this.saving = true;
@@ -24,6 +24,7 @@ const en = {
24
24
 
25
25
  "send.blocked.empty": "Type a message first.",
26
26
  "send.blocked.noTarget": "Open a thread first, or pick a recipient.",
27
+ "send.blocked.readOnly": "This thread is read-only here: its owner holds it, so send from a node that owns it.",
27
28
 
28
29
  "rail.domain.title": "{label} · nodes {count}",
29
30
  "rail.empty": "no domains",
@@ -36,6 +37,7 @@ const en = {
36
37
  "sidebar.inbox": "✉ Inbox",
37
38
  "sidebar.noKey": "no node key",
38
39
  "thread.closed": "closed",
40
+ "thread.readOnly": "read-only",
39
41
 
40
42
  "key.missing": "this node has no key",
41
43
  "approvals.head": "{node} asks to exchange keys with you",
@@ -173,6 +175,7 @@ const zh: Record<ViewerTextKey, string> = {
173
175
 
174
176
  "send.blocked.empty": "输入内容后发送",
175
177
  "send.blocked.noTarget": "先打开线程,或选择收件人",
178
+ "send.blocked.readOnly": "这个线程在此只读:它由所有者托管,请从拥有它的节点发送。",
176
179
 
177
180
  "rail.domain.title": "{label} · {count} 节点",
178
181
  "rail.empty": "无域",
@@ -185,6 +188,7 @@ const zh: Record<ViewerTextKey, string> = {
185
188
  "sidebar.inbox": "✉ 收件箱",
186
189
  "sidebar.noKey": "无节点密钥",
187
190
  "thread.closed": "已关闭",
191
+ "thread.readOnly": "只读",
188
192
 
189
193
  "key.missing": "本节点没有密钥",
190
194
  "approvals.head": "{node} 请求与你交换密钥",
package/src/ui/viewer.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from "@oh-my-pi/pi-tui";
18
18
  import { normalizeServiceUrl, shortNodeId } from "../config.ts";
19
19
  import { getRuntime } from "../runtime.ts";
20
+ import { runDecision } from "../decider.ts";
20
21
  import type { OnlyneStatus, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "../types.ts";
21
22
  import { ageText, clockText, fit, oneLine, shortAlias, sinceText } from "./format.ts";
22
23
  import { type ViewerTextKey, t } from "./viewer-text.ts";
@@ -337,7 +338,11 @@ export class OnlyneViewer implements Component {
337
338
  private sendBlocked(): string | undefined {
338
339
  if (this.error !== undefined) return this.error;
339
340
  if (this.draft.trim() === "") return t("send.blocked.empty");
340
- if (this.target === undefined && this.replyTo === undefined && this.selectedThread() === undefined) {
341
+ const thread = this.selectedThread();
342
+ // A joined thread is another node's: the viewer shows it and offers no write it cannot
343
+ // perform. A manual recipient starts a new message and is unaffected.
344
+ if (this.target === undefined && thread?.read_only === true) return t("send.blocked.readOnly");
345
+ if (this.target === undefined && this.replyTo === undefined && thread === undefined) {
341
346
  return t("send.blocked.noTarget");
342
347
  }
343
348
  return undefined;
@@ -479,7 +484,11 @@ export class OnlyneViewer implements Component {
479
484
  node(
480
485
  "item",
481
486
  {
482
- label: [span(`# ${thread.title}`, open ? "strong" : undefined), ...(thread.state === "closed" ? [span(` ${t("thread.closed")}`, "dim")] : [])],
487
+ label: [
488
+ span(`# ${thread.title}`, open ? "strong" : undefined),
489
+ ...(thread.state === "closed" ? [span(` ${t("thread.closed")}`, "dim")] : []),
490
+ ...(thread.read_only === true ? [span(` ${t("thread.readOnly")}`, "dim")] : []),
491
+ ],
483
492
  // One number per row, always the same number: the unread count. A row that
484
493
  // showed the message total once everything was read made two rows of equal
485
494
  // width mean different things.
@@ -753,7 +762,11 @@ export class OnlyneViewer implements Component {
753
762
  children.push(
754
763
  node("row", { role: "onlyne.thread-head", gap: "md", align: "center", justify: "between" }, [
755
764
  node("col", { gap: "none", grow: 1 }, [
756
- textNode([span(`# ${thread.title}`, "strong"), ...(thread.state === "closed" ? [span(` · ${t("thread.closed")}`, "dim")] : [])], { truncate: "end" }),
765
+ textNode([
766
+ span(`# ${thread.title}`, "strong"),
767
+ ...(thread.state === "closed" ? [span(` · ${t("thread.closed")}`, "dim")] : []),
768
+ ...(thread.read_only === true ? [span(` · ${t("thread.readOnly")}`, "dim")] : []),
769
+ ], { truncate: "end" }),
757
770
  textNode([span(t("thread.meta", {
758
771
  messages: messages.length,
759
772
  speakers: new Set(messages.map(message => message.author)).size,
@@ -1282,7 +1295,11 @@ export class OnlyneViewer implements Component {
1282
1295
  this.sending = true;
1283
1296
  this.changed();
1284
1297
  try {
1285
- const outcome = (await runtime.node.client.msg(input, { context: runtime.mainAgentId })) as SendOutcome;
1298
+ // The library decides every send through the plugin, and the plugin only decides inside
1299
+ // a bound scope: without one the composer reported a send failure (audit §2.3). The
1300
+ // panel is the main session's input, so it carries the main session's scope.
1301
+ const outcome = (await runDecision({ ctx: runtime.main, input }, () =>
1302
+ runtime.node.client.msg(input, { context: runtime.mainAgentId }))) as SendOutcome;
1286
1303
  this.setNotice(this.describeOutcome(outcome), outcome.admission === "refused" || outcome.error !== undefined);
1287
1304
  if (outcome.admission === "accepted" || outcome.admission === "unknown") {
1288
1305
  this.draft = "";
package/src/urls-text.ts CHANGED
@@ -28,6 +28,8 @@ export const en = {
28
28
  rootThreads: "Threads: {count}",
29
29
  rootThreadRow: "- **{title}** `{key}` · {state} · next seq {next}",
30
30
  rootNoThreads: "No threads.",
31
+ rootJoinedThreads: "Joined threads (read-only): {count}",
32
+ rootThreadJoinedRow: "- **{title}** `{key}` · read-only · last seq {last}",
31
33
  rootViews: "Views: onlyne://neighbors, onlyne://threads, onlyne://inbox, onlyne://contexts, onlyne://outbound, onlyne://conversations",
32
34
  rootViewsResources: "Views: onlyne://neighbors, onlyne://threads, onlyne://inbox, onlyne://contexts, onlyne://outbound, onlyne://conversations, onlyne://resources",
33
35
  rootConversation: "Conversation: onlyne://conversations/<encoded-sender>/<encoded-id> · message: onlyne://messages/<encoded-sender>/<encoded-id>",
@@ -35,6 +37,9 @@ export const en = {
35
37
 
36
38
  threadsTitle: "# Threads · {node}",
37
39
  threadRow: "- **{title}** `{key}` · {state} · next seq {next} · {count} messages",
40
+ threadRowJoined: "- **{title}** `{key}` · read-only · last seq {last} · {count} messages",
41
+ threadJoinedLinks: " thread {thread} · read-only, hosted by {owner}",
42
+ joinedThreadTitle: "Thread {id} (hosted by {owner})",
38
43
  threadNoHistory: " no local history yet · thread {url}",
39
44
  threadLatest: " latest {direction} · {at} · {snippet}",
40
45
  threadLinks: " thread {thread} · latest {latest} · conversation {conversation}",
@@ -44,6 +49,10 @@ export const en = {
44
49
  noMessageAt: "No message at {key}/{seq}. onlyne:// lists the views.",
45
50
  threadTitle: "# {title}",
46
51
  threadMeta: "Thread `{key}` · {state} · next seq {next} · {count} messages",
52
+ threadReadOnly: "Read-only: `{owner}` hosts this thread; its state and members belong to the owner.",
53
+ threadStateUnknown: "state unknown",
54
+ ownerUnreachable: "the owner {owner} did not answer",
55
+ ownerHeld: "owner record",
47
56
  members: "## Members",
48
57
  memberRow: "- `{peer}` · {state} · {access}",
49
58
  noAccess: "no access",
@@ -167,6 +176,8 @@ export const zh: Record<keyof typeof en, string> = {
167
176
  rootThreads: "线程:{count}",
168
177
  rootThreadRow: "- **{title}** `{key}` · {state} · 下一序号 {next}",
169
178
  rootNoThreads: "没有线程。",
179
+ rootJoinedThreads: "受邀线程(只读):{count}",
180
+ rootThreadJoinedRow: "- **{title}** `{key}` · 只读 · 最后序号 {last}",
170
181
  rootViews: "视图:onlyne://neighbors、onlyne://threads、onlyne://inbox、onlyne://contexts、onlyne://outbound、onlyne://conversations",
171
182
  rootViewsResources: "视图:onlyne://neighbors、onlyne://threads、onlyne://inbox、onlyne://contexts、onlyne://outbound、onlyne://conversations、onlyne://resources",
172
183
  rootConversation: "对话:onlyne://conversations/<encoded-sender>/<encoded-id> · 消息:onlyne://messages/<encoded-sender>/<encoded-id>",
@@ -174,6 +185,9 @@ export const zh: Record<keyof typeof en, string> = {
174
185
 
175
186
  threadsTitle: "# 线程 · {node}",
176
187
  threadRow: "- **{title}** `{key}` · {state} · 下一序号 {next} · {count} 条消息",
188
+ threadRowJoined: "- **{title}** `{key}` · 只读 · 最后序号 {last} · {count} 条消息",
189
+ threadJoinedLinks: " 线程 {thread} · 只读,由 {owner} 托管",
190
+ joinedThreadTitle: "线程 {id}(由 {owner} 托管)",
177
191
  threadNoHistory: " 尚无本地历史 · 线程 {url}",
178
192
  threadLatest: " 最新 {direction} · {at} · {snippet}",
179
193
  threadLinks: " 线程 {thread} · 最新 {latest} · 对话 {conversation}",
@@ -183,6 +197,10 @@ export const zh: Record<keyof typeof en, string> = {
183
197
  noMessageAt: "{key}/{seq} 处没有消息。onlyne:// 列出了所有视图。",
184
198
  threadTitle: "# {title}",
185
199
  threadMeta: "线程 `{key}` · {state} · 下一序号 {next} · {count} 条消息",
200
+ threadReadOnly: "只读:该线程由 `{owner}` 托管,状态与成员属于所有者。",
201
+ threadStateUnknown: "状态未知",
202
+ ownerUnreachable: "所有者 {owner} 没有应答",
203
+ ownerHeld: "所有者记录",
186
204
  members: "## 成员",
187
205
  memberRow: "- `{peer}` · {state} · {access}",
188
206
  noAccess: "无访问权限",