omp-onlyne 0.0.0-stage → 0.1.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/drafts.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { OnlyneError, type Json, type MessageRef, type MsgInput, type PreparedSend } from "@onlyne/lib";
2
+ import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
3
+ import { runDecision } from "./decider.ts";
4
+ import type { PluginRuntime } from "./runtime.ts";
5
+ import { bridgeText as t } from "./bridge-text.ts";
6
+
7
+ interface Draft {
8
+ id: string;
9
+ created_by: string;
10
+ created_at: string;
11
+ prepared: PreparedSend;
12
+ }
13
+
14
+ export async function createDraft(runtime: PluginRuntime, ctx: ExtensionContext, input: MsgInput, signal?: AbortSignal): Promise<unknown> {
15
+ const meta = { ...input.meta };
16
+ delete meta.op;
17
+ if (meta.relation !== "reply" && meta.relation !== "followup") throw new OnlyneError("invalid_request", t("draftNeedsRelation"));
18
+ const original = await resolveSource(runtime, meta.in_reply_to);
19
+ if (ctx.agent.kind === "sub") {
20
+ const authorized = (await runtime.contexts.list()).some(record => record.agent_id === ctx.agent.id
21
+ && record.source?.sender === original.sender && record.source.id === original.id);
22
+ if (!authorized) throw new OnlyneError("forbidden", t("draftChildScope"));
23
+ }
24
+ meta.in_reply_to = { sender: original.sender, id: original.id };
25
+ const normalized = { ...input, meta };
26
+ const prepared = await runDecision({ ctx, input: normalized, signal, readOnly: false }, () => runtime.node.client.prepare(normalized));
27
+ const draft: Draft = { id: crypto.randomUUID(), created_by: ctx.agent.id, created_at: new Date().toISOString(), prepared };
28
+ await runtime.storage.transaction(tx => tx.put(`draft/${draft.id}`, draft));
29
+ return { draft_id: draft.id, to: prepared.to, relation: prepared.message.meta?.relation,
30
+ in_reply_to: original, body: prepared.message.msg, state: "draft", send: { meta: { draft_id: draft.id } } };
31
+ }
32
+
33
+ export async function sendDraft(runtime: PluginRuntime, ctx: ExtensionContext, id: string, signal?: AbortSignal): Promise<unknown> {
34
+ if (ctx.agent.kind === "sub") throw new OnlyneError("forbidden", t("draftChildCannotSend"));
35
+ const draft = await runtime.storage.transaction(tx => tx.get<Draft>(`draft/${id}`));
36
+ if (draft === undefined) throw new OnlyneError("not_found", t("draftNotFound", { id }));
37
+ const { to, message, plan } = draft.prepared;
38
+ return runtime.node.client.send(to, message, plan, { context: ctx.agent.id, signal });
39
+ }
40
+
41
+ async function resolveSource(runtime: PluginRuntime, value: Json | undefined): Promise<MessageRef> {
42
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new OnlyneError("invalid_request", t("draftNeedsReference"));
43
+ if (typeof value.sender === "string" && typeof value.id === "string") return { sender: value.sender, id: value.id };
44
+ if (typeof value.thread === "string" && (typeof value.seq === "string" || typeof value.seq === "number")) {
45
+ const slash = value.thread.lastIndexOf("/");
46
+ const thread = slash < 0 ? { owner: runtime.config.node.id, id: value.thread }
47
+ : { owner: value.thread.slice(0, slash), id: value.thread.slice(slash + 1) };
48
+ let cursor: string | undefined;
49
+ do {
50
+ const result = await runtime.node.messages.list(runtime.node.auth, thread, { attention: "thread", page_size: 100, cursor });
51
+ const found = result.items.find(row => row.seq === String(value.seq));
52
+ if (found !== undefined) return { sender: found.message.sender, id: found.message.id };
53
+ cursor = result.next_cursor ?? undefined;
54
+ } while (cursor !== undefined);
55
+ }
56
+ throw new OnlyneError("not_found", t("draftSourceUnavailable"));
57
+ }
@@ -0,0 +1,86 @@
1
+ import { createTranslator } from "@onlyne/node-host/i18n";
2
+ const en = {
3
+ regionEmpty: "region must not be empty.",
4
+ nodeIdEmpty: "node.id must not be empty.",
5
+ addressEmpty: "A node address must not be empty.",
6
+ addressUrl: "A node address must be an absolute URL, got {value}.",
7
+ addressScheme: "A node address must use http or https, got {value}.",
8
+ addressShape: "A node address is an origin (http://host:7801) or a service URL (http://host:7801{service}), got {value}.",
9
+ configMissing: "No Onlyne configuration at {path}. Run /onlyne init to create it.",
10
+ configParse: "{path}: {error}",
11
+ languageInvalid: "language must be en or zh-CN.",
12
+ keyScopeInvalid: "node.key_scope must be project, global or none, got {value}.",
13
+ publicUrlEmpty: "server.public_url must not be empty.",
14
+ bindEmpty: "server.bind must not be empty.",
15
+ bindShape: "server.bind must be a hostname or IP address, got {value}.",
16
+ pageSizeRange: "threads.page_size must be an integer from 1 to 100, got {value}.",
17
+ retiredEndpoint: "node.endpoint is not supported; the endpoint comes from server.bind and the assigned port.",
18
+ retiredPort: "server.port is not supported; the operating system assigns the listening port.",
19
+ retiredPeers: "peers is not supported; peers are discovered over the network and kept in the project SQLite.",
20
+ retiredOmp: "omp is not supported; delivery and spawning are decided by the OMP judge.",
21
+ retiredAttention: "threads.default_attention is not supported; reads default to the attention list.",
22
+ tableExpected: "{path}: {key} must be a table.",
23
+ stringExpected: "{field} must be a string.",
24
+ integerExpected: "{field} must be an integer.",
25
+ stringArrayExpected: "{field} must be an array of non-empty strings.",
26
+ trustArray: "{path}: trust must be an array of tables.",
27
+ trustEntry: "{path}: a trust entry needs node_id and a 16-character fingerprint.",
28
+ addressUnavailable: "No concrete network address is available for this bind host.",
29
+ keyProvider: "External credential references need a host key provider.",
30
+ loopbackBind: "The unauthenticated development binding requires a loopback bind host. Create a node key to serve a remote address.",
31
+ managementIdentity: "HTTP callers cannot claim this node's management identity.",
32
+ mentionNotFound: "No unique discovered node is named {name}.",
33
+ guideContacts: "Contacts",
34
+ guidePending: "Pending",
35
+ guideAccept: "Accept",
36
+ guideDiscover: "Discover",
37
+ guideRequestExchange: "Request key exchange",
38
+ resourcePageSize: "page_size must be from 1 to 100.",
39
+ resourceOwner: "Resource owner must be a non-empty node id.",
40
+ resourceRef: "Resource reads need meta.resource {owner,id}.",
41
+ resourceSince: "since must be a decimal event id.",
42
+ resourceOp: "Resource reads use list, describe, log, event with event_id, operations, or status with operation_id.",
43
+ };
44
+ const zh: Record<keyof typeof en, string> = {
45
+ regionEmpty: "region 不能为空。",
46
+ nodeIdEmpty: "node.id 不能为空。",
47
+ addressEmpty: "节点地址不能为空。",
48
+ addressUrl: "节点地址必须是绝对 URL,收到的是 {value}。",
49
+ addressScheme: "节点地址必须使用 http 或 https,收到的是 {value}。",
50
+ addressShape: "节点地址应写成源站(http://host:7801)或服务 URL(http://host:7801{service}),收到的是 {value}。",
51
+ configMissing: "在 {path} 没有 Onlyne 配置。运行 /onlyne init 创建。",
52
+ configParse: "{path}: {error}",
53
+ languageInvalid: "language 必须是 en 或 zh-CN。",
54
+ keyScopeInvalid: "node.key_scope 必须是 project、global 或 none,收到的是 {value}。",
55
+ publicUrlEmpty: "server.public_url 不能为空。",
56
+ bindEmpty: "server.bind 不能为空。",
57
+ bindShape: "server.bind 必须是主机名或 IP 地址,收到的是 {value}。",
58
+ pageSizeRange: "threads.page_size 必须是 1 到 100 之间的整数,收到的是 {value}。",
59
+ retiredEndpoint: "不支持 node.endpoint;端点由 server.bind 和系统分配的端口决定。",
60
+ retiredPort: "不支持 server.port;监听端口由操作系统分配。",
61
+ retiredPeers: "不支持 peers;对端通过网络发现,并保存在项目 SQLite 中。",
62
+ retiredOmp: "不支持 omp;投送与派生由 OMP judge 决定。",
63
+ retiredAttention: "不支持 threads.default_attention;读取默认使用关注列表。",
64
+ tableExpected: "{path}: {key} 必须是表。",
65
+ stringExpected: "{field} 必须是字符串。",
66
+ integerExpected: "{field} 必须是整数。",
67
+ stringArrayExpected: "{field} 必须是非空字符串数组。",
68
+ trustArray: "{path}: trust 必须是表数组。",
69
+ trustEntry: "{path}: 每条 trust 记录需要 node_id 和 16 个字符的指纹。",
70
+ addressUnavailable: "这个绑定主机没有可用的具体网络地址。",
71
+ keyProvider: "外部凭据引用需要主机密钥提供者。",
72
+ loopbackBind: "未认证的开发绑定需要回环绑定主机。要对外提供服务,请创建节点密钥。",
73
+ managementIdentity: "HTTP 调用方不能冒用本节点的管理身份。",
74
+ mentionNotFound: "没有唯一发现到的节点名为 {name}。",
75
+ guideContacts: "联系人",
76
+ guidePending: "待确认",
77
+ guideAccept: "同意",
78
+ guideDiscover: "发现",
79
+ guideRequestExchange: "请求交换密钥",
80
+ resourcePageSize: "page_size 必须是 1 到 100。",
81
+ resourceOwner: "资源 owner 必须是非空节点 id。",
82
+ resourceRef: "读取资源需要 meta.resource {owner,id}。",
83
+ resourceSince: "since 必须是十进制事件 id。",
84
+ resourceOp: "读取资源可用 list、describe、log、带 event_id 的 event、operations,或带 operation_id 的 status。",
85
+ };
86
+ export const hostText = createTranslator({ en, "zh-CN": zh });
@@ -0,0 +1,327 @@
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 { bridgeText as t } from "./bridge-text.ts";
12
+
13
+ export const INBOUND = "onlyne.inbound";
14
+ export const ROUTING = "onlyne.routing";
15
+ export const SUPPLEMENT = "onlyne.supplement";
16
+
17
+ export interface BridgeDeps {
18
+ pi: ExtensionAPI;
19
+ main: ExtensionContext;
20
+ decider: JudgeDecider;
21
+ contexts: ContextRegistry;
22
+ nodeId: string;
23
+ }
24
+ /**
25
+ * One inbound entry is judged once per process. A retry replays the recorded decision
26
+ * and, at most, the delivery it names; asking the judge again would turn the
27
+ * maintenance tick into a model poll (RFC 0005 §1: one message gets one action). A
28
+ * failed judgment is recorded too: the entry stays owed, and the tick does not re-ask.
29
+ */
30
+ interface InboundAttempt {
31
+ decision?: InboundDecision;
32
+ failure?: OnlyneError;
33
+ landed?: string;
34
+ }
35
+ const inboundAttempts = new Map<string, InboundAttempt>();
36
+
37
+ function inboundKey(entry: Entry): string {
38
+ return `${entry.thread_ref.owner}/${entry.thread_ref.id}/${entry.seq}`;
39
+ }
40
+ /** Transitions that repeat every maintenance pass once a record is decided. */
41
+ const TRACE_DEBUG = new Set(["inbound.inject", "inbound.duplicate"]);
42
+
43
+ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
44
+ return {
45
+ /**
46
+ * Admission is the last cheap place to refuse a plan this host cannot honor (RFC 0005
47
+ * §1): a `new` context needs the task tool. It runs inside the library's admission
48
+ * transaction, so it may not read the context registry — that opens a second transaction
49
+ * on the same store and waits on itself. A plan that names a lost context is refused by
50
+ * `decideInbound` instead, which runs outside any transaction.
51
+ */
52
+ async supports(plan: DeliveryPlan, _message?: Message): Promise<boolean> {
53
+ return plan.execution !== "new" || taskTool(deps.main) !== undefined;
54
+ },
55
+ trace: (event: TraceEvent) => {
56
+ const fields = { ...event } as Record<string, unknown>;
57
+ const name = String(fields.event);
58
+ delete fields.event;
59
+ const thread = fields.thread as { owner?: unknown; id?: unknown } | undefined;
60
+ if (thread !== undefined) fields.thread = `${String(thread.owner)}/${String(thread.id)}`;
61
+ if (TRACE_DEBUG.has(name)) deps.pi.logger.debug(`onlyne.${name}`, fields);
62
+ else deps.pi.logger.info(`onlyne.${name}`, fields);
63
+ },
64
+ async inject(entry: Entry, context?: string, plan?: DeliveryPlan) {
65
+ const key = inboundKey(entry);
66
+ let attempt = inboundAttempts.get(key);
67
+ if (attempt === undefined) {
68
+ const started = Date.now();
69
+ let decision: InboundDecision;
70
+ try {
71
+ decision = await deps.decider.decideInbound(deps.main, entry, plan);
72
+ } catch (error) {
73
+ const failure = error instanceof OnlyneError
74
+ ? error
75
+ : new OnlyneError("decision_unavailable", error instanceof Error ? error.message : String(error));
76
+ deps.pi.logger.warn("onlyne.judge.failed", {
77
+ entry: key, ms: Date.now() - started, error: failure.message,
78
+ });
79
+ inboundAttempts.set(key, { failure });
80
+ throw failure;
81
+ }
82
+ attempt = { decision };
83
+ inboundAttempts.set(key, attempt);
84
+ deps.pi.logger.info("onlyne.judge.answered", {
85
+ entry: key, action: decision.action, ms: Date.now() - started,
86
+ ...(decision.context === undefined ? {} : { context: decision.context }),
87
+ });
88
+ }
89
+ if (attempt.failure !== undefined) {
90
+ deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "judge-failed", error: attempt.failure.message });
91
+ throw attempt.failure;
92
+ }
93
+ const decision = attempt.decision;
94
+ if (decision === undefined) throw new OnlyneError("decision_unavailable", t("noDecision"));
95
+ if (decision.action === "new_subagent") {
96
+ attempt.landed ??= await startSubagent(deps, entry);
97
+ // A spawn is a submission, not a read: the child's prompt carries the token and its
98
+ // first provider request is the proof, so nothing is consumed here.
99
+ return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
100
+ }
101
+ // The body is already where the decision put it; re-sending it would append a
102
+ // second copy that the cursor can only confirm once.
103
+ if (attempt.landed !== undefined) {
104
+ deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "already-delivered", context: attempt.landed });
105
+ return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
106
+ }
107
+ if (decision.action === "context") {
108
+ const alias = decision.context;
109
+ if (alias === undefined) throw new OnlyneError("decision_unavailable", t("noContextNamed"));
110
+ const record = await deps.contexts.find(alias);
111
+ if (record?.agent_id === undefined || record.state === "lost" || record.state === "unbound") {
112
+ // The binding went away between the decision and the delivery: the entry stays
113
+ // owed, and an explicit new decision is the only way to land it (RFC 0005 §1).
114
+ throw new OnlyneError("context_lost", t("contextLost", { alias }), { context: alias });
115
+ }
116
+ const token = registerPending(entry, record.agent_id, "fragment");
117
+ try {
118
+ await deliverToContext(record.agent_id, payload(entry, SUPPLEMENT, token), record.state === "running");
119
+ } catch (error) {
120
+ // The session behind this context is gone. Mark the binding lost so no decision
121
+ // offers it again, and keep the entry owed: a silent delivery into a disposed
122
+ // session is how a reply disappears (caerulea report 3, problem 1).
123
+ await deps.contexts.updateState(alias, "lost").catch(() => undefined);
124
+ throw error;
125
+ }
126
+ attempt.landed = record.agent_id;
127
+ deps.pi.logger.info("onlyne.delivery.sent", { entry: key, action: "context", target: record.agent_id, running: record.state === "running" });
128
+ return { context: record.agent_id, consumed: false, outcome: "delivered" as const };
129
+ }
130
+ const token = registerPending(entry, deps.main.agent.id, "fragment");
131
+ if (decision.action === "wake") {
132
+ // An explicit deliverAs never starts a turn on an idle session, so an idle main
133
+ // session gets a plain trigger whatever the insertion mode says.
134
+ const idle = deps.main.isIdle();
135
+ const mode = idle ? undefined : decision.insertion;
136
+ deps.pi.sendMessage(
137
+ payload(entry, INBOUND, token),
138
+ mode === undefined ? { triggerTurn: true } : { deliverAs: mode === "steer" ? "steer" : "followUp" },
139
+ );
140
+ } else {
141
+ deps.pi.sendMessage(payload(entry, INBOUND, token), { deliverAs: "nextTurn" });
142
+ }
143
+ attempt.landed = deps.main.agent.id;
144
+ deps.pi.logger.info("onlyne.delivery.sent", {
145
+ entry: key, action: decision.action, target: deps.main.agent.id, turn: decision.action === "wake",
146
+ ...(decision.insertion === undefined ? {} : { insertion: decision.insertion }),
147
+ });
148
+ return { context: deps.main.agent.id, consumed: false, outcome: "delivered" as const };
149
+ },
150
+ todo: async (entry, context) => {
151
+ inboundAttempts.delete(inboundKey(entry));
152
+ const planner: TodoPlanner = {
153
+ decideTodo: (incoming, current) => deps.decider.decideTodo(deps.main, incoming, current),
154
+ };
155
+ await applyMessageTodo(entry, context, planner);
156
+ // The card reports the consumption this hook just served.
157
+ refreshMailCards();
158
+ },
159
+ notify: (event: NotifyEvent) => {
160
+ getRuntime()?.notify(event);
161
+ },
162
+ };
163
+ }
164
+
165
+ /** A provider request contains the payload; submission only schedules it. */
166
+ interface PendingObservation {
167
+ entry: Entry;
168
+ context: string;
169
+ kind: "fragment" | "tool_result";
170
+ }
171
+ const pending = new Map<string, PendingObservation>();
172
+ const toolResults = new Map<string, PendingObservation>();
173
+ const observed = new Map<string, string[]>();
174
+
175
+ function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"]): string {
176
+ const token = `onlyne-${crypto.randomUUID()}`;
177
+ pending.set(token, { entry, context, kind });
178
+ return token;
179
+ }
180
+ export function observeToolResult(toolCallId: string, entry: Entry, context: string): void {
181
+ toolResults.set(toolCallId, { entry, context, kind: "tool_result" });
182
+ }
183
+ export function registerProviderBoundary(pi: ExtensionAPI): void {
184
+ pi.on("before_provider_request", (event, ctx) => {
185
+ for (const result of pendingAwaitResults(ctx.sessionManager.getBranch())) {
186
+ if (result.context === ctx.agent.id) observeToolResult(result.toolCallId, result.entry, result.context);
187
+ }
188
+ const text = typeof event.payload === "string" ? event.payload : JSON.stringify(event.payload);
189
+ const found: string[] = [];
190
+ for (const [token, record] of pending) if (record.context === ctx.agent.id && text.includes(token)) found.push(token);
191
+ for (const [toolCallId, record] of toolResults) {
192
+ if (record.context !== ctx.agent.id || !text.includes(toolCallId)) continue;
193
+ pending.set(`tool:${toolCallId}`, record);
194
+ toolResults.delete(toolCallId);
195
+ found.push(`tool:${toolCallId}`);
196
+ }
197
+ observed.set(ctx.agent.id, found);
198
+ });
199
+ pi.on("after_provider_response", async (event, ctx) => {
200
+ const tokens = observed.get(ctx.agent.id) ?? [];
201
+ observed.delete(ctx.agent.id);
202
+ if (event.status >= 400) return;
203
+ const runtime = getRuntime();
204
+ if (!runtime) return;
205
+ for (const token of tokens) {
206
+ const record = pending.get(token);
207
+ if (!record) continue;
208
+ try {
209
+ await runtime.node.client.confirmConsumption(record.entry, record.context, record.kind);
210
+ // A confirmed consumption changes what the delivery card reports.
211
+ refreshMailCards();
212
+ if (record.kind === "tool_result") pi.appendEntry(AWAIT_CONSUMED, {
213
+ tool_call_id: token.slice("tool:".length), context: record.context,
214
+ ref: { sender: record.entry.message.sender, id: record.entry.message.id },
215
+ thread_ref: record.entry.thread_ref, seq: record.entry.seq, at: new Date().toISOString(),
216
+ });
217
+ pending.delete(token);
218
+ } catch (error) {
219
+ pi.logger.warn("onlyne.consumption.failed", {
220
+ entry: inboundKey(record.entry), context: record.context, via: record.kind,
221
+ error: error instanceof Error ? error.message : String(error),
222
+ });
223
+ }
224
+ }
225
+ });
226
+ }
227
+
228
+ async function deliverToContext(agentId: string, payload: MessagePayload, running: boolean): Promise<void> {
229
+ // Never revive a session for an inbound delivery: a disposed subagent accepts the message
230
+ // where nobody reads it, and the library records the injection as delivered.
231
+ const session = AgentRegistry.global().get(agentId)?.session ?? undefined;
232
+ if (session === undefined) throw new OnlyneError("not_found", t("noLiveSession", { context: agentId }));
233
+ await session.sendCustomMessage(payload, running ? { deliverAs: "steer" } : { triggerTurn: true });
234
+ }
235
+ /**
236
+ * One source message owns one context, for the process lifetime: a replay of the same
237
+ * inbox record (the maintain loop's every-2s pass) must never spawn a second task. A
238
+ * record bound to a live agent is returned as is; an unbound one from a failed earlier
239
+ * spawn is reused under its own alias. The task text is the child's first prompt and it
240
+ * carries the consumption token, so the child's first provider request is what proves the
241
+ * model read the delivery; the spawn itself consumes nothing.
242
+ */
243
+ async function startSubagent(deps: BridgeDeps, entry: Entry): Promise<string> {
244
+ const existing = await findContextBySource(deps.contexts, entry);
245
+ if (existing?.agent_id !== undefined) return existing.agent_id;
246
+ const tool = taskTool(deps.main);
247
+ if (!tool) throw new OnlyneError("unsupported_delivery", t("noTaskTool"));
248
+ // Both references the child can actually use: the message id for `{sender,id}` replies and
249
+ // the thread/seq pair for reads. A bare `thread/seq` string fits neither (cyanea report 1).
250
+ const source = `${entry.thread_ref.owner}/${entry.thread_ref.id}/${entry.seq}`;
251
+ const references = t("deliveryReferences", {
252
+ message: entry.message.id,
253
+ thread: `${entry.thread_ref.owner}/${entry.thread_ref.id}`,
254
+ seq: entry.seq,
255
+ });
256
+ const record = existing ?? await deps.contexts.allocate(entry.thread_ref, `onlyne ${source}`, entry.message);
257
+ const name = `onlyne-${record.alias}`;
258
+ const token = `onlyne-${crypto.randomUUID()}`;
259
+ const result = await tool.execute(`onlyne-${crypto.randomUUID()}`, {
260
+ // The child cannot send: its task text says so and names the one structured return that
261
+ // does work, because a session told to "handle" a delivery with no send right reads the
262
+ // refusal as its own failure (dpdp report 4).
263
+ task: [
264
+ t("subagentPreamble", { author: entry.authenticated_author, references }),
265
+ t("subagentCannotSend"),
266
+ t("subagentDraftCall", {
267
+ to: JSON.stringify(entry.reply_authority),
268
+ sender: JSON.stringify(entry.message.sender),
269
+ id: JSON.stringify(entry.message.id),
270
+ }),
271
+ t("subagentNoRetry"),
272
+ `[[${token}]]`,
273
+ "",
274
+ entry.message.msg,
275
+ ].join("\n"),
276
+ solutionSpace: t("subagentSolutionSpace"),
277
+ name,
278
+ });
279
+ const details = result.details as unknown as { progress?: Array<{ id?: string; index?: number }>; results?: Array<{ id?: string; index?: number }> } | undefined;
280
+ const child = [...(details?.progress ?? []), ...(details?.results ?? [])].find(item => typeof item.id === "string" && item.id.length > 0)?.id;
281
+ if (child === undefined) throw new OnlyneError("unavailable", t("noChildIdentity"));
282
+ const parentId = AgentRegistry.global().get(child)?.parentId;
283
+ await deps.contexts.bind(record.alias, { agent_id: child, ...(parentId === undefined ? {} : { parent_id: parentId }) });
284
+ // The token is already in the child's prompt, so it can only be registered once the child's
285
+ // identity is known; until then there is no provider boundary to observe it at.
286
+ registerPending(entry, child, "fragment");
287
+ return child;
288
+ }
289
+ async function findContextBySource(contexts: ContextRegistry, entry: Entry): Promise<ContextRecord | undefined> {
290
+ const records = await contexts.list();
291
+ return records.find(record =>
292
+ record.source?.sender === entry.message.sender && record.source?.id === entry.message.id
293
+ && record.thread_ref?.owner === entry.thread_ref.owner && record.thread_ref?.id === entry.thread_ref.id);
294
+ }
295
+ function taskTool(ctx: ExtensionContext) {
296
+ return AgentRegistry.global().get(ctx.agent.id)?.session?.getToolByName("task");
297
+ }
298
+ interface MessagePayload {
299
+ customType: string;
300
+ content: string;
301
+ display: true;
302
+ attribution: "agent";
303
+ details: OnlyneMessageDetails;
304
+ }
305
+ function payload(entry: Entry, customType: string, token: string): MessagePayload {
306
+ return { customType, content: `${renderEntry(entry, customType)}\n\n[[${token}]]`, display: true, attribution: "agent" as const,
307
+ details: {
308
+ thread_ref: entry.thread_ref, seq: entry.seq,
309
+ message_ref: { sender: entry.message.sender, id: entry.message.id },
310
+ // This card exists because the entry reached this node's inbox; whether the model has
311
+ // consumed it is read from history, never assumed here.
312
+ delivery: "received", consumption: "pending",
313
+ mail: {
314
+ from: entry.authenticated_author, at: entry.at, subject: entry.message.delivery_kind, body: entry.message.msg,
315
+ ...(entry.message.meta?.relation === undefined ? {} : { relation: entry.message.meta.relation }),
316
+ ...(entry.message.meta?.in_reply_to === undefined ? {} : { in_reply_to: entry.message.meta.in_reply_to }),
317
+ },
318
+ } };
319
+ }
320
+ export function registerMessageRenderers(pi: ExtensionAPI): void {
321
+ for (const type of [INBOUND, ROUTING, SUPPLEMENT]) pi.registerMessageRenderer(type, renderMailMessage);
322
+ }
323
+ function renderEntry(entry: Entry, customType: string): string {
324
+ const label = customType === SUPPLEMENT ? t("labelSupplement") : t("labelInbound");
325
+ const relation = entry.message.meta?.relation === undefined ? "" : ` · ${entry.message.meta.relation}`;
326
+ return `${entry.thread_ref.id}/${entry.seq} · ${label} · ${entry.authenticated_author}${relation}\n${entry.message.msg.split(/\r?\n/).map(line => ` > ${line}`).join("\n")}`;
327
+ }
package/src/node.ts ADDED
@@ -0,0 +1,127 @@
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
+ const target = id === config.node.id
49
+ ? { node_id: id, base_url: deps.endpoint }
50
+ : await node.discovery.resolve(id);
51
+ return { ...target, headers: { "Onlyne-Actor": config.node.id } };
52
+ };
53
+ /**
54
+ * Admission reads the trust projection, never the discovery catalog (design §1.1): a discovered
55
+ * peer is a clue (RFC 0017 §4), and `discovery.known` is process-local, so a restart would refuse
56
+ * a peer whose exchange already completed.
57
+ */
58
+ const admitted = async (id: string, context: AdmissionContext): Promise<boolean> => {
59
+ if (id === config.node.id) return true;
60
+ return await context.trust(id) === "exchanged";
61
+ };
62
+ const keyDir = deps.keyDir;
63
+ const keys: KeyHook | undefined = deps.keys ?? (keyDir === undefined ? undefined : Object.assign(
64
+ () => loadOrCreateKey(keyDir, config.node.id),
65
+ { replace: (material: KeyMaterial) => writeKeyMaterial(keyDir, material, { replace: true }) },
66
+ ));
67
+ // The library's own signing transport, built here for one reason: a missing trust row has to
68
+ // end at this host's menu. Signing still happens only for a keyed node, and the trust reads
69
+ // resolve lazily because the service exists only once the node is built.
70
+ const signing: SigningTransportOptions | undefined = keys === undefined ? undefined : {
71
+ trust: {
72
+ entry: id => node.trust.entry(id),
73
+ signRequest: (request, target, raw) => node.trust.signRequest(request, target, raw),
74
+ verifyResponse: (target, request, response, body) => node.trust.verifyResponse(target, request, response, body),
75
+ },
76
+ guidance: details => ({
77
+ entry: details.required_action === "confirm_exchange"
78
+ ? ["/onlyne", t("guideContacts"), t("guidePending"), t("guideAccept")]
79
+ : ["/onlyne", t("guideDiscover"), t("guideRequestExchange")],
80
+ }),
81
+ };
82
+ node = new OnlyneNode({
83
+ node_id: config.node.id, base_url: deps.endpoint, instance_id: deps.instanceId,
84
+ now: () => Date.now(), id: () => crypto.randomUUID(),
85
+ authentication: keys === undefined ? ["none"] : ["signature"], plugins: {
86
+ storage: deps.storage, authenticate, resolve, decide: deps.decide, runtime: deps.runtime, maintenanceClock: deps.clock,
87
+ ...(keys === undefined ? {} : { keys }),
88
+ ...(signing === undefined ? {} : { transport: new FetchTransport(resolve, fetch, signing) }),
89
+ ...(config.trust === undefined ? {} : { trust_seeds: config.trust }),
90
+ domainCandidates: id => node.discovery.candidates(id),
91
+ // The keyless development binding checks no identity (RFC 0001 §1): every declared
92
+ // actor may open a thread. A keyed node admits only peers whose exchange completed.
93
+ mayAdmit: async (actor, _thread, context) => keys === undefined || await admitted(actor, context),
94
+ inboundThread: async (actor, context) => keys === undefined || await admitted(actor, context),
95
+ // A context that cannot answer is not a continuation target: publishing one lets a
96
+ // sender aim a message at a session that can never reply (caerulea report 4, problem 7).
97
+ executionContexts: async () => (await deps.contexts.list()).filter(canAnswer)
98
+ .map(record => ({ alias: record.alias, summary: record.summary, state: record.state })) as Json,
99
+ mention: async alias => {
100
+ const name = alias.replace(/^@/, "");
101
+ if (name === "self" || name === config.node.id) return config.node.id;
102
+ const matches = (await node.discovery.listRecords()).filter(record => record.node_id === name || record.node_id.split(":").at(-1) === name);
103
+ const ids = [...new Set(matches.map(record => record.node_id))];
104
+ if (ids.length !== 1) throw new OnlyneError(ids.length ? "ambiguous_target" : "not_found", t("mentionNotFound", { name }));
105
+ return ids[0]!;
106
+ },
107
+ 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,
108
+ // The same rows onlyne://neighbors renders: every observed node with its trust state.
109
+ neighbors: async () => {
110
+ const records = await node.discovery.listRecords();
111
+ const trust = new Map((await node.trust.list(node.auth)).map(entry => [entry.node_id, entry]));
112
+ return records.map(record => {
113
+ const entry = trust.get(record.node_id);
114
+ return {
115
+ node_id: record.node_id, base_url: record.base_url,
116
+ ...(record.membership?.domain_id === undefined ? {} : { region: record.membership.domain_id }),
117
+ ...(record.last_seen === undefined ? {} : { last_seen: record.last_seen }),
118
+ verified: record.verified_at !== undefined && Date.parse(record.expires_at) > Date.now(),
119
+ phase: entry === undefined ? "no_exchange" : trustPhase(entry),
120
+ fingerprint: entry?.fingerprint ?? null,
121
+ };
122
+ });
123
+ },
124
+ },
125
+ });
126
+ return node;
127
+ }
@@ -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
+ }