omp-onlyne 0.0.0-stage → 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.
@@ -0,0 +1,427 @@
1
+ import { OnlyneError, type DeliveryPlan, type Entry, type Message, type NotifyEvent, type Runtime, type TraceEvent } from "@onlyne/lib";
2
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
3
+ import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
4
+ import { refreshMailCards, renderMailMessage, type OnlyneMessageDetails } from "./ui/mail-message.ts";
5
+ import type { InboundDecision, JudgeDecider } from "./decider.ts";
6
+ import type { ContextRegistry } from "./store.ts";
7
+ import type { ContextRecord } from "./types.ts";
8
+ import { AWAIT_CONSUMED, pendingAwaitResults } from "./tool.ts";
9
+ import { applyMessageTodo, type TodoPlanner } from "./todo.ts";
10
+ import { getRuntime } from "./runtime.ts";
11
+ import { handshakeFacts } from "./handshake.ts";
12
+ import { bridgeText as t } from "./bridge-text.ts";
13
+
14
+ export const INBOUND = "onlyne.inbound";
15
+ export const ROUTING = "onlyne.routing";
16
+ export const SUPPLEMENT = "onlyne.supplement";
17
+
18
+ export interface BridgeDeps {
19
+ pi: ExtensionAPI;
20
+ main: ExtensionContext;
21
+ decider: JudgeDecider;
22
+ contexts: ContextRegistry;
23
+ nodeId: string;
24
+ /** Wall clock, so the escalation window can be tested without waiting for it. */
25
+ now?: () => number;
26
+ }
27
+ /**
28
+ * One inbound entry is judged once per process. A retry replays the recorded decision
29
+ * and, at most, the delivery it names; asking the judge again would turn the
30
+ * maintenance tick into a model poll (RFC 0005 §1: one message gets one action). A
31
+ * failed judgment is recorded too: the entry stays owed, and the tick does not re-ask.
32
+ */
33
+ interface InboundAttempt {
34
+ decision?: InboundDecision;
35
+ failure?: OnlyneError;
36
+ landed?: string;
37
+ /** Maintenance passes this entry has been reported landed without being confirmed. */
38
+ passes?: number;
39
+ /** Whether the held delivery has already been forced through as a trigger. */
40
+ escalated?: boolean;
41
+ /** Set once the second window passed with no confirmation; the warning is written once. */
42
+ unconfirmed?: boolean;
43
+ /** How the delivery was submitted: a trigger starts work, a next-turn fragment waits. */
44
+ delivery?: { via: "trigger" | "nextTurn"; at: number };
45
+ }
46
+
47
+ /**
48
+ * How long a next-turn fragment may stay unread before the host offers it again. A turn takes at
49
+ * least one provider round trip, so a window measured in maintenance passes fires while the
50
+ * session is still working and duplicates the delivery (dpdp report, follow_up queue).
51
+ */
52
+ const NEXT_TURN_ESCALATE_MS = 60_000;
53
+ /** Times an entry may be decided again after its binding went away before it is delivered instead. */
54
+ const MAX_REDECISIONS = 2;
55
+ const inboundAttempts = new Map<string, InboundAttempt>();
56
+ /**
57
+ * Deliveries attempted per entry, kept outside the attempt record: dropping that record is what
58
+ * makes the next pass decide again, and it must not reset the budget that bounds the deciding.
59
+ */
60
+ const deliveryAttempts = new Map<string, number>();
61
+
62
+ function countedAttempt(key: string): number {
63
+ const next = (deliveryAttempts.get(key) ?? 0) + 1;
64
+ deliveryAttempts.set(key, next);
65
+ return next;
66
+ }
67
+
68
+ /** The end of the line for an entry nothing can hold: the main session takes it once, visibly. */
69
+ function escalate(deps: BridgeDeps, entry: Entry, key: string, reason: string) {
70
+ const token = registerPending(entry, deps.main.agent.id, "fragment");
71
+ deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
72
+ deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason, attempts: deliveryAttempts.get(key) });
73
+ return { context: deps.main.agent.id, consumed: false, outcome: "delivered" as const };
74
+ }
75
+
76
+ function inboundKey(entry: Entry): string {
77
+ return `${entry.thread_ref.owner}/${entry.thread_ref.id}/${entry.seq}`;
78
+ }
79
+ /** Transitions that repeat every maintenance pass once a record is decided. */
80
+ const TRACE_DEBUG = new Set(["inbound.inject", "inbound.duplicate"]);
81
+
82
+ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
83
+ const now = deps.now ?? Date.now;
84
+ return {
85
+ /**
86
+ * Admission is the last cheap place to refuse a plan this host cannot honor (RFC 0005
87
+ * §1): a `new` context needs the task tool. It runs inside the library's admission
88
+ * transaction, so it may not read the context registry — that opens a second transaction
89
+ * on the same store and waits on itself. A plan that names a lost context is refused by
90
+ * `decideInbound` instead, which runs outside any transaction.
91
+ */
92
+ async supports(plan: DeliveryPlan, _message?: Message): Promise<boolean> {
93
+ return plan.execution !== "new" || taskTool(deps.main) !== undefined;
94
+ },
95
+ trace: (event: TraceEvent) => {
96
+ const fields = { ...event } as Record<string, unknown>;
97
+ const name = String(fields.event);
98
+ delete fields.event;
99
+ const thread = fields.thread as { owner?: unknown; id?: unknown } | undefined;
100
+ if (thread !== undefined) fields.thread = `${String(thread.owner)}/${String(thread.id)}`;
101
+ if (TRACE_DEBUG.has(name)) deps.pi.logger.debug(`onlyne.${name}`, fields);
102
+ else deps.pi.logger.info(`onlyne.${name}`, fields);
103
+ },
104
+ async inject(entry: Entry, context?: string, plan?: DeliveryPlan) {
105
+ const key = inboundKey(entry);
106
+ let attempt = inboundAttempts.get(key);
107
+ if (attempt === undefined) {
108
+ const started = Date.now();
109
+ let decision: InboundDecision;
110
+ try {
111
+ decision = await deps.decider.decideInbound(deps.main, entry, plan);
112
+ } catch (error) {
113
+ const failure = error instanceof OnlyneError
114
+ ? error
115
+ : new OnlyneError("decision_unavailable", error instanceof Error ? error.message : String(error));
116
+ deps.pi.logger.warn("onlyne.judge.failed", {
117
+ entry: key, ms: Date.now() - started, error: failure.message,
118
+ });
119
+ inboundAttempts.set(key, { failure });
120
+ throw failure;
121
+ }
122
+ attempt = { decision };
123
+ inboundAttempts.set(key, attempt);
124
+ deps.pi.logger.info("onlyne.judge.answered", {
125
+ entry: key, action: decision.action, ms: Date.now() - started,
126
+ ...(decision.context === undefined ? {} : { context: decision.context }),
127
+ });
128
+ }
129
+ if (attempt.failure !== undefined) {
130
+ // A lost binding cannot be re-decided into existence: past the budget the entry is
131
+ // delivered to the main session instead, so the judgement stops burning model calls
132
+ // and the message stops being invisible.
133
+ if (attempt.failure.error.code === "context_lost" && countedAttempt(key) > MAX_REDECISIONS) {
134
+ return escalate(deps, entry, key, "context-lost");
135
+ }
136
+ deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "judge-failed", error: attempt.failure.message });
137
+ throw attempt.failure;
138
+ }
139
+ const decision = attempt.decision;
140
+ if (decision === undefined) throw new OnlyneError("decision_unavailable", t("noDecision"));
141
+ // The body is already where the decision put it; re-sending it would append a
142
+ // second copy that the cursor can only confirm once.
143
+ if (attempt.landed !== undefined) {
144
+ // RFC 0008 §5: a delivery is landed only while the context holding it can still
145
+ // confirm it. A subagent session that ended leaves the entry unconfirmed forever,
146
+ // and reporting it delivered on every maintenance pass is a retry storm, not a
147
+ // delivery. Mark the dead binding lost and let the next pass decide again.
148
+ const holder = (await deps.contexts.list()).find(record => record.agent_id === attempt.landed);
149
+ if (holder !== undefined && holder.state !== "idle" && holder.state !== "running") {
150
+ if (countedAttempt(key) > MAX_REDECISIONS) return escalate(deps, entry, key, "context-lost");
151
+ await deps.contexts.updateState(holder.alias, "lost").catch(() => undefined);
152
+ inboundAttempts.delete(key);
153
+ throw new OnlyneError("context_lost", t("contextLost", { alias: holder.alias }), { context: holder.alias });
154
+ }
155
+ // A delivery nobody confirmed has two shapes and one bound. A next-turn fragment waits
156
+ // for an assembly the session may never reach. A trigger starts its own turn, and that
157
+ // turn can die before the model reads the payload — a provider response at or above 400
158
+ // drops the observation it recorded — leaving the entry unconfirmed while every pass
159
+ // reports it delivered. Both are retried once, after a window long enough to be sure the
160
+ // session is not simply working, and only while it is idle right now.
161
+ attempt.passes = (attempt.passes ?? 0) + 1;
162
+ const delivery = attempt.delivery;
163
+ const waiting = delivery !== undefined && now() - delivery.at >= NEXT_TURN_ESCALATE_MS;
164
+ if (attempt.landed === deps.main.agent.id && waiting && delivery !== undefined && deps.main.isIdle()) {
165
+ if (attempt.escalated !== true) {
166
+ attempt.escalated = true;
167
+ // The retry opens its own window; measuring the second one from the first send
168
+ // would report it as unconfirmed on the very next pass.
169
+ attempt.delivery = { via: "trigger", at: now() };
170
+ const token = registerPending(entry, deps.main.agent.id, "fragment");
171
+ deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
172
+ deps.pi.logger.info("onlyne.delivery.escalated", {
173
+ entry: key, reason: "unconfirmed", via: delivery.via, waited_ms: now() - delivery.at,
174
+ });
175
+ } else if (attempt.unconfirmed !== true) {
176
+ // One warning per entry: the reason is recorded once, and the pass stops shouting.
177
+ attempt.unconfirmed = true;
178
+ deps.pi.logger.warn("onlyne.delivery.unconfirmed", {
179
+ entry: key, via: delivery.via, passes: attempt.passes, waited_ms: now() - delivery.at, context: attempt.landed,
180
+ });
181
+ }
182
+ }
183
+ deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "already-delivered", context: attempt.landed });
184
+ return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
185
+ }
186
+ if (decision.action === "new_subagent") {
187
+ attempt.landed = await startSubagent(deps, entry);
188
+ // A spawn is a submission, not a read: the child's prompt carries the token and its
189
+ // first provider request is the proof, so nothing is consumed here.
190
+ return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
191
+ }
192
+ if (decision.action === "context") {
193
+ const alias = decision.context;
194
+ if (alias === undefined) throw new OnlyneError("decision_unavailable", t("noContextNamed"));
195
+ const record = await deps.contexts.find(alias);
196
+ if (record?.agent_id === undefined || record.state === "lost" || record.state === "unbound") {
197
+ // The binding went away between the decision and the delivery: the entry stays
198
+ // owed, and an explicit new decision is the only way to land it (RFC 0005 §1).
199
+ throw new OnlyneError("context_lost", t("contextLost", { alias }), { context: alias });
200
+ }
201
+ const token = registerPending(entry, record.agent_id, "fragment");
202
+ try {
203
+ await deliverToContext(record.agent_id, payload(entry, SUPPLEMENT, token), record.state === "running");
204
+ } catch (error) {
205
+ // The session behind this context is gone. Mark the binding lost so no decision
206
+ // offers it again, and keep the entry owed: a silent delivery into a disposed
207
+ // session is how a reply disappears (caerulea report 3, problem 1).
208
+ await deps.contexts.updateState(alias, "lost").catch(() => undefined);
209
+ throw error;
210
+ }
211
+ attempt.landed = record.agent_id;
212
+ deps.pi.logger.info("onlyne.delivery.sent", { entry: key, action: "context", target: record.agent_id, running: record.state === "running" });
213
+ return { context: record.agent_id, consumed: false, outcome: "delivered" as const };
214
+ }
215
+ const token = registerPending(entry, deps.main.agent.id, "fragment");
216
+ if (decision.action === "wake") {
217
+ // An explicit deliverAs never starts a turn on an idle session, so an idle main
218
+ // session gets a plain trigger whatever the insertion mode says.
219
+ const idle = deps.main.isIdle();
220
+ const mode = idle ? undefined : decision.insertion;
221
+ deps.pi.sendMessage(
222
+ payload(entry, INBOUND, token),
223
+ mode === undefined ? { triggerTurn: true } : { deliverAs: mode === "steer" ? "steer" : "followUp" },
224
+ );
225
+ attempt.delivery = { via: "trigger", at: now() };
226
+ } else {
227
+ // RFC 0008 §7: a fragment enters the next context assembly without starting a turn.
228
+ // An idle session never reaches that assembly by itself, so the entry would sit in
229
+ // the queue as `injected: false` forever; idle means there is no turn to wait for.
230
+ const idle = deps.main.isIdle();
231
+ deps.pi.sendMessage(payload(entry, INBOUND, token), idle ? { triggerTurn: true } : { deliverAs: "nextTurn" });
232
+ if (idle) deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason: "idle-fragment" });
233
+ attempt.delivery = { via: idle ? "trigger" : "nextTurn", at: now() };
234
+ }
235
+ attempt.landed = deps.main.agent.id;
236
+ deps.pi.logger.info("onlyne.delivery.sent", {
237
+ entry: key, action: decision.action, target: deps.main.agent.id, turn: decision.action === "wake",
238
+ ...(decision.insertion === undefined ? {} : { insertion: decision.insertion }),
239
+ });
240
+ return { context: deps.main.agent.id, consumed: false, outcome: "delivered" as const };
241
+ },
242
+ todo: async (entry, context) => {
243
+ inboundAttempts.delete(inboundKey(entry));
244
+ deliveryAttempts.delete(inboundKey(entry));
245
+ // RFC 0018 §3: a handshake status update is delivered for the record, not as work.
246
+ // Only an entry that owes a decision takes the todo decision path.
247
+ if (handshakeFacts(entry.message.meta)?.pending === false) {
248
+ refreshMailCards();
249
+ return;
250
+ }
251
+ const planner: TodoPlanner = {
252
+ decideTodo: (incoming, current) => deps.decider.decideTodo(deps.main, incoming, current),
253
+ };
254
+ await applyMessageTodo(entry, context, planner);
255
+ // The card reports the consumption this hook just served.
256
+ refreshMailCards();
257
+ },
258
+ notify: (event: NotifyEvent) => {
259
+ getRuntime()?.notify(event);
260
+ },
261
+ };
262
+ }
263
+
264
+ /** A provider request contains the payload; submission only schedules it. */
265
+ interface PendingObservation {
266
+ entry: Entry;
267
+ context: string;
268
+ kind: "fragment" | "tool_result";
269
+ }
270
+ const pending = new Map<string, PendingObservation>();
271
+ const toolResults = new Map<string, PendingObservation>();
272
+ const observed = new Map<string, string[]>();
273
+
274
+ function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"], token = `onlyne-${crypto.randomUUID()}`): string {
275
+ pending.set(token, { entry, context, kind });
276
+ return token;
277
+ }
278
+ export function observeToolResult(toolCallId: string, entry: Entry, context: string): void {
279
+ toolResults.set(toolCallId, { entry, context, kind: "tool_result" });
280
+ }
281
+ export function registerProviderBoundary(pi: ExtensionAPI): void {
282
+ pi.on("before_provider_request", (event, ctx) => {
283
+ for (const result of pendingAwaitResults(ctx.sessionManager.getBranch())) {
284
+ if (result.context === ctx.agent.id) observeToolResult(result.toolCallId, result.entry, result.context);
285
+ }
286
+ const text = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload);
287
+ const found: string[] = [];
288
+ for (const [token, record] of pending) if (record.context === ctx.agent.id && text.includes(token)) found.push(token);
289
+ for (const [toolCallId, record] of toolResults) {
290
+ if (record.context !== ctx.agent.id || !text.includes(toolCallId)) continue;
291
+ pending.set(`tool:${toolCallId}`, record);
292
+ toolResults.delete(toolCallId);
293
+ found.push(`tool:${toolCallId}`);
294
+ }
295
+ observed.set(ctx.agent.id, found);
296
+ });
297
+ pi.on("after_provider_response", async (event, ctx) => {
298
+ const tokens = observed.get(ctx.agent.id) ?? [];
299
+ observed.delete(ctx.agent.id);
300
+ if (event.status >= 400) return;
301
+ const runtime = getRuntime();
302
+ if (!runtime) return;
303
+ for (const token of tokens) {
304
+ const record = pending.get(token);
305
+ if (!record) continue;
306
+ try {
307
+ await runtime.node.client.confirmConsumption(record.entry, record.context, record.kind);
308
+ // A confirmed consumption changes what the delivery card reports.
309
+ refreshMailCards();
310
+ if (record.kind === "tool_result") pi.appendEntry(AWAIT_CONSUMED, {
311
+ tool_call_id: token.slice("tool:".length), context: record.context,
312
+ ref: { sender: record.entry.message.sender, id: record.entry.message.id },
313
+ thread_ref: record.entry.thread_ref, seq: record.entry.seq, at: new Date().toISOString(),
314
+ });
315
+ pending.delete(token);
316
+ } catch (error) {
317
+ pi.logger.warn("onlyne.consumption.failed", {
318
+ entry: inboundKey(record.entry), context: record.context, via: record.kind,
319
+ error: error instanceof Error ? error.message : String(error),
320
+ });
321
+ }
322
+ }
323
+ });
324
+ }
325
+
326
+ async function deliverToContext(agentId: string, payload: MessagePayload, running: boolean): Promise<void> {
327
+ // Never revive a session for an inbound delivery: a disposed subagent accepts the message
328
+ // where nobody reads it, and the library records the injection as delivered.
329
+ const session = AgentRegistry.global().get(agentId)?.session ?? undefined;
330
+ if (session === undefined) throw new OnlyneError("not_found", t("noLiveSession", { context: agentId }));
331
+ await session.sendCustomMessage(payload, running ? { deliverAs: "steer" } : { triggerTurn: true });
332
+ }
333
+ /**
334
+ * One source message owns one context, for the process lifetime: a replay of the same
335
+ * inbox record (the maintain loop's every-2s pass) must never spawn a second task. A
336
+ * record bound to a live agent is returned as is; an unbound one from a failed earlier
337
+ * spawn is reused under its own alias. The task text is the child's first prompt and it
338
+ * carries the consumption token, so the child's first provider request is what proves the
339
+ * model read the delivery; the spawn itself consumes nothing.
340
+ */
341
+ async function startSubagent(deps: BridgeDeps, entry: Entry): Promise<string> {
342
+ const existing = await findContextBySource(deps.contexts, entry);
343
+ if (existing?.agent_id !== undefined) return existing.agent_id;
344
+ const tool = taskTool(deps.main);
345
+ if (!tool) throw new OnlyneError("unsupported_delivery", t("noTaskTool"));
346
+ // Both references the child can actually use: the message id for `{sender,id}` replies and
347
+ // the thread/seq pair for reads. A bare `thread/seq` string fits neither (cyanea report 1).
348
+ const source = `${entry.thread_ref.owner}/${entry.thread_ref.id}/${entry.seq}`;
349
+ const references = t("deliveryReferences", {
350
+ message: entry.message.id,
351
+ thread: `${entry.thread_ref.owner}/${entry.thread_ref.id}`,
352
+ seq: entry.seq,
353
+ });
354
+ const record = existing ?? await deps.contexts.allocate(entry.thread_ref, `onlyne ${source}`, entry.message);
355
+ const name = `onlyne-${record.alias}`;
356
+ const token = `onlyne-${crypto.randomUUID()}`;
357
+ const result = await tool.execute(`onlyne-${crypto.randomUUID()}`, {
358
+ // The child cannot send: its task text says so and names the one structured return that
359
+ // does work, because a session told to "handle" a delivery with no send right reads the
360
+ // refusal as its own failure (dpdp report 4).
361
+ task: [
362
+ t("subagentPreamble", { author: entry.authenticated_author, references }),
363
+ t("subagentCannotSend"),
364
+ t("subagentDraftCall", {
365
+ to: JSON.stringify(entry.reply_authority),
366
+ sender: JSON.stringify(entry.message.sender),
367
+ id: JSON.stringify(entry.message.id),
368
+ }),
369
+ t("subagentNoRetry"),
370
+ `[[${token}]]`,
371
+ "",
372
+ entry.message.msg,
373
+ ].join("\n"),
374
+ solutionSpace: t("subagentSolutionSpace"),
375
+ name,
376
+ });
377
+ const details = result.details as unknown as { progress?: Array<{ id?: string; index?: number }>; results?: Array<{ id?: string; index?: number }> } | undefined;
378
+ const child = [...(details?.progress ?? []), ...(details?.results ?? [])].find(item => typeof item.id === "string" && item.id.length > 0)?.id;
379
+ if (child === undefined) throw new OnlyneError("unavailable", t("noChildIdentity"));
380
+ const parentId = AgentRegistry.global().get(child)?.parentId;
381
+ await deps.contexts.bind(record.alias, { agent_id: child, ...(parentId === undefined ? {} : { parent_id: parentId }) });
382
+ // The token is already in the child's prompt, so it can only be registered once the child's
383
+ // identity is known; until then there is no provider boundary to observe it at.
384
+ // It is registered as the token the prompt carries: a fresh one would never appear in any
385
+ // provider payload, and the delivery could never be confirmed.
386
+ registerPending(entry, child, "fragment", token);
387
+ return child;
388
+ }
389
+ async function findContextBySource(contexts: ContextRegistry, entry: Entry): Promise<ContextRecord | undefined> {
390
+ const records = await contexts.list();
391
+ return records.find(record =>
392
+ record.source?.sender === entry.message.sender && record.source?.id === entry.message.id
393
+ && record.thread_ref?.owner === entry.thread_ref.owner && record.thread_ref?.id === entry.thread_ref.id);
394
+ }
395
+ function taskTool(ctx: ExtensionContext) {
396
+ return AgentRegistry.global().get(ctx.agent.id)?.session?.getToolByName("task");
397
+ }
398
+ interface MessagePayload {
399
+ customType: string;
400
+ content: string;
401
+ display: true;
402
+ attribution: "agent";
403
+ details: OnlyneMessageDetails;
404
+ }
405
+ function payload(entry: Entry, customType: string, token: string): MessagePayload {
406
+ return { customType, content: `${renderEntry(entry, customType)}\n\n[[${token}]]`, display: true, attribution: "agent" as const,
407
+ details: {
408
+ thread_ref: entry.thread_ref, seq: entry.seq,
409
+ message_ref: { sender: entry.message.sender, id: entry.message.id },
410
+ // This card exists because the entry reached this node's inbox; whether the model has
411
+ // consumed it is read from history, never assumed here.
412
+ delivery: "received", consumption: "pending",
413
+ mail: {
414
+ from: entry.authenticated_author, at: entry.at, subject: entry.message.delivery_kind, body: entry.message.msg,
415
+ ...(entry.message.meta?.relation === undefined ? {} : { relation: entry.message.meta.relation }),
416
+ ...(entry.message.meta?.in_reply_to === undefined ? {} : { in_reply_to: entry.message.meta.in_reply_to }),
417
+ },
418
+ } };
419
+ }
420
+ export function registerMessageRenderers(pi: ExtensionAPI): void {
421
+ for (const type of [INBOUND, ROUTING, SUPPLEMENT]) pi.registerMessageRenderer(type, renderMailMessage);
422
+ }
423
+ function renderEntry(entry: Entry, customType: string): string {
424
+ const label = customType === SUPPLEMENT ? t("labelSupplement") : t("labelInbound");
425
+ const relation = entry.message.meta?.relation === undefined ? "" : ` · ${entry.message.meta.relation}`;
426
+ return `${entry.thread_ref.id}/${entry.seq} · ${label} · ${entry.authenticated_author}${relation}\n${entry.message.msg.split(/\r?\n/).map(line => ` > ${line}`).join("\n")}`;
427
+ }
package/src/node.ts ADDED
@@ -0,0 +1,139 @@
1
+ import { networkInterfaces } from "node:os";
2
+ import { FetchTransport, OnlyneError, OnlyneNode, trustPhase, type Action, type AdmissionContext, type Auth, type Json, type KeyHook, type KeyMaterial, type MaintenanceClock, type MsgInput, type Runtime, type SigningTransportOptions, type Storage, type Target } from "@onlyne/lib";
3
+ import { loadOrCreateKey, writeKeyMaterial } from "@onlyne/node-host/keys";
4
+ import { hostText as t } from "./host-text.ts";
5
+ import { canAnswer, type ContextRegistry } from "./store.ts";
6
+ import type { OnlyneConfig } from "./types.ts";
7
+
8
+ export interface NodeDeps {
9
+ config: OnlyneConfig;
10
+ endpoint: string;
11
+ instanceId: string;
12
+ storage: Storage;
13
+ contexts: ContextRegistry;
14
+ decide(input: MsgInput): Promise<Action>;
15
+ runtime: Runtime;
16
+ /** The timer primitives of the library's own maintenance loop (RFC 0017 §6). */
17
+ clock: MaintenanceClock;
18
+ /** The directory the host resolved for this node's identity key; absent keeps the loopback development binding. */
19
+ keyDir?: string;
20
+ keys?: KeyHook;
21
+ }
22
+
23
+ export function advertisedHost(bind: string): string {
24
+ if (bind !== "0.0.0.0" && bind !== "::") return bind;
25
+ for (const addresses of Object.values(networkInterfaces())) {
26
+ for (const address of addresses ?? []) {
27
+ if (!address.internal && address.family === "IPv4") return address.address;
28
+ }
29
+ }
30
+ throw new OnlyneError("unavailable", t("addressUnavailable"));
31
+ }
32
+
33
+ export function createOnlyneNode(deps: NodeDeps): OnlyneNode {
34
+ const { config } = deps;
35
+ if (config.node.key_ref && deps.keys === undefined) throw new OnlyneError("unsupported_delivery", t("keyProvider"));
36
+ // Without a key directory the node keeps the loopback development binding (RFC 0001 §1).
37
+ if (deps.keys === undefined && deps.keyDir === undefined && !["127.0.0.1", "::1", "localhost"].includes(config.server.bind)) {
38
+ throw new OnlyneError("unsupported_delivery", t("loopbackBind"));
39
+ }
40
+ let node: OnlyneNode;
41
+ const authenticate = async (request: Request): Promise<Auth | null> => {
42
+ const actor = request.headers.get("Onlyne-Actor");
43
+ if (!actor || !/^[A-Za-z][A-Za-z0-9+.-]*:[^\s]+$/.test(actor)) return null;
44
+ if (actor === config.node.id) throw new OnlyneError("forbidden", t("managementIdentity"));
45
+ return { actor };
46
+ };
47
+ const resolve = async (id: string): Promise<Target> => {
48
+ let target: Target;
49
+ if (id === config.node.id) {
50
+ target = { node_id: id, base_url: deps.endpoint };
51
+ } else {
52
+ try {
53
+ target = await node.discovery.resolve(id);
54
+ } catch (error) {
55
+ const trustEntry = await node.trust.entry(id).catch(() => undefined);
56
+ if (trustEntry?.base_url) {
57
+ target = { node_id: id, base_url: trustEntry.base_url };
58
+ } else {
59
+ throw error;
60
+ }
61
+ }
62
+ }
63
+ return { ...target, headers: { "Onlyne-Actor": config.node.id } };
64
+ };
65
+ /**
66
+ * Admission reads the trust projection, never the discovery catalog (design §1.1): a discovered
67
+ * peer is a clue (RFC 0017 §4), and `discovery.known` is process-local, so a restart would refuse
68
+ * a peer whose exchange already completed.
69
+ */
70
+ const admitted = async (id: string, context: AdmissionContext): Promise<boolean> => {
71
+ if (id === config.node.id) return true;
72
+ return await context.trust(id) === "exchanged";
73
+ };
74
+ const keyDir = deps.keyDir;
75
+ const keys: KeyHook | undefined = deps.keys ?? (keyDir === undefined ? undefined : Object.assign(
76
+ () => loadOrCreateKey(keyDir, config.node.id),
77
+ { replace: (material: KeyMaterial) => writeKeyMaterial(keyDir, material, { replace: true }) },
78
+ ));
79
+ // The library's own signing transport, built here for one reason: a missing trust row has to
80
+ // end at this host's menu. Signing still happens only for a keyed node, and the trust reads
81
+ // resolve lazily because the service exists only once the node is built.
82
+ const signing: SigningTransportOptions | undefined = keys === undefined ? undefined : {
83
+ trust: {
84
+ entry: id => node.trust.entry(id),
85
+ signRequest: (request, target, raw) => node.trust.signRequest(request, target, raw),
86
+ verifyResponse: (target, request, response, body) => node.trust.verifyResponse(target, request, response, body),
87
+ },
88
+ guidance: details => ({
89
+ entry: details.required_action === "confirm_exchange"
90
+ ? ["/onlyne", t("guideContacts"), t("guidePending"), t("guideAccept")]
91
+ : ["/onlyne", t("guideDiscover"), t("guideRequestExchange")],
92
+ }),
93
+ };
94
+ node = new OnlyneNode({
95
+ node_id: config.node.id, base_url: deps.endpoint, instance_id: deps.instanceId,
96
+ now: () => Date.now(), id: () => crypto.randomUUID(),
97
+ authentication: keys === undefined ? ["none"] : ["signature"], plugins: {
98
+ storage: deps.storage, authenticate, resolve, decide: deps.decide, runtime: deps.runtime, maintenanceClock: deps.clock,
99
+ ...(keys === undefined ? {} : { keys }),
100
+ ...(signing === undefined ? {} : { transport: new FetchTransport(resolve, fetch, signing) }),
101
+ ...(config.trust === undefined ? {} : { trust_seeds: config.trust }),
102
+ domainCandidates: id => node.discovery.candidates(id),
103
+ // The keyless development binding checks no identity (RFC 0001 §1): every declared
104
+ // actor may open a thread. A keyed node admits only peers whose exchange completed.
105
+ mayAdmit: async (actor, _thread, context) => keys === undefined || await admitted(actor, context),
106
+ inboundThread: async (actor, context) => keys === undefined || await admitted(actor, context),
107
+ // A context that cannot answer is not a continuation target: publishing one lets a
108
+ // sender aim a message at a session that can never reply (caerulea report 4, problem 7).
109
+ executionContexts: async () => (await deps.contexts.list()).filter(canAnswer)
110
+ .map(record => ({ alias: record.alias, summary: record.summary, state: record.state })) as Json,
111
+ mention: async alias => {
112
+ const name = alias.replace(/^@/, "");
113
+ if (name === "self" || name === config.node.id) return config.node.id;
114
+ const matches = (await node.discovery.listRecords()).filter(record => record.node_id === name || record.node_id.split(":").at(-1) === name);
115
+ const ids = [...new Set(matches.map(record => record.node_id))];
116
+ if (ids.length !== 1) throw new OnlyneError(ids.length ? "ambiguous_target" : "not_found", t("mentionNotFound", { name }));
117
+ return ids[0]!;
118
+ },
119
+ directory: async () => ({ node: config.node.id, region: config.region, endpoint: deps.endpoint, peers: await node.discovery.listRecords(), contexts: await deps.contexts.list() }) as unknown as Json,
120
+ // The same rows onlyne://neighbors renders: every observed node with its trust state.
121
+ neighbors: async () => {
122
+ const records = await node.discovery.listRecords();
123
+ const trust = new Map((await node.trust.list(node.auth)).map(entry => [entry.node_id, entry]));
124
+ return records.map(record => {
125
+ const entry = trust.get(record.node_id);
126
+ return {
127
+ node_id: record.node_id, base_url: record.base_url,
128
+ ...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
129
+ ...(record.last_seen === undefined ? {} : { last_seen: record.last_seen }),
130
+ verified: record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now(),
131
+ phase: entry === undefined ? "no_exchange" : trustPhase(entry),
132
+ fingerprint: entry?.fingerprint ?? null,
133
+ };
134
+ });
135
+ },
136
+ },
137
+ });
138
+ return node;
139
+ }
@@ -0,0 +1,36 @@
1
+ import { OnlyneError, trustPhase, type OnlyneNode, type ResourceReadQuery } from "@onlyne/lib";
2
+ import { hostText as t } from "./host-text.ts";
3
+
4
+ export async function resourceOwners(node: OnlyneNode): Promise<string[]> {
5
+ const trusted = await node.trust.list(node.auth);
6
+ return [node.auth.actor, ...trusted.filter(entry => trustPhase(entry) === "exchanged").map(entry => entry.node_id)];
7
+ }
8
+
9
+ export async function resourceQuery(node: OnlyneNode, meta: Record<string, unknown>): Promise<ResourceReadQuery> {
10
+ const op = meta.op ?? "list";
11
+ const cursor = typeof meta.cursor === "string" ? meta.cursor : undefined;
12
+ const size = meta.page_size;
13
+ if (size !== undefined && (typeof size !== "number" || !Number.isInteger(size) || size < 1 || size > 100)) {
14
+ throw new OnlyneError("invalid_request", t("resourcePageSize"));
15
+ }
16
+ const page = { ...(cursor === undefined ? {} : { cursor }), ...(size === undefined ? {} : { page_size: size as number }) };
17
+ if (op === "list") {
18
+ if (meta.owner !== undefined && (typeof meta.owner !== "string" || meta.owner.length === 0)) throw new OnlyneError("invalid_request", t("resourceOwner"));
19
+ const owners = typeof meta.owner === "string" ? [meta.owner] : await resourceOwners(node);
20
+ return { op, owners, ...page };
21
+ }
22
+ const ref = meta.resource as { owner?: unknown; id?: unknown } | null | undefined;
23
+ if (ref === null || typeof ref !== "object" || typeof ref.owner !== "string" || ref.owner.length === 0 || typeof ref.id !== "string" || ref.id.length === 0) {
24
+ throw new OnlyneError("invalid_request", t("resourceRef"));
25
+ }
26
+ const resource = { owner: ref.owner, id: ref.id };
27
+ if (op === "describe") return { op, resource };
28
+ if (op === "log") {
29
+ if (meta.since !== undefined && (typeof meta.since !== "string" || !/^(0|[1-9][0-9]*)$/.test(meta.since))) throw new OnlyneError("invalid_request", t("resourceSince"));
30
+ return { op, resource, ...page, ...(meta.since === undefined ? {} : { since: meta.since as string }) };
31
+ }
32
+ if (op === "operations") return { op, resource, ...page };
33
+ if (op === "event" && typeof meta.event_id === "string" && meta.event_id.length > 0) return { op, resource, event_id: meta.event_id };
34
+ if (op === "status" && typeof meta.operation_id === "string" && meta.operation_id.length > 0) return { op, resource, operation_id: meta.operation_id };
35
+ throw new OnlyneError("invalid_request", t("resourceOp"));
36
+ }