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/urls.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  type Entry,
6
6
  type HistoryMessage,
7
7
  type HistoryQuery,
8
+ type JoinedThread,
8
9
  type LocalAttention,
9
10
  type MessageRef,
10
11
  type OperationRecord,
@@ -15,6 +16,7 @@ import {
15
16
  type ResourceInventory,
16
17
  type ResourceRef,
17
18
  type Thread,
19
+ type ThreadDescription,
18
20
  type ThreadRef,
19
21
  type TrustPhase,
20
22
  } from "@onlyne/lib";
@@ -29,6 +31,7 @@ import {
29
31
  } from "@oh-my-pi/pi-coding-agent/internal-urls";
30
32
  import { resourceQuery } from "./resources.ts";
31
33
  import { getRuntime, type PluginRuntime } from "./runtime.ts";
34
+ import { isTrustThread, trustThread } from "./handshake.ts";
32
35
  import type { ContextRecord } from "./types.ts";
33
36
  import { urlsText } from "./urls-text.ts";
34
37
 
@@ -79,10 +82,16 @@ const handler: ProtocolHandler = {
79
82
  for (const thread of await visibleThreads(runtime, caller)) {
80
83
  urls.push(threadPath(thread.thread_ref));
81
84
  }
85
+ for (const row of await joinedThreads(runtime, caller)) {
86
+ urls.push(threadPath(row.thread_ref));
87
+ }
82
88
  return leafDocuments(runtime, caller, urls);
83
89
  }
84
90
  if (view.host === "threads" && view.parts.length === 0) {
85
- const urls = (await visibleThreads(runtime, caller)).map(thread => threadPath(thread.thread_ref));
91
+ const urls = [
92
+ ...(await visibleThreads(runtime, caller)).map(thread => threadPath(thread.thread_ref)),
93
+ ...(await joinedThreads(runtime, caller)).map(row => threadPath(row.thread_ref)),
94
+ ];
86
95
  return leafDocuments(runtime, caller, urls);
87
96
  }
88
97
  if (view.host === "threads") {
@@ -92,7 +101,7 @@ const handler: ProtocolHandler = {
92
101
  const entry = await entryAt(runtime, thread, target.seq);
93
102
  return [{ url: entryPath(thread.thread_ref, target.seq), content: `${JSON.stringify(entry, null, 2)}\n` }];
94
103
  }
95
- const entries = await allMessages(runtime, thread.thread_ref);
104
+ const entries = await threadEntries(runtime, thread.thread_ref);
96
105
  return entries.map(entry => ({
97
106
  url: entryPath(thread.thread_ref, entry.seq),
98
107
  content: JSON.stringify(entry, null, 2),
@@ -233,6 +242,7 @@ async function renderRoot(runtime: PluginRuntime, caller: Caller): Promise<strin
233
242
  }
234
243
  const records = await scopedRecords(runtime, caller);
235
244
  const threads = await visibleThreads(runtime, caller);
245
+ const joined = await joinedThreads(runtime, caller);
236
246
  lines.push("", caller.main
237
247
  ? t("rootContexts", { count: records.length })
238
248
  : t("rootContextsHeld", { agent: caller.agent_id ?? "", count: records.length }));
@@ -251,6 +261,16 @@ async function renderRoot(runtime: PluginRuntime, caller: Caller): Promise<strin
251
261
  }));
252
262
  }
253
263
  if (threads.length === 0) lines.push(t("rootNoThreads"));
264
+ if (joined.length > 0) {
265
+ lines.push("", t("rootJoinedThreads", { count: joined.length }));
266
+ for (const row of joined) {
267
+ lines.push(t("rootThreadJoinedRow", {
268
+ title: row.title ?? t("joinedThreadTitle", { id: row.thread_ref.id, owner: row.thread_ref.owner }),
269
+ key: threadKey(row.thread_ref),
270
+ last: row.last_seq,
271
+ }));
272
+ }
273
+ }
254
274
  lines.push(
255
275
  "",
256
276
  caller.main ? t("rootViewsResources") : t("rootViews"),
@@ -264,13 +284,30 @@ async function renderThreads(runtime: PluginRuntime, caller: Caller, view: View)
264
284
  requireSession(caller, "threads");
265
285
  if (view.parts.length === 0) {
266
286
  const request = pageRequest(view);
267
- const threads = (await visibleThreads(runtime, caller))
268
- .slice()
269
- .sort((left, right) => threadKey(left.thread_ref).localeCompare(threadKey(right.thread_ref)));
287
+ // One list of everything this node can read: a joined row is marked read-only and
288
+ // carries only what the local records prove, because its owner holds the rest. The
289
+ // merged order is the cursor's order, so it is sorted after the two sources join.
290
+ const threads: Array<Thread | JoinedThread> = [
291
+ ...(await visibleThreads(runtime, caller)),
292
+ ...(await joinedThreads(runtime, caller)),
293
+ ].sort((left, right) => threadKey(left.thread_ref).localeCompare(threadKey(right.thread_ref)));
270
294
  const index = await historyIndex(runtime, caller);
271
295
  const page = localPage(threads, request, cursorScope("threads", caller, ""), thread => threadKey(thread.thread_ref));
272
296
  const lines = [t("threadsTitle", { node: runtime.config.node.id }), ""];
273
297
  for (const thread of page.items) {
298
+ if ("read_only" in thread) {
299
+ lines.push(t("threadRowJoined", {
300
+ title: thread.title ?? t("joinedThreadTitle", { id: thread.thread_ref.id, owner: thread.thread_ref.owner }),
301
+ key: threadKey(thread.thread_ref),
302
+ last: thread.last_seq,
303
+ count: thread.message_count,
304
+ }));
305
+ lines.push(t("threadJoinedLinks", {
306
+ thread: threadPath(thread.thread_ref),
307
+ owner: thread.thread_ref.owner,
308
+ }));
309
+ continue;
310
+ }
274
311
  const rows = index.get(threadKey(thread.thread_ref)) ?? [];
275
312
  const first = rows[0];
276
313
  const latest = rows.at(-1);
@@ -327,34 +364,54 @@ function threadTarget(parts: string[]): ThreadTarget {
327
364
  return { alias: parts.join("/") };
328
365
  }
329
366
 
330
- async function entryAt(runtime: PluginRuntime, thread: Thread, seq: string): Promise<Entry> {
331
- const entry = (await allMessages(runtime, thread.thread_ref)).find(item => item.seq === seq);
367
+ async function entryAt(runtime: PluginRuntime, thread: Thread | JoinedThread, seq: string): Promise<Entry> {
368
+ const entry = (await threadEntries(runtime, thread.thread_ref)).find(item => item.seq === seq);
332
369
  if (entry === undefined) throw new Error(t("noMessageAt", { key: threadKey(thread.thread_ref), seq }));
333
370
  return entry;
334
371
  }
335
372
 
336
- async function messageView(runtime: PluginRuntime, thread: Thread, seq: string): Promise<Rendered> {
373
+ async function messageView(runtime: PluginRuntime, thread: Thread | JoinedThread, seq: string): Promise<Rendered> {
337
374
  return json(await entryAt(runtime, thread, seq));
338
375
  }
339
376
 
340
- async function renderThread(runtime: PluginRuntime, thread: Thread): Promise<string> {
341
- const rows = await threadMessages(runtime, thread.thread_ref);
342
- const title = rows.length === 0 ? thread.title : titleOf(rows[0]!);
377
+ /**
378
+ * One thread document. A hosted thread is rendered from this node's history projection; a
379
+ * joined one is read from its owner, and the page says so: the owner holds the state, the
380
+ * member table and the entries, and nothing here claims to change them.
381
+ */
382
+ async function renderThread(runtime: PluginRuntime, thread: Thread | JoinedThread): Promise<string> {
383
+ const joined = "read_only" in thread;
384
+ const ref = thread.thread_ref;
385
+ const rows = joined ? [] : await threadMessages(runtime, ref);
386
+ const entries = joined ? await threadEntries(runtime, ref) : [];
387
+ let described: ThreadDescription | undefined;
388
+ try {
389
+ described = await runtime.node.client.describeThread(ref);
390
+ } catch {
391
+ // The owner's answer is what a joined page is mostly about, so its absence is stated
392
+ // below rather than papered over with local guesses.
393
+ described = undefined;
394
+ }
395
+ const title = thread.title ?? (rows.length === 0 ? ref.id : titleOf(rows[0]!));
396
+ const next = described?.thread.next_seq ?? ("next_seq" in thread ? thread.next_seq : thread.last_seq);
397
+ const count = joined ? entries.length : rows.length;
343
398
  const lines = [
344
399
  t("threadTitle", { title }),
345
400
  "",
346
401
  t("threadMeta", {
347
- key: threadKey(thread.thread_ref),
348
- state: thread.state,
349
- next: thread.next_seq,
350
- count: rows.length,
402
+ key: threadKey(ref),
403
+ state: described?.thread.state ?? thread.state ?? t("threadStateUnknown"),
404
+ next,
405
+ count,
351
406
  }),
352
- "",
353
- t("members"),
354
- "",
355
407
  ];
356
- try {
357
- const described = await runtime.node.threads.describe(runtime.node.auth, thread.thread_ref);
408
+ if (joined) lines.push("", t("threadReadOnly", { owner: ref.owner }));
409
+ lines.push("", t("members"), "");
410
+ if (described === undefined) {
411
+ lines.push(t("unavailable", { message: t("ownerUnreachable", { owner: ref.owner }) }));
412
+ } else if (described.members.length === 0) {
413
+ lines.push(t("noMembers"));
414
+ } else {
358
415
  for (const member of described.members) {
359
416
  lines.push(t("memberRow", {
360
417
  peer: member.peer,
@@ -362,13 +419,11 @@ async function renderThread(runtime: PluginRuntime, thread: Thread): Promise<str
362
419
  access: member.access.length === 0 ? t("noAccess") : member.access.join(", "),
363
420
  }));
364
421
  }
365
- if (described.members.length === 0) lines.push(t("noMembers"));
366
- } catch (error) {
367
- lines.push(t("unavailable", { message: error instanceof Error ? error.message : String(error) }));
368
422
  }
369
423
  lines.push("", t("messages"), "");
370
- for (const row of rows) lines.push(renderHistoryRow(row, runtime.config.node.id));
371
- if (rows.length === 0) lines.push(t("noMessages"));
424
+ if (joined) for (const entry of entries) lines.push(renderEntryRow(entry, runtime.config.node.id));
425
+ else for (const row of rows) lines.push(renderHistoryRow(row, runtime.config.node.id));
426
+ if (count === 0) lines.push(t("noMessages"));
372
427
  return `${lines.join("\n")}\n`;
373
428
  }
374
429
 
@@ -732,7 +787,7 @@ async function inboxLeaves(runtime: PluginRuntime, caller: Caller): Promise<Arra
732
787
  }
733
788
  const leaves: Array<{ url: string; content: string }> = [];
734
789
  for (const group of byThread.values()) {
735
- const entries = await allMessages(runtime, group.ref);
790
+ const entries = await threadEntries(runtime, group.ref);
736
791
  for (const entry of entries) {
737
792
  if (!group.seqs.has(entry.seq)) continue;
738
793
  leaves.push({
@@ -758,19 +813,38 @@ async function visibleThreads(runtime: PluginRuntime, caller: Caller): Promise<T
758
813
  return threads.filter(thread => allowed.has(threadKey(thread.thread_ref)));
759
814
  }
760
815
 
761
- async function findThread(runtime: PluginRuntime, caller: Caller, alias: string): Promise<Thread> {
816
+ async function findThread(runtime: PluginRuntime, caller: Caller, alias: string): Promise<Thread | JoinedThread> {
762
817
  const threads = await visibleThreads(runtime, caller);
763
- const matches = threads.filter(thread =>
818
+ const matches: Array<Thread | JoinedThread> = threads.filter(thread =>
764
819
  thread.thread_ref.id === alias ||
765
820
  thread.title === alias ||
766
821
  threadKey(thread.thread_ref) === alias,
767
822
  );
823
+ // A thread this node was invited into is named the same way, but its record lives on the
824
+ // owner: the id and the `owner/id` key are what the local records can answer for.
825
+ for (const row of await joinedThreads(runtime, caller)) {
826
+ if (row.thread_ref.id === alias || row.title === alias || threadKey(row.thread_ref) === alias) matches.push(row);
827
+ }
768
828
  if (matches.length === 1) return matches[0]!;
769
829
  if (matches.length > 1) throw new Error(t("manyThreadsNamed", { alias }));
830
+ // RFC 0018 §3.2 keeps the handshake thread out of the conversation listings. An inbox row
831
+ // for a handshake entry still links straight to it, and a link that opens nothing is worse
832
+ // than a listing nobody needs: a reference that names it exactly resolves here.
833
+ const direct = caller.main ? await trustThreadNamed(runtime, alias) : undefined;
834
+ if (direct !== undefined) return direct;
770
835
  if (!caller.main && caller.agent_id !== undefined) throw new Error(t("noThreadSlice", { alias }));
771
836
  throw new Error(t("noThreadNamed", { alias }));
772
837
  }
773
838
 
839
+ /** The handshake thread, when the alias names it exactly; the listings never offer it. */
840
+ async function trustThreadNamed(runtime: PluginRuntime, alias: string): Promise<Thread | undefined> {
841
+ const ref = trustThread(runtime.config.node.id);
842
+ const names = new Set([ref.id, threadKey(ref), `${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}`]);
843
+ if (!names.has(alias)) return undefined;
844
+ const described = await runtime.node.threads.describe(runtime.node.auth, ref).catch(() => undefined);
845
+ return described?.thread;
846
+ }
847
+
774
848
  /** Contexts this caller may see: every node-local record for the main session, its own records otherwise. */
775
849
  async function scopedRecords(runtime: PluginRuntime, caller: Caller): Promise<ContextRecord[]> {
776
850
  const records = (await runtime.contexts.list()).filter(record => record.node_id === runtime.config.node.id);
@@ -811,12 +885,32 @@ function conversationPermitted(conversation: Conversation, allowed: Set<string>,
811
885
  }
812
886
 
813
887
  async function localThreads(runtime: PluginRuntime): Promise<Thread[]> {
814
- return collect(cursor =>
888
+ // RFC 0018 §3.2: the handshake thread is this host's own record, not a conversation.
889
+ const threads = await collect(cursor =>
815
890
  runtime.node.threads.list(runtime.node.auth, {
816
891
  page_size: PAGE,
817
892
  ...(cursor === undefined ? {} : { cursor }),
818
893
  }),
819
894
  );
895
+ return threads.filter(thread => !isTrustThread(runtime.config.node.id, thread.thread_ref));
896
+ }
897
+
898
+ /**
899
+ * Threads this node was invited into. They live on their owner, so the rows carry only what
900
+ * the local records prove; the owner is asked for the title and the member table where the
901
+ * detail page needs them. A subagent sees the joined threads its own records name.
902
+ */
903
+ async function joinedThreads(runtime: PluginRuntime, caller: Caller): Promise<JoinedThread[]> {
904
+ const rows = await collect(cursor =>
905
+ runtime.node.threads.joined(runtime.node.auth, {
906
+ page_size: PAGE,
907
+ ...(cursor === undefined ? {} : { cursor }),
908
+ }),
909
+ );
910
+ const visible = rows.filter(row => !isTrustThread(runtime.config.node.id, row.thread_ref));
911
+ if (caller.main) return visible;
912
+ const allowed = allowedThreads(await scopedRecords(runtime, caller));
913
+ return visible.filter(row => allowed.has(threadKey(row.thread_ref)));
820
914
  }
821
915
 
822
916
  async function allMessages(runtime: PluginRuntime, ref: Thread["thread_ref"]): Promise<Entry[]> {
@@ -829,6 +923,22 @@ async function allMessages(runtime: PluginRuntime, ref: Thread["thread_ref"]): P
829
923
  );
830
924
  }
831
925
 
926
+ /**
927
+ * A thread's entries: this node's own storage for a thread it hosts, the owner's message
928
+ * route otherwise. A member holds no copy of a joined thread's entries, so the owner is the
929
+ * only source that can answer for them.
930
+ */
931
+ async function threadEntries(runtime: PluginRuntime, ref: ThreadRef): Promise<Entry[]> {
932
+ if (ref.owner === runtime.config.node.id) return allMessages(runtime, ref);
933
+ return collect(cursor =>
934
+ runtime.node.client.threadEntries(ref, {
935
+ attention: "thread",
936
+ page_size: PAGE,
937
+ ...(cursor === undefined ? {} : { cursor }),
938
+ }),
939
+ );
940
+ }
941
+
832
942
  /** The node's own outbound records; a subagent sees only replies tied to a message it received. */
833
943
  async function outboundRows(runtime: PluginRuntime, caller: Caller): Promise<Outbound[]> {
834
944
  const rows = await runtime.storage.transaction(tx => tx.scan<Outbound>("outbound/"));
@@ -1316,6 +1426,37 @@ function entryPath(ref: ThreadRef, seq: string): string {
1316
1426
  return `onlyne://threads/${encodeURIComponent(ref.id)}/messages/${encodeURIComponent(seq)}`;
1317
1427
  }
1318
1428
 
1429
+ /** The entry route of a thread named by its full `owner/id` key, which a joined thread needs. */
1430
+ function ownedEntryPath(ref: ThreadRef, seq: string): string {
1431
+ return `onlyne://threads/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.id)}/messages/${encodeURIComponent(seq)}`;
1432
+ }
1433
+
1434
+ /**
1435
+ * One entry as its owner holds it. A joined thread's page is the owner's record, so it reports
1436
+ * the entry itself and never claims this node's own delivery or consumption state for it.
1437
+ */
1438
+ function renderEntryRow(entry: Entry, nodeId: string): string {
1439
+ const ref: MessageRef = { sender: entry.message.sender, id: entry.message.id };
1440
+ const related = entry.message.meta?.in_reply_to;
1441
+ const lines = [
1442
+ t("historyRow", {
1443
+ prefix: t("historySeqPrefix", { seq: entry.seq }),
1444
+ direction: entry.message.sender === nodeId ? "out" : "in",
1445
+ sender: ref.sender,
1446
+ at: entry.at,
1447
+ status: t("ownerHeld"),
1448
+ }),
1449
+ ];
1450
+ if (related !== undefined) lines.push(t("replyTo", { ref: refKey(related), url: messagePath(related) }));
1451
+ lines.push(t("historyLinks", {
1452
+ message: messagePath(ref),
1453
+ conversation: conversationPath(ref),
1454
+ entry: ownedEntryPath(entry.thread_ref, entry.seq),
1455
+ }));
1456
+ for (const line of entry.message.msg.split(/\r?\n/)) lines.push(` > ${line}`);
1457
+ return lines.join("\n");
1458
+ }
1459
+
1319
1460
  function renderHistoryRow(row: HistoryMessage, nodeId: string): string {
1320
1461
  const ref = row.message_ref;
1321
1462
  const thread = row.thread_ref ?? row.message.meta?.thread;