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/README.md +13 -5
- package/package.json +3 -3
- package/src/acquire.ts +182 -0
- package/src/bridge-text.ts +36 -0
- package/src/config.ts +35 -3
- package/src/decider-text.ts +33 -15
- package/src/decider.ts +306 -55
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +10 -4
- package/src/messages.ts +110 -10
- package/src/node.ts +15 -3
- package/src/runtime.ts +268 -8
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +125 -19
- package/src/types.ts +14 -0
- package/src/ui/bootstrap.ts +6 -1
- package/src/ui/viewer-text.ts +4 -0
- package/src/ui/viewer.ts +21 -4
- package/src/urls-text.ts +18 -0
- package/src/urls.ts +170 -29
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
|
|
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: `${
|
|
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
|
|
120
|
-
|
|
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 =
|
|
155
|
+
const answers = receipt?.in_reply_to;
|
|
124
156
|
const answering = answers === undefined ? "" : toolText("summary.answering", { sender: answers.sender, id: answers.id });
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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",
|
|
203
|
+
return toolText("summary.delivered_timed_out", vars);
|
|
150
204
|
}
|
|
151
205
|
if (outcome.wait === "cancelled") {
|
|
152
|
-
return toolText("summary.delivered_cancelled",
|
|
206
|
+
return toolText("summary.delivered_cancelled", vars);
|
|
153
207
|
}
|
|
154
|
-
return toolText("summary.delivered",
|
|
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. */
|
package/src/ui/bootstrap.ts
CHANGED
|
@@ -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: {
|
|
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;
|
package/src/ui/viewer-text.ts
CHANGED
|
@@ -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
|
-
|
|
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: [
|
|
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([
|
|
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
|
-
|
|
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: "无访问权限",
|