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,27 @@
1
+ # Onlyne in this project
2
+
3
+ A starting point for a project that hosts an Onlyne node. Copy what you need into the project's own `AGENTS.md`; the wording assumes the `omp-onlyne` plugin is loaded.
4
+
5
+ ## Reaching another node
6
+
7
+ `msg` is the only entry:
8
+
9
+ msg({ delivery_kind: "ask reviewer to check the data", msg: "…", meta: { to: "reviewer" } })
10
+
11
+ `delivery_kind` states the intent in plain language; the judge role turns it into an action. Put the references you already have in `meta` — `to`, `thread`, `in_reply_to {thread,seq}` or `{sender,id}`, `relation`, `message`, `scope`, `seq`.
12
+
13
+ The tool is the whole surface. Do not look for a CLI, do not read the plugin source to learn the protocol, and do not open the node database to find out how it works.
14
+
15
+ ## Reading a result
16
+
17
+ The first line of a result is the outcome. `admission: "accepted"` with `guarantee: "durable"` means the receiver recorded the message. `wait: "timed_out"` means no reply arrived within the wait, not that the send failed. Report the outcome and stop.
18
+
19
+ A reply arrives by itself: the plugin injects it as an `onlyne.inbound` message and starts a turn. Do not poll after a send — no inbox scans, thread reads, evidence queries, log reads or database queries. Read state when the user asks about history, or when a fact the result did not carry is genuinely needed.
20
+
21
+ ## Subagent sessions
22
+
23
+ A subagent can read its slice but cannot send. Return the reply text and the target so the main session can send it.
24
+
25
+ ## Local files
26
+
27
+ `.onlyne/config.toml` holds the node id, region and bind address. The `omp.db` beside it is the node's own record, owned by the plugin; treat it as runtime state, not documentation.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dbydd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # omp-onlyne
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Onlyne v3 for OMP. One model entry, `msg({ delivery_kind, msg, meta })`, backed by
4
+ thread-aware message admission, reply correlation and OMP's `judge` model role.
5
+
6
+ The plugin owns the OMP integration: a local Onlyne HTTP node, project-scoped
7
+ configuration, `onlyne://` reads, custom inbound messages and the `msg` tool.
8
+ The protocol library is [`@onlyne/lib`](../lib).
9
+
10
+ ## Configuration
11
+
12
+ Configuration belongs to the project at `<project>/.onlyne/config.toml`. It is
13
+ not read from user-level plugin settings, so two projects on one machine keep
14
+ separate identities. `/onlyne init` creates it; no file is required before the
15
+ first run.
16
+
17
+ ```toml
18
+ language = "en"
19
+
20
+ [node]
21
+ region = "urn:onlyne:region:research-chamber"
22
+ id = "urn:onlyne:agent:lazuli"
23
+ key_scope = "project"
24
+
25
+ [server]
26
+ bind = "127.0.0.1"
27
+ # port is written by the runtime; set it only to pin one by hand.
28
+
29
+ [threads]
30
+ page_size = 50
31
+ ```
32
+
33
+ `region` is the required protocol membership and `id` is the stable node
34
+ identity. They are independent; moving region does not change existing message
35
+ references. `/onlyne init` suggests region from the current Tern session name
36
+ and leaves the node id empty for the user. A short region is saved as
37
+ `urn:onlyne:region:<encoded name>`.
38
+
39
+ The first start lets the operating system choose the listening port, and the
40
+ runtime writes that port back as `server.port`; every later start reuses it, so
41
+ the address a peer recorded keeps answering. A port another process already
42
+ holds moves the node to a system-chosen one, which is written back the same way.
43
+ The runtime derives the endpoint and publishes it through discovery. Discovery
44
+ uses a seed address supplied during bootstrap and an HTTP candidate exchange
45
+ stored in `.onlyne/omp.db`. DNS-SD is an optional same-link accelerator: install
46
+ `bonjour-service` to enable it. The configuration does not contain endpoint,
47
+ peers, task-spawn policy or supplement windows.
48
+
49
+ `node.key_scope` names where the identity key lives. `project` keeps it in
50
+ `<project>/.onlyne/keys`, which a git work tree must ignore; the node refuses to
51
+ create it otherwise and names both escapes. `global` keeps it in
52
+ `~/.config/onlyne/keys`, shared by every project on the machine. `none` runs the
53
+ keyless loopback development binding, which refuses a non-loopback `bind`. A
54
+ configuration without the key keeps reading the global store.
55
+
56
+ `key_ref` is parsed but not enforced. The current development binding refuses a
57
+ non-empty value and a non-loopback `bind`, because it authenticates with
58
+ `Onlyne-Actor` only. Credential exchange and proof of possession remain a
59
+ separate security proposal; leave both at their defaults for the first E2E.
60
+
61
+ ## Surface
62
+
63
+ - `msg` — send, reply, read, manage threads and operate resources. The judge
64
+ role chooses the action and delivery plan; the result reports the plan that
65
+ ran. A new execution context belongs to the receiver; the sender never mints
66
+ one.
67
+ - `onlyne://threads`, `onlyne://threads/<alias>`, `onlyne://threads/<alias>/messages/<seq>`,
68
+ `onlyne://conversations`, `onlyne://conversations/<sender>/<id>`,
69
+ `onlyne://messages/<sender>/<id>`, `onlyne://contexts`, `onlyne://inbox`,
70
+ `onlyne://outbound`, `onlyne://neighbors` — read-only views. List views accept
71
+ `page_size` (or `limit`) and `cursor`; a conversation holds both directions of one
72
+ exchange.
73
+ - `onlyne://threads` also lists the threads this node was invited into and does not own.
74
+ Those rows carry the owner, the last sequence the local records know, and the member
75
+ list when the owner answers; they are read-only, so the composer refuses them and
76
+ entries are read from the owner.
77
+ - `onlyne://resources` reads the local node and peers whose key exchange is complete.
78
+ Each owner returns its authorized resources or an explicit failure. Select
79
+ `onlyne://resources/<encoded-owner>` for one owner's inventory and pagination.
80
+ A resource lives at `onlyne://resources/<encoded-owner>/<encoded-id>`, with
81
+ `/log`, `/log/<event-id>`, `/operations`, and `/operations/<operation-id>` reads.
82
+ Log pages accept an exclusive `since` event id. Resource reads belong to the
83
+ main OMP session because context records contain no resource grants.
84
+ - Inbound messages arrive as `onlyne.inbound` custom messages. A supplement for
85
+ an existing context uses `onlyne.supplement` and `sendCustomMessage`.
86
+ - Tern renders these messages as mailing-list letters: an `[onlyne]` subject,
87
+ sender, date, thread and reply headers, Markdown body, and a link to the
88
+ original entry. Other terminals show the same fields as text. Consumption
89
+ markers stay in the model payload and are hidden from the letter.
90
+ - Consumption is confirmed only after the host observes the entry in an actual
91
+ provider request; submitting a custom message or tool result does not advance
92
+ the record.
93
+ - `/onlyne` opens a domain rail, thread sidebar, message flow and domain node list.
94
+ Threads, messages and nodes scroll independently. The inbox opens from the sidebar.
95
+ Posting names the thread's owner and id; clicking an incoming message selects its
96
+ message reference for a reply. Reply routing currently follows the original
97
+ author and can land in another thread. `/onlyne settings` edits the project
98
+ configuration. `/onlyne init` writes a first config and starts the node; with
99
+ no config, `/onlyne` and `/onlyne settings` open the same bootstrap.
100
+
101
+ Subagent sessions may read only the context slice attached to their own agent
102
+ identity and may not send; the main agent owns the node identity and the reply
103
+ path.
104
+
105
+ The equivalent `msg` query uses `meta: { scope: "resources", op: "list" }`.
106
+ An optional `owner` selects one inventory. The `describe`, `log`, `event`,
107
+ `operations`, and `status` queries name `resource: { owner, id }`.
108
+ `event` requires `event_id`; `status` requires `operation_id`.
109
+ Paged queries accept `page_size` and `cursor`. A resource cursor requires one owner.
110
+
111
+ ## Language
112
+
113
+ `/onlyne language en` and `/onlyne language zh-CN` save the project language.
114
+ The command changes rendered text, judge prompts, and the registered `msg`
115
+ description in the current session. Configuration forms preserve this setting.
116
+ The optional top-level `language` setting takes precedence over `ONLYNE_LANG`,
117
+ then `LC_ALL` or `LANG`. The default is English. Route names, protocol fields,
118
+ error codes, diagnostic event names, and user messages keep their original values.
119
+
120
+ `AGENTS.sample.md` is the agent-facing half of that surface. Copy it into the
121
+ host project's `AGENTS.md`: it states that `msg` is the only entry, that a send's
122
+ `admission` is the delivery fact rather than the wait, and that replies arrive as
123
+ inbound messages so a send needs no polling.
124
+
125
+ Select a domain node to request a key exchange. The viewer reads the
126
+ peer's public key and shows both fingerprints before sending. Confirmation sends
127
+ the signed introduction and records local approval. The peer's sidebar and message
128
+ flow show the pending request. Both nodes become trusted after the peer approves. Committed
129
+ trust changes notify the viewer immediately, including introductions received over
130
+ HTTP, so an open viewer does not wait for maintenance to show a request or approval.
131
+
132
+ ## Run
133
+
134
+ ```sh
135
+ bun install
136
+ bun run --filter omp-onlyne check
137
+ bun run --filter omp-onlyne test
138
+ ```
139
+
140
+ The package is loaded by OMP through its `omp.extensions` field. It requires
141
+ OMP 18.12.6 or newer.
package/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
+ import { registerContextBinding } from "./src/binding.ts";
3
+ import { ConfigError } from "./src/config.ts";
4
+ import { registerMessageRenderers, registerProviderBoundary } from "./src/messages.ts";
5
+ import { registerTodoRecovery } from "./src/todo.ts";
6
+ import { startRuntime, stopRuntime, type RuntimeHost } from "./src/runtime.ts";
7
+ import { registerMsgTool } from "./src/tool.ts";
8
+ import { registerOnlyneUrls } from "./src/urls.ts";
9
+ import { attachStatus, registerUi } from "./src/ui/index.ts";
10
+
11
+ /**
12
+ * OMP extension entry. The factory runs for every session, including subagents;
13
+ * the network node belongs to the main session only. State recovery and the
14
+ * listener start on `session_start`, never during factory execution.
15
+ */
16
+ export default function ompOnlyne(pi: ExtensionAPI, host: RuntimeHost = {}): void {
17
+ registerOnlyneUrls();
18
+ registerMessageRenderers(pi);
19
+ registerProviderBoundary(pi);
20
+ registerMsgTool(pi);
21
+ registerContextBinding(pi);
22
+ registerTodoRecovery(pi);
23
+ registerUi(pi);
24
+ let detachStatus: (() => void) | undefined;
25
+
26
+ pi.on("session_start", async (_event, ctx) => {
27
+ if (ctx.agent.kind !== "main") return;
28
+ if (ctx.hasUI) {
29
+ try {
30
+ detachStatus?.();
31
+ detachStatus = attachStatus(ctx);
32
+ } catch (error) {
33
+ pi.logger.warn("onlyne status widget could not be attached", {
34
+ error: error instanceof Error ? error.message : String(error),
35
+ });
36
+ }
37
+ }
38
+ try {
39
+ await startRuntime(pi, ctx, host);
40
+ } catch (error) {
41
+ // A project without a saved config is the normal first-run state; /onlyne init creates it.
42
+ if (error instanceof ConfigError && error.code === "missing") return;
43
+ pi.logger.warn("onlyne could not start for this project", {
44
+ error: error instanceof Error ? error.message : String(error),
45
+ });
46
+ }
47
+ });
48
+
49
+ pi.on("session_shutdown", async (_event, ctx) => {
50
+ if (ctx.agent.kind !== "main") return;
51
+ detachStatus?.();
52
+ detachStatus = undefined;
53
+ await stopRuntime();
54
+ });
55
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
- "name": "omp-onlyne",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "omp-onlyne",
3
+ "version": "0.2.0",
4
+ "description": "Onlyne v3 for OMP: one msg tool, thread-aware attention and SystemOne delivery decisions",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "license": "MIT",
9
+ "homepage": "https://github.com/dbydd/onlyne#readme",
10
+ "bugs": "https://github.com/dbydd/onlyne/issues",
11
+ "keywords": ["onlyne", "omp", "agent", "messaging", "tools"],
12
+ "publishConfig": { "access": "public" },
13
+ "engines": { "bun": ">=1.4.3" },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/dbydd/onlyne.git",
17
+ "directory": "packages/omp-onlyne"
18
+ },
19
+ "files": [
20
+ "index.ts",
21
+ "src",
22
+ "AGENTS.sample.md",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "omp": {
27
+ "extensions": [
28
+ "./index.ts"
29
+ ]
30
+ },
31
+ "exports": {
32
+ ".": "./index.ts",
33
+ "./runtime": "./src/runtime.ts",
34
+ "./config": "./src/config.ts",
35
+ "./types": "./src/types.ts"
36
+ },
37
+ "scripts": {
38
+ "check": "tsc --noEmit",
39
+ "test": "bun test"
40
+ },
41
+ "dependencies": {
42
+ "@onlyne/lib": "^3.1.0",
43
+ "@onlyne/node-host": "^3.1.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@oh-my-pi/pi-agent-core": "*",
47
+ "@oh-my-pi/pi-coding-agent": "*",
48
+ "@oh-my-pi/pi-tui": "*",
49
+ "omp-todo-more": ">=0.1.4"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "omp-todo-more": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "devDependencies": {
57
+ "@oh-my-pi/pi-agent-core": "18.12.6",
58
+ "@oh-my-pi/pi-coding-agent": "18.12.6",
59
+ "@oh-my-pi/pi-tui": "18.12.6",
60
+ "@types/bun": "latest",
61
+ "typescript": "latest"
62
+ }
63
+ }
package/src/acquire.ts ADDED
@@ -0,0 +1,182 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { BlockList, isIP } from "node:net";
3
+
4
+ /** RFC 0018 §2: how far a peer may be acquired without a human confirming first. */
5
+ export type AcquirePolicy = "all" | "segment" | "none";
6
+
7
+ /** RFC 0018 §5: host-side acquisition settings. The library never reads them. */
8
+ export interface AcquisitionPolicy {
9
+ policy: AcquirePolicy;
10
+ /** CIDR blocks and host names; only read when `policy` is `segment`. */
11
+ allow: string[];
12
+ }
13
+
14
+ /**
15
+ * The default delegation: loopback only. Discovery excludes loopback by default, so the two
16
+ * defaults together mean a remote peer is neither discovered nor approved (RFC 0018 §5.1).
17
+ */
18
+ export const DEFAULT_ACQUISITION: AcquisitionPolicy = {
19
+ policy: "segment",
20
+ allow: ["127.0.0.0/8", "::1/128"],
21
+ };
22
+
23
+ /** Turns a host name into the addresses it names; an empty list means it names none. */
24
+ export type Resolver = (host: string) => Promise<string[]>;
25
+
26
+ const dnsResolver: Resolver = async host => (await lookup(host, { all: true })).map(row => row.address);
27
+
28
+ /** The configured policy, or the documented default when the config carries none. */
29
+ export function acquisitionOf(config: { acquisition?: AcquisitionPolicy }): AcquisitionPolicy {
30
+ return config.acquisition ?? DEFAULT_ACQUISITION;
31
+ }
32
+
33
+ export type AcquireReason =
34
+ | "policy_all"
35
+ | "policy_segment"
36
+ | "policy_denied_none"
37
+ | "policy_unobserved"
38
+ | "policy_denied_observed"
39
+ | "policy_denied_declared"
40
+ | "policy_denied_recorded";
41
+
42
+ /** RFC 0018 §2.1–2.2: what the host observed, what the peer declared, what discovery recorded. */
43
+ export interface AcquireInput {
44
+ /** The address this statement's connection arrived from; absent when it was not observed. */
45
+ observed?: string;
46
+ /** The `base_url` the peer wrote into its own statement. */
47
+ declared?: string;
48
+ /** The discovery addresses recorded for this node id when the statement arrived. */
49
+ recorded?: readonly string[];
50
+ }
51
+
52
+ export interface AcquireVerdict {
53
+ allowed: boolean;
54
+ reason: AcquireReason;
55
+ /** The allow entry the observed address matched, when one did. */
56
+ matched?: string;
57
+ }
58
+
59
+ interface Block {
60
+ network: string;
61
+ prefix: number;
62
+ type: "ipv4" | "ipv6";
63
+ }
64
+
65
+ const HOST_NAME = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
66
+
67
+ /** One `[acquisition] allow` entry: `network/prefix`, or a host name to accept as written. */
68
+ export function parseAllowBlock(value: string): Block | { name: string } | undefined {
69
+ const trimmed = value.trim();
70
+ if (trimmed.length === 0) return undefined;
71
+ const slash = trimmed.lastIndexOf("/");
72
+ if (slash <= 0) {
73
+ // A name is matched by its own spelling, so listing it involves no resolution. A bare
74
+ // address is not a block: it needs a prefix to say what it covers.
75
+ if (isIP(trimmed) !== 0 || !HOST_NAME.test(trimmed)) return undefined;
76
+ return { name: trimmed.toLowerCase() };
77
+ }
78
+ const prefix = Number(trimmed.slice(slash + 1));
79
+ if (!Number.isInteger(prefix) || prefix < 0) return undefined;
80
+ const network = literalAddress(trimmed.slice(0, slash));
81
+ if (network === undefined) return undefined;
82
+ if (prefix > (network.type === "ipv4" ? 32 : 128)) return undefined;
83
+ return { network: network.address, prefix, type: network.type };
84
+ }
85
+
86
+ /** The host a value names: a URL's hostname, or the value itself when it is already a host. */
87
+ function hostOf(value: string): string | undefined {
88
+ const trimmed = value.trim();
89
+ if (trimmed.length === 0) return undefined;
90
+ if (!trimmed.includes("://")) return trimmed.replace(/^\[(.*)\]$/, "$1");
91
+ try {
92
+ const host = new URL(trimmed).hostname;
93
+ return host.replace(/^\[(.*)\]$/, "$1");
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ /** A host that is already a literal address, with IPv4-mapped IPv6 read as IPv4. */
100
+ function literalAddress(host: string): { address: string; type: "ipv4" | "ipv6" } | undefined {
101
+ const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
102
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
103
+ if (mapped !== null) return { address: mapped[1]!, type: "ipv4" };
104
+ const family = isIP(bare);
105
+ if (family === 4) return { address: bare, type: "ipv4" };
106
+ if (family === 6) return { address: bare, type: "ipv6" };
107
+ return undefined;
108
+ }
109
+
110
+ function blockHolds(address: { address: string; type: "ipv4" | "ipv6" }, block: Block): boolean {
111
+ if (block.type !== address.type) return false;
112
+ const blocks = new BlockList();
113
+ blocks.addSubnet(block.network, block.prefix, block.type);
114
+ return blocks.check(address.address, block.type);
115
+ }
116
+
117
+ /** The allow entry an address falls in, or undefined. */
118
+ function entryFor(address: { address: string; type: "ipv4" | "ipv6" }, allow: readonly string[]): string | undefined {
119
+ for (const entry of allow) {
120
+ const block = parseAllowBlock(entry);
121
+ if (block === undefined || "name" in block) continue;
122
+ if (blockHolds(address, block)) return entry.trim();
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /**
128
+ * The allow entry a value matches, or undefined. A literal address is judged as itself. A host
129
+ * name matches an allow entry of the same name; otherwise it is resolved and every address it
130
+ * names must fall inside the allow set (RFC 0018 §2.1) — a name that resolves to nothing, or to
131
+ * one address outside the set, matches nothing.
132
+ */
133
+ export async function allowMatch(value: string, allow: readonly string[], resolve: Resolver = dnsResolver): Promise<string | undefined> {
134
+ const host = hostOf(value);
135
+ if (host === undefined) return undefined;
136
+ const literal = literalAddress(host);
137
+ if (literal !== undefined) return entryFor(literal, allow);
138
+ const named = allow.find(entry => {
139
+ const block = parseAllowBlock(entry);
140
+ return block !== undefined && "name" in block && block.name === host.toLowerCase();
141
+ });
142
+ if (named !== undefined) return named.trim();
143
+ const resolved = await resolve(host).catch(() => [] as string[]);
144
+ let matched: string | undefined;
145
+ for (const address of resolved) {
146
+ const target = literalAddress(address);
147
+ if (target === undefined) return undefined;
148
+ const entry = entryFor(target, allow);
149
+ if (entry === undefined) return undefined;
150
+ matched ??= entry;
151
+ }
152
+ return matched;
153
+ }
154
+
155
+ /**
156
+ * RFC 0018 §2: decide whether this peer may be approved without a human confirming first.
157
+ *
158
+ * The declared address is checked because the trust row stores it and `peerBase` falls back to
159
+ * it when discovery has no record. The recorded addresses are checked because
160
+ * `discovery.resolve()` re-probes every one of them before it connects, so a record outside the
161
+ * allow set is an outbound request the policy never authorized.
162
+ */
163
+ export async function evaluateAcquisition(
164
+ policy: AcquisitionPolicy,
165
+ input: AcquireInput,
166
+ resolve: Resolver = dnsResolver,
167
+ ): Promise<AcquireVerdict> {
168
+ if (policy.policy === "none") return { allowed: false, reason: "policy_denied_none" };
169
+ if (policy.policy === "all") return { allowed: true, reason: "policy_all" };
170
+ if (input.observed === undefined) return { allowed: false, reason: "policy_unobserved" };
171
+ const observed = await allowMatch(input.observed, policy.allow, resolve);
172
+ if (observed === undefined) return { allowed: false, reason: "policy_denied_observed" };
173
+ if (input.declared !== undefined && await allowMatch(input.declared, policy.allow, resolve) === undefined) {
174
+ return { allowed: false, reason: "policy_denied_declared", matched: observed };
175
+ }
176
+ for (const address of input.recorded ?? []) {
177
+ if (await allowMatch(address, policy.allow, resolve) === undefined) {
178
+ return { allowed: false, reason: "policy_denied_recorded", matched: observed };
179
+ }
180
+ }
181
+ return { allowed: true, reason: "policy_segment", matched: observed };
182
+ }
package/src/binding.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
+ import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
3
+ import { getRuntime, type PluginRuntime } from "./runtime.ts";
4
+
5
+ let registryRegistered = false;
6
+
7
+ /**
8
+ * Task-spawned sessions are bound to the context alias their dispatch named.
9
+ * The name is only the marker: the registry reference must still prove the
10
+ * session is a child of this node's main agent before any record is bound.
11
+ */
12
+ export function registerContextBinding(pi: ExtensionAPI): void {
13
+ pi.on("tool_result", async (event, _ctx) => {
14
+ if (event.toolName !== "task") return;
15
+ const runtime = getRuntime();
16
+ if (runtime === undefined) return;
17
+ try {
18
+ const input = event.input;
19
+ const items = Array.isArray(input.tasks) ? input.tasks : [input];
20
+ const details = event.details as
21
+ | { progress?: unknown; results?: unknown }
22
+ | undefined;
23
+ for (const candidate of [...array(details?.progress), ...array(details?.results)]) {
24
+ const id = stringField(candidate, "id");
25
+ if (id === undefined) continue;
26
+ const index = numberField(candidate, "index");
27
+ const name = index === undefined ? undefined : stringField(items[index], "name");
28
+ const alias = aliasOf(name);
29
+ if (alias === undefined) continue;
30
+ await bind(runtime, alias, id);
31
+ }
32
+ } catch (error) {
33
+ pi.logger.warn("onlyne.context.binding.failed", {
34
+ error: error instanceof Error ? error.message : String(error),
35
+ });
36
+ }
37
+ });
38
+
39
+ if (registryRegistered) return;
40
+ registryRegistered = true;
41
+ AgentRegistry.global().onChange(event => {
42
+ const runtime = getRuntime();
43
+ if (runtime === undefined) return;
44
+ if (event.type === "removed") {
45
+ void updateState(runtime, event.ref.id, "lost").catch(report(pi));
46
+ return;
47
+ }
48
+ if (event.type === "status_changed") {
49
+ void updateState(runtime, event.ref.id, stateOf(event.ref.status)).catch(report(pi));
50
+ return;
51
+ }
52
+ const alias = aliasOf(event.ref.displayName);
53
+ if (alias === undefined) return;
54
+ void bind(runtime, alias, event.ref.id).catch(report(pi));
55
+ });
56
+ }
57
+
58
+ async function bind(
59
+ runtime: PluginRuntime,
60
+ alias: string,
61
+ agentId: string,
62
+ ): Promise<void> {
63
+ const existing = await runtime.contexts.find(alias);
64
+ if (existing === undefined) return;
65
+ if (existing.node_id !== runtime.config.node.id) return;
66
+ if (existing.main_agent_id !== runtime.mainAgentId) return;
67
+ if (existing.agent_id !== undefined && existing.agent_id !== agentId) return;
68
+ if (existing.agent_id === agentId && existing.session_file !== undefined) return;
69
+ const ref = AgentRegistry.global().get(agentId);
70
+ if (ref === undefined || ref.kind !== "sub" || ref.parentId !== runtime.mainAgentId) return;
71
+ await runtime.contexts.bind(alias, {
72
+ agent_id: agentId,
73
+ ...(ref.sessionFile === null || ref.sessionFile === undefined ? {} : { session_file: ref.sessionFile }),
74
+ ...(ref.parentId === undefined ? {} : { parent_id: ref.parentId }),
75
+ });
76
+ }
77
+
78
+ /** An alias-recorded update follows the bound agent id, never a display name. */
79
+ async function updateState(
80
+ runtime: PluginRuntime,
81
+ agentId: string,
82
+ state: "idle" | "running" | "parked" | "lost",
83
+ ): Promise<void> {
84
+ const records = await runtime.contexts.list();
85
+ const record = records.find(candidate =>
86
+ candidate.node_id === runtime.config.node.id &&
87
+ candidate.main_agent_id === runtime.mainAgentId &&
88
+ candidate.agent_id === agentId,
89
+ );
90
+ if (record === undefined) return;
91
+ await runtime.contexts.updateState(record.alias, state);
92
+ }
93
+
94
+ /**
95
+ * The task name carries the alias; the host may append a numeric suffix to a
96
+ * repeated name. Nothing else about the name is trusted.
97
+ */
98
+ function aliasOf(name: string | undefined): string | undefined {
99
+ if (name === undefined) return undefined;
100
+ return /^onlyne-(c-[0-9]+)(?:-[0-9]+)?$/.exec(name)?.[1];
101
+ }
102
+
103
+ function array(value: unknown): unknown[] {
104
+ return Array.isArray(value) ? value : [];
105
+ }
106
+
107
+ function stringField(value: unknown, key: string): string | undefined {
108
+ if (value === null || typeof value !== "object") return undefined;
109
+ const found = (value as Record<string, unknown>)[key];
110
+ return typeof found === "string" && found.length > 0 ? found : undefined;
111
+ }
112
+
113
+ function numberField(value: unknown, key: string): number | undefined {
114
+ if (value === null || typeof value !== "object") return undefined;
115
+ const found = (value as Record<string, unknown>)[key];
116
+ return typeof found === "number" && Number.isInteger(found) ? found : undefined;
117
+ }
118
+
119
+ function stateOf(status: string): "idle" | "running" | "parked" | "lost" {
120
+ if (status === "running") return "running";
121
+ if (status === "parked") return "parked";
122
+ if (status === "aborted") return "lost";
123
+ return "idle";
124
+ }
125
+
126
+ function report(pi: ExtensionAPI): (error: unknown) => void {
127
+ return error => {
128
+ pi.logger.warn("onlyne.context.state.failed", {
129
+ error: error instanceof Error ? error.message : String(error),
130
+ });
131
+ };
132
+ }