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.
- package/README.md +325 -203
- package/README.zh.md +322 -0
- package/onlyne.json.example +6 -0
- package/package.json +19 -34
- package/relay.toml.example +18 -0
- package/src/agent.live.test.mjs +112 -0
- package/src/agent.mjs +923 -0
- package/src/agent.test.mjs +1079 -0
- package/src/config.mjs +74 -0
- package/src/config.test.mjs +88 -0
- package/src/frame.mjs +99 -0
- package/src/frame.test.mjs +97 -0
- package/src/index.ts +300 -0
- package/src/pi-surface.mjs +140 -0
- package/src/protocol.mjs +364 -0
- package/src/protocol.test.mjs +296 -0
- package/src/relay.mjs +299 -0
- package/src/relay.test.mjs +210 -0
- package/LICENSE +0 -21
- package/SPEC.md +0 -124
- package/dist/config.d.ts +0 -32
- package/dist/config.js +0 -19
- package/dist/index.d.ts +0 -27
- package/dist/index.js +0 -780
- package/dist/onlyne.d.ts +0 -70
- package/dist/onlyne.js +0 -194
- package/dist/session.d.ts +0 -82
- package/dist/session.js +0 -91
- package/dist/swarm-prompt.d.ts +0 -12
- package/dist/swarm-prompt.js +0 -45
- package/dist/swarm-slot.d.ts +0 -43
- package/dist/swarm-slot.js +0 -76
- package/dist/swarm.d.ts +0 -96
- package/dist/swarm.js +0 -296
- package/dist/workspace.d.ts +0 -6
- package/dist/workspace.js +0 -14
|
@@ -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 {};
|