pi-onlyne 0.9.1 → 1.0.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,210 @@
1
+ // The relay guard's policy reader and its verdict.
2
+ //
3
+ // The reader is the half that decides whether a workspace guards anything at
4
+ // all: a missing file, a malformed line or an unknown key must leave the guard
5
+ // off (or leave only the sound half of it on) instead of refusing completions
6
+ // on a policy nobody wrote. The client's injected policy outranks the file, and
7
+ // the file is the fallback a manual installation still has.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { afterEach, test } from "node:test";
14
+
15
+ import {
16
+ DEFAULT_RELAY,
17
+ RELAY_ENV_COUNT,
18
+ RELAY_ENV_REQUIRED,
19
+ RELAY_FILE,
20
+ envRelay,
21
+ loadRelay,
22
+ parseRelay,
23
+ relayEnabled,
24
+ relayPath,
25
+ relayRefusal,
26
+ } from "./relay.mjs";
27
+
28
+ const cleanups = [];
29
+ afterEach(() => {
30
+ while (cleanups.length > 0) cleanups.pop()();
31
+ });
32
+
33
+ /** One temp directory holding a `relay.toml` body, when given one. */
34
+ function workspace(body) {
35
+ const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-relay-"));
36
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
37
+ if (body !== undefined) {
38
+ writeFileSync(join(dir, RELAY_FILE), body);
39
+ }
40
+ return dir;
41
+ }
42
+
43
+ test("a missing policy file leaves the guard off", () => {
44
+ const dir = workspace();
45
+ const relay = loadRelay({ path: join(dir, RELAY_FILE), env: {} });
46
+ assert.deepEqual(
47
+ { required: relay.required, count: relay.count },
48
+ { required: DEFAULT_RELAY.required, count: DEFAULT_RELAY.count },
49
+ );
50
+ assert.equal(relay.present, false);
51
+ assert.equal(relay.source, "none");
52
+ assert.equal(relay.warning, null);
53
+ assert.equal(relayEnabled(relay), false);
54
+ assert.equal(relay.path, join(dir, RELAY_FILE));
55
+ });
56
+
57
+ test("the default policy path is the one beside the plugin's package.json", () => {
58
+ const path = relayPath();
59
+ assert.ok(path.endsWith(`/${RELAY_FILE}`), path);
60
+ // A generated workspace loads the vendored copy of this package, so the
61
+ // policy has to travel inside it (`crates/onlyne-server/src/generate.rs`).
62
+ assert.ok(existsSync(join(dirname(path), "package.json")), dirname(path));
63
+ });
64
+
65
+ test("both policy keys are honoured, comments and blank lines included", () => {
66
+ const list = parseRelay('# which handoffs this session owes\nrelay_required = ["writer", "auditor"]\n');
67
+ assert.deepEqual(list.required, ["writer", "auditor"]);
68
+ assert.equal(list.count, null);
69
+ assert.equal(list.warning, null);
70
+
71
+ const counted = parseRelay("relay_required_count = 2 # distinct downstream roles\n");
72
+ assert.deepEqual(counted.required, []);
73
+ assert.equal(counted.count, 2);
74
+ assert.equal(counted.warning, null);
75
+
76
+ const both = parseRelay('relay_required = []\nrelay_required_count = 3\n');
77
+ assert.deepEqual(both.required, []);
78
+ assert.equal(both.count, 3);
79
+ });
80
+
81
+ test("a body outside the closed subset warns and keeps the default", () => {
82
+ const unsupported = parseRelay(
83
+ ["[relay]", 'relay_required = [', ' "writer",', "]", "relay_required_count = 0", "write = true"].join("\n"),
84
+ "relay.toml",
85
+ );
86
+ assert.deepEqual(unsupported.required, []);
87
+ assert.equal(unsupported.count, null);
88
+ assert.match(unsupported.warning, /relay\.toml:1: not a `key = value` line/);
89
+ assert.match(unsupported.warning, /relay\.toml:2: relay_required must be one line/);
90
+ assert.match(unsupported.warning, /relay\.toml:4: not a `key = value` line/);
91
+ assert.match(unsupported.warning, /relay\.toml:5: relay_required_count must be a positive integer/);
92
+ assert.match(unsupported.warning, /relay\.toml:6: unknown key "write"/);
93
+
94
+ // One bad line does not take the sound one with it.
95
+ const partial = parseRelay('relay_required = ["writer"]\nrelay_required_count = two\n');
96
+ assert.deepEqual(partial.required, ["writer"]);
97
+ assert.equal(partial.count, null);
98
+ assert.match(partial.warning, /relay_required_count must be a positive integer/);
99
+ });
100
+
101
+ test("a policy file on disk reaches the caller with its warnings", () => {
102
+ const dir = workspace('relay_required = ["writer"]\nnonsense\n');
103
+ const relay = loadRelay({ path: join(dir, RELAY_FILE), env: {} });
104
+ assert.equal(relay.present, true);
105
+ assert.equal(relay.source, "file");
106
+ assert.deepEqual(relay.required, ["writer"]);
107
+ assert.equal(relayEnabled(relay), true);
108
+ assert.match(relay.warning, /relay\.toml:2:/);
109
+ });
110
+
111
+ test("an injected policy is the one in force, and the file is not consulted", () => {
112
+ const dir = workspace('relay_required = ["legacy"]\n');
113
+ const relay = loadRelay({
114
+ path: join(dir, RELAY_FILE),
115
+ env: { [RELAY_ENV_REQUIRED]: "writer, auditor", [RELAY_ENV_COUNT]: "2" },
116
+ });
117
+ assert.deepEqual(relay.required, ["writer", "auditor"]);
118
+ assert.equal(relay.count, 2);
119
+ assert.equal(relay.source, "env");
120
+ assert.equal(relay.present, false, "the file is not where this policy came from");
121
+ assert.equal(relay.path, join(dir, RELAY_FILE));
122
+ assert.equal(relay.warning, null);
123
+ assert.equal(relayEnabled(relay), true);
124
+ // Both forms travel when the spec names both, and the list still decides.
125
+ assert.match(relayRefusal(relay, ["builder", "auditor"]), /missing handoff to: writer/);
126
+ });
127
+
128
+ test("the environment spells the spec's two keys", () => {
129
+ const listed = envRelay({ [RELAY_ENV_REQUIRED]: "writer,, auditor ," });
130
+ assert.deepEqual(listed.required, ["writer", "auditor"]);
131
+ assert.equal(listed.count, null);
132
+ assert.equal(listed.specified, true);
133
+ assert.equal(listed.warning, null);
134
+
135
+ const counted = envRelay({ [RELAY_ENV_COUNT]: "2" });
136
+ assert.deepEqual(counted.required, []);
137
+ assert.equal(counted.count, 2);
138
+ assert.equal(counted.specified, true);
139
+
140
+ const nothing = envRelay({});
141
+ assert.deepEqual(nothing.required, []);
142
+ assert.equal(nothing.count, null);
143
+ assert.equal(nothing.specified, false, "an absent pair is not a policy");
144
+ assert.equal(nothing.warning, null);
145
+ });
146
+
147
+ test("an unparsable variable is reported and the file keeps its turn", () => {
148
+ const dir = workspace('relay_required = ["legacy"]\n');
149
+ const relay = loadRelay({
150
+ path: join(dir, RELAY_FILE),
151
+ env: { [RELAY_ENV_COUNT]: "two" },
152
+ });
153
+ assert.equal(relay.source, "file");
154
+ assert.equal(relay.present, true);
155
+ assert.deepEqual(relay.required, ["legacy"]);
156
+ assert.equal(relay.count, null);
157
+ assert.equal(relayEnabled(relay), true);
158
+ assert.match(relay.warning, /ONLYNE_RELAY_COUNT must be a positive integer, got "two"/);
159
+
160
+ // A variable that names no role is not a policy either; with no file behind
161
+ // it the guard stays off, and the reason it is off is reported.
162
+ const off = loadRelay({ path: join(workspace(), RELAY_FILE), env: { [RELAY_ENV_REQUIRED]: ", ," } });
163
+ assert.deepEqual(off.required, []);
164
+ assert.equal(off.count, null);
165
+ assert.equal(off.source, "none");
166
+ assert.equal(off.present, false);
167
+ assert.equal(relayEnabled(off), false);
168
+ assert.match(off.warning, /ONLYNE_RELAY_REQUIRED: no role names in ", ,"/);
169
+ });
170
+
171
+ test("the verdict names every missing edge, and the way out", () => {
172
+ const refusal = relayRefusal({ required: ["writer", "auditor"] }, ["auditor"]);
173
+ assert.match(refusal, /^relay guard: missing handoff to: writer \(/);
174
+ assert.match(refusal, /force:true and a non-empty reason/);
175
+ assert.match(refusal, /"relay-guard-forced: <reason>"/);
176
+ assert.equal(relayRefusal({ required: ["writer"] }, ["writer"]), null);
177
+ // The list wins when both keys are present, however many roles were reached.
178
+ assert.match(
179
+ relayRefusal({ required: ["writer"], count: 2 }, ["builder", "auditor"]),
180
+ /missing handoff to: writer/,
181
+ );
182
+ });
183
+
184
+ test("count mode wants distinct downstream roles, not echoes", () => {
185
+ const policy = { required: [], count: 2 };
186
+ const self = "planner";
187
+ const upstream = "supervisor";
188
+ // A send back to the role that assigned this task, and a note to itself, are
189
+ // not handoffs further down the cluster.
190
+ const echoed = relayRefusal(policy, [upstream, self], { role: self, upstream });
191
+ assert.match(echoed, /missing handoff: 2 of 2 required distinct downstream roles/);
192
+ assert.match(echoed, /delivered downstream: none/);
193
+
194
+ const one = relayRefusal(policy, [upstream, self, "builder"], { role: self, upstream });
195
+ assert.match(one, /missing handoff: 1 of 2 required distinct downstream roles/);
196
+ assert.match(one, /delivered downstream: builder/);
197
+
198
+ const enough = relayRefusal(policy, [upstream, self, "builder", "writer"], { role: self, upstream });
199
+ assert.equal(enough, null);
200
+ // Two handoffs to the same role are one edge, not two.
201
+ const repeated = relayRefusal(policy, ["builder", "builder"], { role: self, upstream });
202
+ assert.match(repeated, /missing handoff: 1 of 2/);
203
+ });
204
+
205
+ test("no policy, or a policy that guards nothing, never refuses", () => {
206
+ assert.equal(relayRefusal(DEFAULT_RELAY, []), null);
207
+ assert.equal(relayRefusal({ required: [], count: 0 }, []), null);
208
+ assert.equal(relayRefusal(undefined, []), null);
209
+ assert.equal(relayEnabled({ required: [], count: null }), false);
210
+ });
package/LICENSE DELETED
@@ -1,21 +0,0 @@
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/SPEC.md DELETED
@@ -1,124 +0,0 @@
1
- # pi-onlyne SPEC
2
-
3
- ## Scope
4
-
5
- Pi extension for Onlyne. Onlyne remains a workspace-local IM broker; this extension owns Pi session lifecycle, watch behavior, message injection, send tools, and a small config surface.
6
-
7
- ## v1 Decisions
8
-
9
- - Watch is configurable; default manual.
10
- - `/onlyne` provides argument completions for its supported subcommands, including daemon lifecycle commands.
11
- - `watch on` connects to the workspace-local `.onlyne/run/s`; if unavailable, it starts a Pi-owned workspace daemon.
12
- - `/onlyne daemon start|stop|restart` is the preferred lifecycle surface. Agents must not use ad-hoc `nohup onlyne run`, `pkill -f 'onlyne run'`, or global launchd/systemd jobs when pi-onlyne owns the daemon.
13
- - Inbound events come from Onlyne `subscribe_events`; no polling.
14
- - Inbound mode is rule-based: `auto-handle`, `queue-only`, or `muted`.
15
- - Outbound defaults to `guarded-explicit`: prefer tool reply, fallback to final text, else send configured error text.
16
- - Send tools default to Markdown and may pass `raw_text: true` to Onlyne for literal text.
17
- - Broadcast sends concurrently with per-target retry and per-target results.
18
- - Loopback inbound messages wake Pi without creating a reply obligation.
19
- - `/handshake` inbound messages are Onlyne control messages and must not be surfaced to Pi as agent work.
20
- - After pi surfaces an inbound follow-up, it calls `mark_io_consumed` so Onlyne FIFO `out_cursor = "consume"` stays synchronized with pi notifications.
21
- - FIFO IO itself remains owned by the Onlyne daemon; pi-onlyne does not open `.onlyne/channels/*/in|out` directly.
22
-
23
- ## Config
24
-
25
- Stored in project `.pi/onlyne.json`:
26
-
27
- ```json
28
- {
29
- "watch": { "autoStart": false },
30
- "inbound": { "defaultMode": "auto-handle", "rules": [] },
31
- "outbound": {
32
- "defaultReplyMode": "guarded-explicit",
33
- "guardedExplicit": { "reminders": 2, "noOutputFallbackText": "Onlyne/Pi error: no valid reply was produced." },
34
- "retry": { "attempts": 2, "concurrency": 8 }
35
- },
36
- "swarm_prompt": { "template": "prompts/swarm-task.md" }
37
- }
38
- ```
39
-
40
- - `swarm_prompt` externalizes the swarm task injection wrapper
41
- (`src/swarm-prompt.ts`). `template` is a workspace-relative path whose
42
- content is injected as the followUp message; placeholders `{task_id}`,
43
- `{from}`, `{transfer_send_to}`, `{attempt}`, `{payload}` are substituted.
44
- `false` disables the wrapper (raw payload injected). Omitting the key
45
- keeps the built-in default text. Operators with relay-only nodes
46
- (no history worth restoring) should ship a template that names the role
47
- as the complete instruction set and warns that `onlyne_in/` and
48
- `.onlyne/` are OS pipes (opening one freezes the session).
49
-
50
- ## Tools
51
-
52
- Normal mode (default):
53
-
54
- - `onlyne_reply({ text })`
55
- - `onlyne_send({ channelId, text, rawText? })`
56
- - `onlyne_broadcast({ targets, text, rawText? })`
57
- - `onlyne_loopback({ text, rawText? })`
58
- - `onlyne_mark_no_reply({ reason? })`
59
- - `onlyne_daemon_start/stop/restart`
60
-
61
- Swarm mode (`[swarm] enabled`, see below):
62
-
63
- - `swarm_complete({ text })`
64
- - `swarm_quit({ reason? })`
65
- - `swarm_send({ to, text })`
66
- - `swarm_status()`
67
- - `onlyne_daemon_start/stop/restart`
68
-
69
- One session sees one toolset. The surface is chosen at `session_start` from
70
- `[swarm]` and applied with `setActiveTools` when the Pi API exists.
71
-
72
- ## Deferred
73
-
74
- - Attachments.
75
- - Auth QR/secret editing TUI.
76
- - Schedules.
77
- - Target groups.
78
-
79
- ## Swarm mode (v2, amendment-1)
80
-
81
- - Switch: `/onlyne swarm on|off|status`. On/off persists to the workspace
82
- `.onlyne/config.toml` `[swarm] enabled` flag and restarts watch. Status line
83
- and `session_start` banner report `swarm` vs `ready`.
84
- - When swarm is on, generic in/out auto-handling is disabled: the scheduler owns
85
- input/output. Only loopback messages carrying a `---swarm` body header enter
86
- the session, via the `followUp` task queue.
87
- - Session model: one session carries exactly one scheduler-delivered hop. A
88
- `delivery: scheduler` header field claims the slot; raw `swarm_send` relay
89
- wires and out wires omit it and never claim. Any further delivered header
90
- while claimed is ignored (the scheduler always opens a new session, so this
91
- guard never fires on the normal path). No waiting, no callbacks, no parent
92
- bookkeeping.
93
- - Startup handshake: swarm watch sends the `swarm_ready` op
94
- (`{workspace, terminal_handle}`) so the scheduler can match a pending task.
95
- `ONLYNE_SWARM_TASK` env and `ORCA_TERMINAL_HANDLE`/`ONLYNE_TERMINAL_HANDLE`
96
- provide fallback correlation. **`ONLYNE_SWARM_TASK` is a hard binding:** a
97
- newly created terminal may replay history only for that exact task id. If
98
- its FIFO delivery has not reached history yet, the session waits for the
99
- live inbound event; it never claims a newest/old workspace-local task.
100
- Sessions with no env task (manual ready pool) retain newest-unclosed
101
- fallback replay.
102
- - Hop activity (v0.9.1, R4): `agent_start` with a live claimed task sends
103
- `swarm_busy` (`{workspace, terminal_handle, task_id}`); `agent_end` with
104
- the turn ended and no out sends `swarm_idle` (same fields plus
105
- `pending_exit`, mirroring whether the hop still awaits `swarm_complete`).
106
- Both are fire-and-forget over the existing swarm event channel; the
107
- scheduler treats missing rows as race residue and drives its own TTLs.
108
- - Tools: `swarm_complete({text})` writes the out message carrying this hop's
109
- header (the scheduler's done signal). Scheduler sends a `---swarm-ctl`
110
- recycle wire; pi-onlyne intercepts it, sends `swarm_recycled`, stops the
111
- watch, clears the slot, and exits its own process. `swarm_quit({reason?})`
112
- sends `swarm_recycled` with `quit:<reason>` then self-exits; scheduler marks
113
- the hop failed with no retry. The second empty-exit guard runs this same path.
114
- - `swarm_send({to, text})` spawns a downstream task with
115
- `transfer_send_to` set to the current task and returns the child id without
116
- waiting. `swarm_status()` reports the current task and spawned ids. Daemon
117
- lifecycle tools stay available in both modes.
118
- - Generic send/reply tools are not registered in the swarm surface: unheaded
119
- or misheaded writes would pollute the protocol. All swarm IO goes through
120
- the `swarm_*` tools, whose headers are constructed inside the plugin
121
- (`renderSwarmHeader`).
122
- - Body protocol lives in `src/swarm.ts` (`parseSwarmHeader`,
123
- `renderSwarmHeader`, `readSwarmEnabled`); covered by `test/swarm.test.mjs`.
124
- Old `reply_to` headers parse as ordinary (non-swarm) messages.
package/dist/config.d.ts DELETED
@@ -1,32 +0,0 @@
1
- export type InboundMode = "auto-handle" | "queue-only" | "muted";
2
- export type ReplyMode = "guarded-explicit" | "explicit-only" | "implicit-final";
3
- export interface OnlyneRule {
4
- channel: string;
5
- conversation?: string;
6
- mode: InboundMode;
7
- }
8
- export interface OnlyneConfig {
9
- watch: {
10
- autoStart: boolean;
11
- };
12
- inbound: {
13
- defaultMode: InboundMode;
14
- rules: OnlyneRule[];
15
- };
16
- outbound: {
17
- defaultReplyMode: ReplyMode;
18
- guardedExplicit: {
19
- reminders: number;
20
- noOutputFallbackText: string;
21
- };
22
- retry: {
23
- attempts: number;
24
- concurrency: number;
25
- };
26
- };
27
- }
28
- export declare const defaultConfig: OnlyneConfig;
29
- export declare function configPath(cwd: string): string;
30
- export declare function loadConfig(cwd: string): OnlyneConfig;
31
- export declare function saveConfig(cwd: string, config: OnlyneConfig): void;
32
- export declare function inboundModeFor(config: OnlyneConfig, channel: string, conversation?: string): InboundMode;
package/dist/config.js DELETED
@@ -1,19 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- export const defaultConfig = {
4
- watch: { autoStart: false },
5
- inbound: { defaultMode: "auto-handle", rules: [] },
6
- outbound: { defaultReplyMode: "guarded-explicit", guardedExplicit: { reminders: 2, noOutputFallbackText: "Onlyne/Pi error: no valid reply was produced." }, retry: { attempts: 2, concurrency: 8 } },
7
- };
8
- export function configPath(cwd) { return join(cwd, ".pi", "onlyne.json"); }
9
- export function loadConfig(cwd) {
10
- const path = configPath(cwd);
11
- if (!existsSync(path))
12
- return structuredClone(defaultConfig);
13
- const parsed = JSON.parse(readFileSync(path, "utf8"));
14
- return { ...structuredClone(defaultConfig), ...parsed, watch: { ...defaultConfig.watch, ...parsed.watch }, inbound: { ...defaultConfig.inbound, ...parsed.inbound, rules: parsed.inbound?.rules ?? [] }, outbound: { ...defaultConfig.outbound, ...parsed.outbound, guardedExplicit: { ...defaultConfig.outbound.guardedExplicit, ...parsed.outbound?.guardedExplicit }, retry: { ...defaultConfig.outbound.retry, ...parsed.outbound?.retry } } };
15
- }
16
- export function saveConfig(cwd, config) { const path = configPath(cwd); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); }
17
- export function inboundModeFor(config, channel, conversation) {
18
- return config.inbound.rules.find((r) => r.channel === channel && r.conversation === conversation)?.mode ?? config.inbound.rules.find((r) => r.channel === channel && !r.conversation)?.mode ?? config.inbound.defaultMode;
19
- }
package/dist/index.d.ts DELETED
@@ -1,27 +0,0 @@
1
- import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { type Workspace } from "./workspace.js";
3
- import { type SwarmReportKind } from "./swarm.js";
4
- import { type StateContext } from "./session.js";
5
- /** Swarm task slot: one session carries exactly one hop. No waiting, no callbacks. */
6
- interface SwarmTask {
7
- taskId: string;
8
- from: string;
9
- transferSendTo: string;
10
- attempt: number;
11
- }
12
- export default function onlyne(pi: ExtensionAPI): void;
13
- /** Test seam: drive the lifecycle-report wiring against a stub daemon socket.
14
- * No live pi session and no real daemon are involved; the seam exposes the
15
- * same closures the hooks call. Mirrors `__swarmSlotForTest`. */
16
- export declare function __swarmReportForTest(): {
17
- setWorkspace: (ws: Workspace | null) => void;
18
- beginSession: (appendEntry: StateContext["appendEntry"], taskId: string) => {
19
- generation: number;
20
- seq: number;
21
- };
22
- setTask: (task?: SwarmTask) => void;
23
- reportTaskId: () => string | null;
24
- emit: (kind: SwarmReportKind) => Promise<boolean>;
25
- beat: () => void;
26
- };
27
- export {};