pi-onlyne 0.6.0 → 0.7.3
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 +19 -2
- package/SPEC.md +33 -9
- package/dist/index.js +273 -35
- package/dist/swarm-slot.d.ts +23 -10
- package/dist/swarm-slot.js +31 -12
- package/dist/swarm.d.ts +13 -1
- package/dist/swarm.js +72 -4
- package/dist/workspace.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -122,6 +122,8 @@ The extension merges partial JSON with the defaults. `inbound.rules` accepts cha
|
|
|
122
122
|
|
|
123
123
|
## Agent tools
|
|
124
124
|
|
|
125
|
+
Normal mode:
|
|
126
|
+
|
|
125
127
|
```text
|
|
126
128
|
onlyne_daemon_start()
|
|
127
129
|
onlyne_daemon_stop()
|
|
@@ -131,9 +133,24 @@ onlyne_send({ channelId, text, rawText? })
|
|
|
131
133
|
onlyne_broadcast({ targets, text, rawText? })
|
|
132
134
|
onlyne_loopback({ text, rawText? })
|
|
133
135
|
onlyne_mark_no_reply({ reason? })
|
|
134
|
-
onlyne_swarm_reply({ text, rawText? })
|
|
135
136
|
```
|
|
136
137
|
|
|
138
|
+
Swarm mode (`[swarm] enabled`):
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
onlyne_daemon_start()
|
|
142
|
+
onlyne_daemon_stop()
|
|
143
|
+
onlyne_daemon_restart()
|
|
144
|
+
swarm_complete({ text })
|
|
145
|
+
swarm_quit({ reason? })
|
|
146
|
+
swarm_send({ to, text })
|
|
147
|
+
swarm_status()
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
One session sees one toolset, chosen at session start. Generic send/reply
|
|
151
|
+
tools stay out of the swarm surface so unheaded writes cannot pollute the
|
|
152
|
+
protocol.
|
|
153
|
+
|
|
137
154
|
Messages use Markdown by default. `rawText: true` preserves literal text for scripts and protocol payloads.
|
|
138
155
|
|
|
139
156
|
### Send one message
|
|
@@ -173,7 +190,7 @@ Swarm mode lets `onlyne-swarm` own task routing for a generated agent workspace.
|
|
|
173
190
|
enabled = true
|
|
174
191
|
```
|
|
175
192
|
|
|
176
|
-
A swarm Pi session subscribes to loopback events, reports `swarm_ready`, accepts one
|
|
193
|
+
A swarm Pi session subscribes to loopback events, reports `swarm_ready`, accepts one hop atomically, spawns continuations with `swarm_send` (fire-and-forget), and exits with `swarm_complete` (done signal) or `swarm_quit` (silent, scheduler records failed). Downstream results travel through files and the ledger; nothing waits.
|
|
177
194
|
|
|
178
195
|
For automatic startup in a generated workspace, add `.pi/onlyne.json`:
|
|
179
196
|
|
package/SPEC.md
CHANGED
|
@@ -8,7 +8,7 @@ Pi extension for Onlyne. Onlyne remains a workspace-local IM broker; this extens
|
|
|
8
8
|
|
|
9
9
|
- Watch is configurable; default manual.
|
|
10
10
|
- `/onlyne` provides argument completions for its supported subcommands, including daemon lifecycle commands.
|
|
11
|
-
- `watch on` connects to the workspace-local `.onlyne/run/
|
|
11
|
+
- `watch on` connects to the workspace-local `.onlyne/run/s`; if unavailable, it starts a Pi-owned workspace daemon.
|
|
12
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
13
|
- Inbound events come from Onlyne `subscribe_events`; no polling.
|
|
14
14
|
- Inbound mode is rule-based: `auto-handle`, `queue-only`, or `muted`.
|
|
@@ -38,11 +38,25 @@ Stored in project `.pi/onlyne.json`:
|
|
|
38
38
|
|
|
39
39
|
## Tools
|
|
40
40
|
|
|
41
|
+
Normal mode (default):
|
|
42
|
+
|
|
41
43
|
- `onlyne_reply({ text })`
|
|
42
44
|
- `onlyne_send({ channelId, text, rawText? })`
|
|
43
45
|
- `onlyne_broadcast({ targets, text, rawText? })`
|
|
44
46
|
- `onlyne_loopback({ text, rawText? })`
|
|
45
47
|
- `onlyne_mark_no_reply({ reason? })`
|
|
48
|
+
- `onlyne_daemon_start/stop/restart`
|
|
49
|
+
|
|
50
|
+
Swarm mode (`[swarm] enabled`, see below):
|
|
51
|
+
|
|
52
|
+
- `swarm_complete({ text })`
|
|
53
|
+
- `swarm_quit({ reason? })`
|
|
54
|
+
- `swarm_send({ to, text })`
|
|
55
|
+
- `swarm_status()`
|
|
56
|
+
- `onlyne_daemon_start/stop/restart`
|
|
57
|
+
|
|
58
|
+
One session sees one toolset. The surface is chosen at `session_start` from
|
|
59
|
+
`[swarm]` and applied with `setActiveTools` when the Pi API exists.
|
|
46
60
|
|
|
47
61
|
## Deferred
|
|
48
62
|
|
|
@@ -51,7 +65,7 @@ Stored in project `.pi/onlyne.json`:
|
|
|
51
65
|
- Schedules.
|
|
52
66
|
- Target groups.
|
|
53
67
|
|
|
54
|
-
## Swarm mode (v2)
|
|
68
|
+
## Swarm mode (v2, amendment-1)
|
|
55
69
|
|
|
56
70
|
- Switch: `/onlyne swarm on|off|status`. On/off persists to the workspace
|
|
57
71
|
`.onlyne/config.toml` `[swarm] enabled` flag and restarts watch. Status line
|
|
@@ -59,16 +73,26 @@ Stored in project `.pi/onlyne.json`:
|
|
|
59
73
|
- When swarm is on, generic in/out auto-handling is disabled: the scheduler owns
|
|
60
74
|
input/output. Only loopback messages carrying a `---swarm` body header enter
|
|
61
75
|
the session, via the `followUp` task queue.
|
|
62
|
-
- Session model: one session carries exactly one
|
|
63
|
-
header claims the slot;
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
- Session model: one session carries exactly one hop, no exceptions. A new
|
|
77
|
+
header claims the slot; any further header while claimed is ignored
|
|
78
|
+
(the scheduler always opens a new session, so this guard never fires
|
|
79
|
+
on the normal path). No waiting, no callbacks, no parent bookkeeping.
|
|
66
80
|
- Startup handshake: swarm watch sends the `swarm_ready` op
|
|
67
81
|
(`{workspace, terminal_handle}`) so the scheduler can match a pending task.
|
|
68
82
|
`ONLYNE_SWARM_TASK` env and `ORCA_TERMINAL_HANDLE`/`ONLYNE_TERMINAL_HANDLE`
|
|
69
83
|
provide fallback correlation.
|
|
70
|
-
- Tools: `
|
|
71
|
-
|
|
72
|
-
`
|
|
84
|
+
- Tools: `swarm_complete({text})` writes the out message carrying this hop's
|
|
85
|
+
header (the scheduler's done signal). `swarm_quit({reason?})` exits silently
|
|
86
|
+
(scheduler records failed). `swarm_send({to, text})` spawns a downstream
|
|
87
|
+
task with `transfer_send_to` set to the current task and returns the child
|
|
88
|
+
id without waiting. `swarm_status()` reports the current task and spawned
|
|
89
|
+
ids. Daemon lifecycle tools stay available in both modes.
|
|
90
|
+
- Generic send/reply tools are not registered in the swarm surface: unheaded
|
|
91
|
+
or misheaded writes would pollute the protocol. All swarm IO goes through
|
|
92
|
+
the `swarm_*` tools, whose headers are constructed inside the plugin
|
|
93
|
+
(`renderSwarmHeader`).
|
|
94
|
+
- Exit guard: at `agent_end` with an unfinished hop, one followUp reminder
|
|
95
|
+
fires; a second quiet window auto-runs `swarm_quit` (failed ledger row).
|
|
73
96
|
- Body protocol lives in `src/swarm.ts` (`parseSwarmHeader`,
|
|
74
97
|
`renderSwarmHeader`, `readSwarmEnabled`); covered by `test/swarm.test.mjs`.
|
|
98
|
+
Old `reply_to` headers parse as ordinary (non-swarm) messages.
|
package/dist/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
2
5
|
import { Type } from "typebox";
|
|
3
6
|
import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady } from "./onlyne.js";
|
|
4
7
|
import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
|
|
5
8
|
import { findWorkspace } from "./workspace.js";
|
|
6
|
-
import { envTaskId, parseSwarmHeader, readSwarmEnabled, terminalHandle } from "./swarm.js";
|
|
9
|
+
import { envTaskId, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
|
|
7
10
|
import { SwarmSlot } from "./swarm-slot.js";
|
|
8
11
|
const swarmSlot = new SwarmSlot();
|
|
9
|
-
const
|
|
10
|
-
|
|
12
|
+
const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
|
|
13
|
+
let cachedCtx = undefined;
|
|
11
14
|
const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
|
|
12
15
|
const currentConfig = () => loadConfig(state.cwd);
|
|
13
16
|
function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
|
|
@@ -54,27 +57,120 @@ function scheduleReconnect(pi) {
|
|
|
54
57
|
}
|
|
55
58
|
}, 1000);
|
|
56
59
|
}
|
|
57
|
-
/**
|
|
58
|
-
function
|
|
60
|
+
/** Whether a history task already has an out (done) in this workspace. */
|
|
61
|
+
async function historyTaskDone(taskId) {
|
|
62
|
+
try {
|
|
63
|
+
const { request } = await import("./onlyne.js");
|
|
64
|
+
if (!state.workspace)
|
|
65
|
+
return false;
|
|
66
|
+
const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
|
|
67
|
+
const items = res?.data ?? res ?? [];
|
|
68
|
+
if (!Array.isArray(items))
|
|
69
|
+
return false;
|
|
70
|
+
const outs = new Set();
|
|
71
|
+
for (const m of items) {
|
|
72
|
+
const text = m?.text ?? "";
|
|
73
|
+
if (typeof text !== "string" || !text.startsWith("---swarm"))
|
|
74
|
+
continue;
|
|
75
|
+
if (m?.direction === "outbound") {
|
|
76
|
+
const p = parseSwarmHeader(text);
|
|
77
|
+
if (p)
|
|
78
|
+
outs.add(p.header.task_id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (outs.has(taskId))
|
|
82
|
+
return true;
|
|
83
|
+
// Stale-claim guard: an inbound older than the newest outbound batch
|
|
84
|
+
// belongs to a previous hop generation. Only the scheduler-assigned
|
|
85
|
+
// env task bypasses this check.
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Replay the newest unclaimed swarm task from loopback history. Idempotent.
|
|
93
|
+
* Prefers the scheduler-assigned env task (ONLYNE_SWARM_TASK); never claims
|
|
94
|
+
* an already-done task unless it is the env task (env wins, scheduler owns
|
|
95
|
+
* the truth about what this terminal must run). */
|
|
96
|
+
async function catchUpSwarmHistory(pi, ctx) {
|
|
97
|
+
if (swarmSlot.task().taskId)
|
|
98
|
+
return;
|
|
99
|
+
try {
|
|
100
|
+
const { request } = await import("./onlyne.js");
|
|
101
|
+
if (!state.workspace)
|
|
102
|
+
return;
|
|
103
|
+
const env = envTaskId();
|
|
104
|
+
const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
|
|
105
|
+
const items = res?.data ?? res ?? [];
|
|
106
|
+
if (!Array.isArray(items))
|
|
107
|
+
return;
|
|
108
|
+
const inbounds = [];
|
|
109
|
+
const outs = new Set();
|
|
110
|
+
for (const m of items) {
|
|
111
|
+
const text = m?.text ?? m?.content ?? "";
|
|
112
|
+
if (typeof text !== "string" || !text.startsWith("---swarm"))
|
|
113
|
+
continue;
|
|
114
|
+
const p = parseSwarmHeader(text);
|
|
115
|
+
if (!p)
|
|
116
|
+
continue;
|
|
117
|
+
if (m?.direction === "outbound")
|
|
118
|
+
outs.add(p.header.task_id);
|
|
119
|
+
else
|
|
120
|
+
inbounds.push(text);
|
|
121
|
+
}
|
|
122
|
+
// Env task first: exact task_id match on inbound, even if an out exists
|
|
123
|
+
// (out may belong to a previous generation; scheduler owns the truth).
|
|
124
|
+
if (env) {
|
|
125
|
+
for (const text of inbounds) {
|
|
126
|
+
const p = parseSwarmHeader(text);
|
|
127
|
+
if (p.header.task_id === env) {
|
|
128
|
+
if (handleSwarmInbound(pi, text, undefined, ctx))
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Fallback: newest inbound with no out yet.
|
|
134
|
+
for (let i = inbounds.length - 1; i >= 0; i--) {
|
|
135
|
+
const p = parseSwarmHeader(inbounds[i]);
|
|
136
|
+
if (outs.has(p.header.task_id))
|
|
137
|
+
continue;
|
|
138
|
+
if (p.header.task_id === env)
|
|
139
|
+
continue; // already tried above
|
|
140
|
+
if (handleSwarmInbound(pi, inbounds[i]))
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { /* best effort; live events still arrive */ }
|
|
145
|
+
}
|
|
146
|
+
/** Pin the tab title for a claimed hop. Pi rewrites the create-time title on
|
|
147
|
+
* boot and on idle transitions, and the scheduler-side rename races those
|
|
148
|
+
* writes. Last writer wins, so the session re-asserts its own title on claim
|
|
149
|
+
* and again on agent_end (idle), which is pi's other rewrite point. */
|
|
150
|
+
function pinSwarmTitle(ctx, taskId) {
|
|
151
|
+
try {
|
|
152
|
+
ctx?.ui?.setTitle?.(`swarm:${swarmTreePath()}:${taskId.slice(0, 8)}`);
|
|
153
|
+
}
|
|
154
|
+
catch { /* title is cosmetic */ }
|
|
155
|
+
}
|
|
156
|
+
/** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
|
|
157
|
+
function handleSwarmInbound(pi, text, eventSeq, ctx) {
|
|
59
158
|
const parsed = parseSwarmHeader(text);
|
|
60
159
|
if (!parsed)
|
|
61
160
|
return false;
|
|
62
161
|
if (state.socket && eventSeq !== undefined)
|
|
63
162
|
void consumeEvent(state.socket, eventSeq).catch(() => { });
|
|
64
163
|
// Slot transitions live in SwarmSlot (unit-tested); here we only mirror
|
|
65
|
-
// the
|
|
66
|
-
|
|
67
|
-
const outcome = swarmSlot.handle(pi, text);
|
|
68
|
-
|
|
69
|
-
if (outcome === "claimed") {
|
|
164
|
+
// the claimed task into session state. A claimed session never accepts
|
|
165
|
+
// another task; downstream work spawns new tasks via swarm_send.
|
|
166
|
+
const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined);
|
|
167
|
+
if (outcome === "claimed" || outcome === "yielded") {
|
|
70
168
|
const cur = swarmSlot.task();
|
|
71
|
-
state.swarmTask = { taskId: cur.taskId, from: cur.from,
|
|
72
|
-
|
|
169
|
+
state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
|
|
170
|
+
if (ctx)
|
|
171
|
+
void pinSwarmTitle(ctx, cur.taskId);
|
|
73
172
|
}
|
|
74
|
-
|
|
75
|
-
state.swarmPending = Math.max(0, state.swarmPending - 1);
|
|
76
|
-
}
|
|
77
|
-
return true;
|
|
173
|
+
return outcome === "claimed" || outcome === "yielded";
|
|
78
174
|
}
|
|
79
175
|
async function startWatch(pi) {
|
|
80
176
|
state.workspace = findWorkspace(state.cwd);
|
|
@@ -95,6 +191,9 @@ async function startWatch(pi) {
|
|
|
95
191
|
// Report readiness so the scheduler can match a pending task (fork+exec: any
|
|
96
192
|
// clean ready session on this workspace path may take it).
|
|
97
193
|
const ws = state.workspace;
|
|
194
|
+
// Priority 1 (above default 0): the scheduler consumes at MAX, then we
|
|
195
|
+
// take the event at tier 1 so it also reaches the session. A plain
|
|
196
|
+
// tier-0 subscription would starve whenever the scheduler consumes.
|
|
98
197
|
const socket = subscribe(ws.socketPath, (line) => {
|
|
99
198
|
if (!line?.event)
|
|
100
199
|
return;
|
|
@@ -103,9 +202,14 @@ async function startWatch(pi) {
|
|
|
103
202
|
const inbound = inboundText(line);
|
|
104
203
|
if (!inbound || inbound.channelId !== "loopback")
|
|
105
204
|
return;
|
|
205
|
+
// Catch up on history first: a fresh watch may subscribe after the
|
|
206
|
+
// task was already delivered (scheduler writes in before the
|
|
207
|
+
// session finishes starting). History replay is idempotent via
|
|
208
|
+
// the slot guard + scheduler task_id dedup.
|
|
209
|
+
void catchUpSwarmHistory(pi);
|
|
106
210
|
handleSwarmInbound(pi, inbound.text, line.event_seq);
|
|
107
211
|
}, () => { if (state.socket === socket)
|
|
108
|
-
scheduleReconnect(pi); });
|
|
212
|
+
scheduleReconnect(pi); }, { priority: 1 });
|
|
109
213
|
state.socket = socket;
|
|
110
214
|
state.watching = true;
|
|
111
215
|
const task = envTaskId();
|
|
@@ -113,6 +217,10 @@ async function startWatch(pi) {
|
|
|
113
217
|
await swarmReady(ws.socketPath, ws.root, terminalHandle());
|
|
114
218
|
}
|
|
115
219
|
catch { /* scheduler may read env fallback */ }
|
|
220
|
+
// The task may already sit in history (scheduler wrote in before this
|
|
221
|
+
// session finished starting). Claim it now instead of waiting for a
|
|
222
|
+
// live event that already fired.
|
|
223
|
+
await catchUpSwarmHistory(pi);
|
|
116
224
|
return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
|
|
117
225
|
}
|
|
118
226
|
const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
|
|
@@ -136,7 +244,7 @@ async function startWatch(pi) {
|
|
|
136
244
|
return `watching ${state.workspace.root} (${state.owner})`;
|
|
137
245
|
}
|
|
138
246
|
function stopWatch() { if (state.reconnectTimer)
|
|
139
|
-
clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; state.swarmTask = undefined;
|
|
247
|
+
clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; state.swarmTask = undefined; swarmSlot.clear(); return "watch stopped"; }
|
|
140
248
|
async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
141
249
|
throw new Error("current workspace has no .onlyne configuration"); refreshSwarmFlag(); const conn = await connectDaemon(state.workspace, true); state.owner = conn.owner; state.child = conn.process; return `daemon ${state.owner === "extension" ? "started" : "already running"} for ${state.workspace.root}`; }
|
|
142
250
|
async function stopDaemon() { if (!state.workspace)
|
|
@@ -149,28 +257,103 @@ async function reply(text) { if (!state.workspace)
|
|
|
149
257
|
inbound.replied = true;
|
|
150
258
|
clearReminder();
|
|
151
259
|
} return res; }
|
|
152
|
-
/**
|
|
153
|
-
|
|
260
|
+
/** Resolve the current workspace tree path: segment after /.ws/, root is ".". */
|
|
261
|
+
function swarmTreePath() {
|
|
262
|
+
if (!state.workspace)
|
|
263
|
+
throw new Error("onlyne workspace not found");
|
|
264
|
+
return treePathForWorkspace(state.workspace.root);
|
|
265
|
+
}
|
|
266
|
+
/** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
|
|
267
|
+
async function swarmComplete(text) {
|
|
154
268
|
if (!state.workspace)
|
|
155
269
|
throw new Error("onlyne workspace not found");
|
|
156
270
|
const task = state.swarmTask;
|
|
157
271
|
if (!task)
|
|
158
272
|
throw new Error("no active swarm task");
|
|
159
273
|
const { renderSwarmHeader } = await import("./swarm.js");
|
|
160
|
-
const wire = renderSwarmHeader({ task_id: task.taskId, from:
|
|
161
|
-
const res = await sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, currentConfig().outbound.retry.attempts
|
|
274
|
+
const wire = renderSwarmHeader({ task_id: task.taskId, from: swarmTreePath(), transfer_send_to: task.transferSendTo, attempt: task.attempt }, "", text);
|
|
275
|
+
const res = await sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, currentConfig().outbound.retry.attempts);
|
|
162
276
|
if (res.ok) {
|
|
163
277
|
state.swarmTask = undefined;
|
|
164
|
-
state.swarmPending = 0;
|
|
165
278
|
swarmSlot.clear();
|
|
166
279
|
}
|
|
167
280
|
return { ...res, taskId: task.taskId };
|
|
168
281
|
}
|
|
282
|
+
/** swarm_quit: silent exit. No out written; the scheduler records failed. */
|
|
283
|
+
async function swarmQuit(reason) {
|
|
284
|
+
const task = state.swarmTask;
|
|
285
|
+
state.swarmTask = undefined;
|
|
286
|
+
swarmSlot.clear();
|
|
287
|
+
return { quit: true, taskId: task?.taskId, reason: reason ?? "" };
|
|
288
|
+
}
|
|
289
|
+
/** swarm_send: spawn downstream. New UUID, transfer_send_to = current task, fire-and-forget. */
|
|
290
|
+
async function swarmSend(to, text) {
|
|
291
|
+
if (!state.workspace)
|
|
292
|
+
throw new Error("onlyne workspace not found");
|
|
293
|
+
const task = state.swarmTask;
|
|
294
|
+
if (!task)
|
|
295
|
+
throw new Error("no active swarm task");
|
|
296
|
+
const { renderSwarmHeader } = await import("./swarm.js");
|
|
297
|
+
// Resolve the target workspace dir: root "." or a tree path under the swarm tree.
|
|
298
|
+
// The scheduler owns the tree; here we only verify the send-side symlink exists
|
|
299
|
+
// (missing target = dangling link = error, never a blind FIFO write).
|
|
300
|
+
const wsRoot = state.workspace.root;
|
|
301
|
+
const linkPath = to === "." || to === "_root"
|
|
302
|
+
? join(wsRoot, "onlyne_in", "_root")
|
|
303
|
+
: join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
|
|
304
|
+
const resolved = resolve(wsRoot);
|
|
305
|
+
const linkDir = resolve(linkPath);
|
|
306
|
+
if (!linkDir.startsWith(resolved))
|
|
307
|
+
throw new Error(`invalid swarm_send target: ${to}`);
|
|
308
|
+
if (!existsSync(linkPath))
|
|
309
|
+
throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
|
|
310
|
+
const childId = randomUUID();
|
|
311
|
+
const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
|
|
312
|
+
try {
|
|
313
|
+
writeFileSync(linkPath, wire);
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
throw new Error(`swarm_send failed for ${to}: ${e instanceof Error ? e.message : String(e)}`);
|
|
317
|
+
}
|
|
318
|
+
swarmSlot.noteSpawned(childId);
|
|
319
|
+
return { childId, to };
|
|
320
|
+
}
|
|
321
|
+
/** Swarm mode: apply the workspace snapshot model triple to this session. */
|
|
322
|
+
async function applySwarmModel(pi, ctx) {
|
|
323
|
+
if (!state.swarm || !state.workspace)
|
|
324
|
+
return;
|
|
325
|
+
const m = readSwarmModel(state.workspace.onlyneDir);
|
|
326
|
+
if (!m)
|
|
327
|
+
return;
|
|
328
|
+
if (ctx?.model && m.provider && ctx.model.provider === m.provider && m.model && ctx.model.id === m.model && (!m.effort || m.effort === ctx.thinkingLevel))
|
|
329
|
+
return;
|
|
330
|
+
try {
|
|
331
|
+
if (m.provider && m.model) {
|
|
332
|
+
const found = ctx.modelRegistry?.find?.(m.provider, m.model);
|
|
333
|
+
if (found) {
|
|
334
|
+
const ok = await pi.setModel(found);
|
|
335
|
+
if (!ok) {
|
|
336
|
+
ctx.ui?.notify(`swarm model ${m.provider}/${m.model}: no auth`, "warning");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else
|
|
341
|
+
ctx.ui?.notify(`swarm model ${m.provider}/${m.model} not in registry; keeping default`, "warning");
|
|
342
|
+
}
|
|
343
|
+
const eff = m.effort;
|
|
344
|
+
if (eff && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(eff))
|
|
345
|
+
pi.setThinkingLevel(eff);
|
|
346
|
+
ctx.ui?.setStatus?.("onlyne-model", `model:${m.provider ?? ""}/${m.model ?? ""}${m.effort ? ":" + m.effort : ""}`);
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
ctx.ui?.notify(`swarm model apply failed: ${e}`, "warning");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
169
352
|
export default function onlyne(pi) {
|
|
170
|
-
pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
|
|
353
|
+
pi.on("session_start", async (_event, ctx) => { cachedCtx = ctx; const resumeWatch = state.watching; if (state.owner === "extension")
|
|
171
354
|
await stopDaemon().catch(() => { });
|
|
172
355
|
else
|
|
173
|
-
stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; state.swarmTask = undefined;
|
|
356
|
+
stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; state.swarmTask = undefined; swarmSlot.clear(); refreshSwarmFlag(); applyToolSurface(pi); void applySwarmModel(pi, ctx); ctx.ui.setStatus("onlyne", state.workspace ? (state.swarm ? "onlyne: swarm" : "onlyne: ready") : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
|
|
174
357
|
try {
|
|
175
358
|
ctx.ui.notify(await startWatch(pi), "info");
|
|
176
359
|
}
|
|
@@ -187,7 +370,14 @@ export default function onlyne(pi) {
|
|
|
187
370
|
pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
|
|
188
371
|
state.lastValidOutput = text; });
|
|
189
372
|
pi.on("agent_start", async () => clearReminder());
|
|
190
|
-
pi.on("agent_end", async () =>
|
|
373
|
+
pi.on("agent_end", async () => { if (state.swarm) {
|
|
374
|
+
const cur = swarmSlot.task();
|
|
375
|
+
if (cur.taskId && cachedCtx)
|
|
376
|
+
void pinSwarmTitle(cachedCtx, cur.taskId);
|
|
377
|
+
scheduleSwarmExitReminder(pi);
|
|
378
|
+
}
|
|
379
|
+
else
|
|
380
|
+
scheduleReminder(pi); });
|
|
191
381
|
pi.registerCommand("onlyne", {
|
|
192
382
|
description: "Onlyne watch/status/config commands",
|
|
193
383
|
getArgumentCompletions: (prefix) => {
|
|
@@ -211,8 +401,10 @@ export default function onlyne(pi) {
|
|
|
211
401
|
ctx.ui.notify(await restartDaemon(), "info");
|
|
212
402
|
else if (cmd === "status")
|
|
213
403
|
ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; swarm=${state.swarm ? "on" : "off"}; workspace=${state.workspace?.root ?? "none"}`, "info");
|
|
214
|
-
else if (cmd === "swarm" && sub === "status")
|
|
215
|
-
|
|
404
|
+
else if (cmd === "swarm" && sub === "status") {
|
|
405
|
+
const cur = swarmSlot.task();
|
|
406
|
+
ctx.ui.notify(`swarm=${state.swarm ? "on" : "off"}; task=${cur.taskId ?? "none"}; spawned=${cur.sentChildIds.length}`, "info");
|
|
407
|
+
}
|
|
216
408
|
else if (cmd === "swarm" && (sub === "on" || sub === "off")) {
|
|
217
409
|
ctx.ui.notify(await setSwarm(pi, sub === "on"), "info");
|
|
218
410
|
}
|
|
@@ -230,11 +422,32 @@ export default function onlyne(pi) {
|
|
|
230
422
|
}
|
|
231
423
|
},
|
|
232
424
|
});
|
|
425
|
+
registerNormalTools(pi);
|
|
426
|
+
registerSwarmTools(pi);
|
|
427
|
+
applyToolSurface(pi);
|
|
428
|
+
}
|
|
429
|
+
const NORMAL_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "onlyne_reply", "onlyne_send", "onlyne_broadcast", "onlyne_loopback", "onlyne_mark_no_reply"];
|
|
430
|
+
const SWARM_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "swarm_complete", "swarm_quit", "swarm_send", "swarm_status"];
|
|
431
|
+
/** Tool surface follows [swarm] at session start: one mode, one toolset. */
|
|
432
|
+
function applyToolSurface(pi) {
|
|
433
|
+
try {
|
|
434
|
+
const active = new Set(typeof pi.getActiveTools === "function" ? pi.getActiveTools() : []);
|
|
435
|
+
const all = typeof pi.getAllTools === "function" ? pi.getAllTools().map((t) => t.name) : [];
|
|
436
|
+
const keep = state.swarm ? SWARM_TOOLS : NORMAL_TOOLS;
|
|
437
|
+
const next = [...active].filter((n) => all.includes(n) && !(NORMAL_TOOLS.includes(n) || SWARM_TOOLS.includes(n)));
|
|
438
|
+
for (const n of keep)
|
|
439
|
+
if (all.includes(n) && !next.includes(n))
|
|
440
|
+
next.push(n);
|
|
441
|
+
if (typeof pi.setActiveTools === "function")
|
|
442
|
+
pi.setActiveTools(next);
|
|
443
|
+
}
|
|
444
|
+
catch { /* older pi without tool-surface API: both sets stay registered */ }
|
|
445
|
+
}
|
|
446
|
+
function registerNormalTools(pi) {
|
|
233
447
|
pi.registerTool(defineTool({ name: "onlyne_daemon_start", label: "Onlyne daemon start", description: "Start or connect to the current workspace-local Onlyne daemon managed by pi-onlyne.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await startDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
234
448
|
pi.registerTool(defineTool({ name: "onlyne_daemon_stop", label: "Onlyne daemon stop", description: "Stop the current workspace-local Onlyne daemon when pi-onlyne manages it, without shelling out to pkill/nohup.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await stopDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
235
449
|
pi.registerTool(defineTool({ name: "onlyne_daemon_restart", label: "Onlyne daemon restart", description: "Restart the current workspace-local Onlyne daemon through pi-onlyne lifecycle management.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await restartDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
236
450
|
pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with plain text to the current Onlyne inbound message.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await reply(params.text))); } }));
|
|
237
|
-
pi.registerTool(defineTool({ name: "onlyne_swarm_reply", label: "Onlyne swarm reply", description: "Swarm mode: write the task out message carrying the task header (the success signal) and end the task slot. Use for the active swarm task only.", parameters: Type.Object({ text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmReply(params.text, params.rawText ?? false))); } }));
|
|
238
451
|
pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to the channel's configured Onlyne conversation. Set rawText=true only for literal plain text.", parameters: Type.Object({ channelId: Type.String(), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
239
452
|
throw new Error("onlyne workspace not found"); const res = await sendWithRetry(state.workspace.socketPath, params, params.text, currentConfig().outbound.retry.attempts, params.rawText ?? false); return textResult(JSON.stringify(res), res); } }));
|
|
240
453
|
pi.registerTool(defineTool({ name: "onlyne_broadcast", label: "Onlyne broadcast", description: "Send Markdown to many configured Onlyne channels concurrently. Set rawText=true only for literal plain text.", parameters: Type.Object({ targets: Type.Array(Type.Object({ channelId: Type.String() })), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
@@ -244,18 +457,43 @@ export default function onlyne(pi) {
|
|
|
244
457
|
pi.registerTool(defineTool({ name: "onlyne_mark_no_reply", label: "Onlyne no reply", description: "Mark the current Onlyne inbound message as intentionally not replied.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { if (state.currentInbound) {
|
|
245
458
|
state.currentInbound.noReply = true;
|
|
246
459
|
clearReminder();
|
|
247
|
-
} if (state.swarmTask) {
|
|
248
|
-
state.swarmTask = undefined;
|
|
249
|
-
state.swarmPending = 0;
|
|
250
|
-
swarmSlot.clear();
|
|
251
460
|
} return textResult("marked no reply", params); } }));
|
|
252
461
|
}
|
|
462
|
+
function registerSwarmTools(pi) {
|
|
463
|
+
pi.registerTool(defineTool({ name: "swarm_complete", label: "Swarm complete", description: "Hand over this swarm hop: write the out message carrying this task header (done signal). The session becomes recyclable. Use once per swarm task.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmComplete(params.text))); } }));
|
|
464
|
+
pi.registerTool(defineTool({ name: "swarm_quit", label: "Swarm quit", description: "Quit this swarm hop silently: no out is written and the scheduler records failed. Use when the task premise does not hold or there is nothing to do.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmQuit(params.reason))); } }));
|
|
465
|
+
pi.registerTool(defineTool({ name: "swarm_send", label: "Swarm send", description: "Spawn a downstream swarm task: write a new task to onlyne_in/<to>/ with transfer_send_to set to the current task. Fire-and-forget; returns the child id without waiting. Errors on missing targets.", parameters: Type.Object({ to: Type.String(), text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmSend(params.to, params.text))); } }));
|
|
466
|
+
pi.registerTool(defineTool({ name: "swarm_status", label: "Swarm status", description: "Read-only swarm state: current task id, from, workspace path, and spawned child ids.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const cur = swarmSlot.task(); return textResult(JSON.stringify({ taskId: cur.taskId ?? null, from: cur.from, transferSendTo: cur.transferSendTo, workspace: state.workspace?.root ?? null, spawned: cur.sentChildIds })); } }));
|
|
467
|
+
}
|
|
468
|
+
/** Swarm exit guard: at agent end, an unfinished hop gets one reminder, then auto-quit. */
|
|
469
|
+
function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
470
|
+
clearReminder();
|
|
471
|
+
if (!state.swarmTask)
|
|
472
|
+
return;
|
|
473
|
+
state.reminderTimer = setTimeout(() => {
|
|
474
|
+
state.reminderTimer = undefined;
|
|
475
|
+
if (!state.swarmTask)
|
|
476
|
+
return;
|
|
477
|
+
if (state.swarmTask) {
|
|
478
|
+
{
|
|
479
|
+
const p = pi;
|
|
480
|
+
if (typeof p.sendMessage === "function")
|
|
481
|
+
p.sendMessage({ customType: "onlyne-swarm-reminder", content: `Swarm hop ${state.swarmTask.taskId} has no exit yet. Call swarm_complete with the handover summary, or swarm_quit when there is nothing to do.`, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
482
|
+
else
|
|
483
|
+
pi.sendUserMessage(`Swarm hop ${state.swarmTask.taskId} has no exit yet. Call swarm_complete with the handover summary, or swarm_quit when there is nothing to do.`, { deliverAs: "followUp" });
|
|
484
|
+
}
|
|
485
|
+
state.reminderTimer = setTimeout(() => {
|
|
486
|
+
state.reminderTimer = undefined;
|
|
487
|
+
if (state.swarmTask)
|
|
488
|
+
void swarmQuit("auto-quit: no explicit exit").catch(() => { });
|
|
489
|
+
}, delayMs);
|
|
490
|
+
}
|
|
491
|
+
}, delayMs);
|
|
492
|
+
}
|
|
253
493
|
/** Toggle swarm mode: persists to .onlyne/config.toml [swarm] enabled, restarts watch. */
|
|
254
494
|
async function setSwarm(pi, enabled) {
|
|
255
495
|
if (!state.workspace)
|
|
256
496
|
throw new Error("onlyne workspace not found");
|
|
257
|
-
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
258
|
-
const { join } = await import("node:path");
|
|
259
497
|
const cfgPath = join(state.workspace.onlyneDir, "config.toml");
|
|
260
498
|
let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
261
499
|
if (text.includes("[swarm]")) {
|
package/dist/swarm-slot.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
export type SwarmHandleResult = "claimed" | "
|
|
2
|
-
export interface SwarmSlotMessage {
|
|
3
|
-
text: string;
|
|
4
|
-
deliverAs: "followUp";
|
|
5
|
-
}
|
|
1
|
+
export type SwarmHandleResult = "claimed" | "yielded" | "not-swarm";
|
|
6
2
|
export interface SwarmPi {
|
|
7
|
-
|
|
3
|
+
sendMessage: (message: {
|
|
4
|
+
customType: string;
|
|
5
|
+
content: string;
|
|
6
|
+
display: boolean;
|
|
7
|
+
details?: unknown;
|
|
8
|
+
}, opts: {
|
|
9
|
+
triggerTurn: true;
|
|
8
10
|
deliverAs: "followUp";
|
|
9
11
|
}) => void;
|
|
10
12
|
}
|
|
@@ -16,21 +18,32 @@ export interface SwarmPi {
|
|
|
16
18
|
export declare class SwarmSlot {
|
|
17
19
|
private taskId?;
|
|
18
20
|
private from;
|
|
19
|
-
private
|
|
21
|
+
private transfer;
|
|
20
22
|
private attempt;
|
|
21
|
-
|
|
23
|
+
private sentChildIds;
|
|
24
|
+
handle(pi: SwarmPi, text: string, preferredTaskId?: string): SwarmHandleResult;
|
|
22
25
|
task(): {
|
|
23
26
|
taskId?: string;
|
|
24
27
|
from: string;
|
|
25
|
-
|
|
28
|
+
transferSendTo: string;
|
|
26
29
|
attempt: number;
|
|
30
|
+
sentChildIds: string[];
|
|
27
31
|
};
|
|
32
|
+
noteSpawned(childId: string): void;
|
|
28
33
|
taskIdOf(): string | undefined;
|
|
29
34
|
clear(): void;
|
|
30
35
|
}
|
|
31
36
|
/** Test seam: fresh slot without touching module-global pi-onlyne state. */
|
|
32
37
|
export declare function __swarmSlotForTest(): {
|
|
33
|
-
handle: (pi: SwarmPi, text: string) => SwarmHandleResult;
|
|
38
|
+
handle: (pi: SwarmPi, text: string, preferredTaskId?: string) => SwarmHandleResult;
|
|
39
|
+
task: () => {
|
|
40
|
+
taskId?: string;
|
|
41
|
+
from: string;
|
|
42
|
+
transferSendTo: string;
|
|
43
|
+
attempt: number;
|
|
44
|
+
sentChildIds: string[];
|
|
45
|
+
};
|
|
34
46
|
taskId: () => string | undefined;
|
|
47
|
+
noteSpawned: (childId: string) => void;
|
|
35
48
|
clear: () => void;
|
|
36
49
|
};
|
package/dist/swarm-slot.js
CHANGED
|
@@ -7,9 +7,10 @@ import { parseSwarmHeader } from "./swarm.js";
|
|
|
7
7
|
export class SwarmSlot {
|
|
8
8
|
taskId;
|
|
9
9
|
from = ".";
|
|
10
|
-
|
|
10
|
+
transfer = "";
|
|
11
11
|
attempt = 1;
|
|
12
|
-
|
|
12
|
+
sentChildIds = [];
|
|
13
|
+
handle(pi, text, preferredTaskId) {
|
|
13
14
|
const parsed = parseSwarmHeader(text);
|
|
14
15
|
if (!parsed)
|
|
15
16
|
return "not-swarm";
|
|
@@ -17,20 +18,35 @@ export class SwarmSlot {
|
|
|
17
18
|
if (!this.taskId) {
|
|
18
19
|
this.taskId = header.task_id;
|
|
19
20
|
this.from = header.from;
|
|
20
|
-
this.
|
|
21
|
+
this.transfer = header.transfer_send_to;
|
|
21
22
|
this.attempt = header.attempt;
|
|
22
|
-
|
|
23
|
+
this.sentChildIds = [];
|
|
24
|
+
// Cold start needs triggerTurn: followUp alone only queues behind an
|
|
25
|
+
// active turn, and a fresh session has none. sendMessage with
|
|
26
|
+
// triggerTurn starts the model turn immediately.
|
|
27
|
+
pi.sendMessage({ customType: "onlyne-swarm-task", content: `Onlyne swarm task ${header.task_id} (from ${header.from}):\n\n${payload}\n\nThis session carries this one task only. Restore context from files, work, spawn continuations with swarm_send when another unit must continue, then exit with swarm_complete. Downstream results travel through files and the ledger; nothing waits here.`, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
23
28
|
return "claimed";
|
|
24
29
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
// Env-task preemption: the scheduler injected ONLYNE_SWARM_TASK for
|
|
31
|
+
// this terminal. If the slot holds a stale claim (history catchup
|
|
32
|
+
// grabbed an already-done task) and the env task arrives, yield the
|
|
33
|
+
// slot silently — the stale claim never did work, so nothing is lost.
|
|
34
|
+
if (preferredTaskId && header.task_id === preferredTaskId && this.taskId !== preferredTaskId) {
|
|
35
|
+
this.taskId = header.task_id;
|
|
36
|
+
this.from = header.from;
|
|
37
|
+
this.transfer = header.transfer_send_to;
|
|
38
|
+
this.attempt = header.attempt;
|
|
39
|
+
this.sentChildIds = [];
|
|
40
|
+
pi.sendMessage({ customType: "onlyne-swarm-task", content: `Onlyne swarm task ${header.task_id} (from ${header.from}):\n\n${payload}\n\nThis session carries this one task only. Restore context from files, work, spawn continuations with swarm_send when another unit must continue, then exit with swarm_complete. Downstream results travel through files and the ledger; nothing waits here.`, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
41
|
+
return "yielded";
|
|
28
42
|
}
|
|
29
|
-
|
|
30
|
-
return "busy-ignored";
|
|
43
|
+
return "not-swarm";
|
|
31
44
|
}
|
|
32
45
|
task() {
|
|
33
|
-
return { taskId: this.taskId, from: this.from,
|
|
46
|
+
return { taskId: this.taskId, from: this.from, transferSendTo: this.transfer, attempt: this.attempt, sentChildIds: [...this.sentChildIds] };
|
|
47
|
+
}
|
|
48
|
+
noteSpawned(childId) {
|
|
49
|
+
this.sentChildIds.push(childId);
|
|
34
50
|
}
|
|
35
51
|
taskIdOf() {
|
|
36
52
|
return this.taskId;
|
|
@@ -38,16 +54,19 @@ export class SwarmSlot {
|
|
|
38
54
|
clear() {
|
|
39
55
|
this.taskId = undefined;
|
|
40
56
|
this.from = ".";
|
|
41
|
-
this.
|
|
57
|
+
this.transfer = "";
|
|
42
58
|
this.attempt = 1;
|
|
59
|
+
this.sentChildIds = [];
|
|
43
60
|
}
|
|
44
61
|
}
|
|
45
62
|
/** Test seam: fresh slot without touching module-global pi-onlyne state. */
|
|
46
63
|
export function __swarmSlotForTest() {
|
|
47
64
|
const slot = new SwarmSlot();
|
|
48
65
|
return {
|
|
49
|
-
handle: (pi, text) => slot.handle(pi, text),
|
|
66
|
+
handle: (pi, text, preferredTaskId) => slot.handle(pi, text, preferredTaskId),
|
|
67
|
+
task: () => slot.task(),
|
|
50
68
|
taskId: () => slot.taskIdOf(),
|
|
69
|
+
noteSpawned: (childId) => slot.noteSpawned(childId),
|
|
51
70
|
clear: () => slot.clear(),
|
|
52
71
|
};
|
|
53
72
|
}
|
package/dist/swarm.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export interface SwarmHeader {
|
|
3
3
|
task_id: string;
|
|
4
4
|
from: string;
|
|
5
|
-
|
|
5
|
+
transfer_send_to: string;
|
|
6
6
|
attempt: number;
|
|
7
7
|
}
|
|
8
8
|
export interface SwarmMessage {
|
|
@@ -15,6 +15,18 @@ export declare function parseSwarmHeader(text: string): SwarmMessage | null;
|
|
|
15
15
|
export declare function renderSwarmHeader(header: SwarmHeader, role: string, payloadMarkdown: string): string;
|
|
16
16
|
/** Read the `[swarm] enabled` flag from the workspace .onlyne/config.toml. */
|
|
17
17
|
export declare function readSwarmEnabled(onlyneDir: string): boolean;
|
|
18
|
+
/** Strip // line comments and trailing commas so jsonc parses as JSON. */
|
|
19
|
+
export declare function stripJsonc(text: string): string;
|
|
20
|
+
/** Read the model triple from the workspace .onlyne/swarm.workspace.jsonc snapshot. */
|
|
21
|
+
export declare function readSwarmModel(onlyneDir: string): {
|
|
22
|
+
provider?: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
effort?: string;
|
|
25
|
+
} | null;
|
|
26
|
+
/** Tree path of a workspace inside the swarm tree: the segment after the
|
|
27
|
+
* nearest `/.ws/` marker; the swarm root itself is "." .
|
|
28
|
+
* Pure function so quoting/edge cases are unit-tested without a live session. */
|
|
29
|
+
export declare function treePathForWorkspace(workspaceRoot: string): string;
|
|
18
30
|
/** Terminal handle for swarm_ready correlation (injected by the scheduler). */
|
|
19
31
|
export declare function terminalHandle(): string;
|
|
20
32
|
/** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
|
package/dist/swarm.js
CHANGED
|
@@ -26,8 +26,9 @@ export function parseSwarmHeader(text) {
|
|
|
26
26
|
}
|
|
27
27
|
let task_id;
|
|
28
28
|
let from = ".";
|
|
29
|
-
let
|
|
29
|
+
let transfer_send_to = "";
|
|
30
30
|
let attempt = 1;
|
|
31
|
+
let sawTransfer = false;
|
|
31
32
|
for (const line of headRaw.split("\n")) {
|
|
32
33
|
const t = line.trim();
|
|
33
34
|
if (!t || t.startsWith("#"))
|
|
@@ -41,18 +42,24 @@ export function parseSwarmHeader(text) {
|
|
|
41
42
|
task_id = v;
|
|
42
43
|
else if (k === "from")
|
|
43
44
|
from = v || ".";
|
|
45
|
+
else if (k === "transfer_send_to") {
|
|
46
|
+
transfer_send_to = v;
|
|
47
|
+
sawTransfer = true;
|
|
48
|
+
}
|
|
44
49
|
else if (k === "reply_to")
|
|
45
|
-
|
|
50
|
+
return null;
|
|
46
51
|
else if (k === "attempt")
|
|
47
52
|
attempt = Number.parseInt(v, 10) || 1;
|
|
48
53
|
}
|
|
54
|
+
if (!sawTransfer)
|
|
55
|
+
return null;
|
|
49
56
|
if (!task_id || !UUID_RE.test(task_id.trim()))
|
|
50
57
|
return null;
|
|
51
|
-
return { header: { task_id: task_id.trim(), from,
|
|
58
|
+
return { header: { task_id: task_id.trim(), from, transfer_send_to, attempt }, payload };
|
|
52
59
|
}
|
|
53
60
|
/** Render a swarm body header in front of a Markdown payload. */
|
|
54
61
|
export function renderSwarmHeader(header, role, payloadMarkdown) {
|
|
55
|
-
let s = `---swarm\ntask_id: ${header.task_id}\nfrom: ${header.from}\
|
|
62
|
+
let s = `---swarm\ntask_id: ${header.task_id}\nfrom: ${header.from}\ntransfer_send_to: ${header.transfer_send_to}\nattempt: ${header.attempt}\n---\n`;
|
|
56
63
|
if (role)
|
|
57
64
|
s += `## role: ${role}\n\n${role}\n`;
|
|
58
65
|
s += payloadMarkdown;
|
|
@@ -82,6 +89,67 @@ export function readSwarmEnabled(onlyneDir) {
|
|
|
82
89
|
catch { /* unreadable -> disabled */ }
|
|
83
90
|
return false;
|
|
84
91
|
}
|
|
92
|
+
/** Strip // line comments and trailing commas so jsonc parses as JSON. */
|
|
93
|
+
export function stripJsonc(text) {
|
|
94
|
+
const out = [];
|
|
95
|
+
let inStr = false;
|
|
96
|
+
let esc = false;
|
|
97
|
+
for (const line of text.split("\n")) {
|
|
98
|
+
let res = "";
|
|
99
|
+
for (let i = 0; i < line.length; i++) {
|
|
100
|
+
const c = line[i];
|
|
101
|
+
if (inStr) {
|
|
102
|
+
res += c;
|
|
103
|
+
if (esc)
|
|
104
|
+
esc = false;
|
|
105
|
+
else if (c === "\\")
|
|
106
|
+
esc = true;
|
|
107
|
+
else if (c === '"')
|
|
108
|
+
inStr = false;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (c === '"') {
|
|
112
|
+
inStr = true;
|
|
113
|
+
res += c;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === "/" && line[i + 1] === "/")
|
|
117
|
+
break;
|
|
118
|
+
res += c;
|
|
119
|
+
}
|
|
120
|
+
out.push(res);
|
|
121
|
+
}
|
|
122
|
+
return out.join("\n").replace(/,(\s*[}\]])/g, "$1");
|
|
123
|
+
}
|
|
124
|
+
/** Read the model triple from the workspace .onlyne/swarm.workspace.jsonc snapshot. */
|
|
125
|
+
export function readSwarmModel(onlyneDir) {
|
|
126
|
+
const path = join(onlyneDir, "swarm.workspace.jsonc");
|
|
127
|
+
if (!existsSync(path))
|
|
128
|
+
return null;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(stripJsonc(readFileSync(path, "utf8")));
|
|
131
|
+
const m = parsed?.model;
|
|
132
|
+
if (!m || typeof m !== "object")
|
|
133
|
+
return null;
|
|
134
|
+
const provider = typeof m.provider === "string" && m.provider ? m.provider : undefined;
|
|
135
|
+
const model = typeof m.model === "string" && m.model ? m.model : undefined;
|
|
136
|
+
const effort = typeof m.effort === "string" && m.effort ? m.effort : undefined;
|
|
137
|
+
if (!provider && !model)
|
|
138
|
+
return null;
|
|
139
|
+
return { provider, model, effort };
|
|
140
|
+
}
|
|
141
|
+
catch { /* malformed -> no model override */ }
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/** Tree path of a workspace inside the swarm tree: the segment after the
|
|
145
|
+
* nearest `/.ws/` marker; the swarm root itself is "." .
|
|
146
|
+
* Pure function so quoting/edge cases are unit-tested without a live session. */
|
|
147
|
+
export function treePathForWorkspace(workspaceRoot) {
|
|
148
|
+
const i = workspaceRoot.lastIndexOf("/.ws/");
|
|
149
|
+
if (i >= 0)
|
|
150
|
+
return workspaceRoot.slice(i + "/.ws/".length) || ".";
|
|
151
|
+
return ".";
|
|
152
|
+
}
|
|
85
153
|
/** Terminal handle for swarm_ready correlation (injected by the scheduler). */
|
|
86
154
|
export function terminalHandle() {
|
|
87
155
|
return process.env.ORCA_TERMINAL_HANDLE || process.env.ONLYNE_TERMINAL_HANDLE || "";
|
package/dist/workspace.js
CHANGED
|
@@ -5,7 +5,7 @@ export function findWorkspace(start) {
|
|
|
5
5
|
for (;;) {
|
|
6
6
|
const onlyneDir = join(dir, ".onlyne");
|
|
7
7
|
if (existsSync(onlyneDir))
|
|
8
|
-
return { root: dir, onlyneDir, socketPath: join(onlyneDir, "run", "
|
|
8
|
+
return { root: dir, onlyneDir, socketPath: join(onlyneDir, "run", "s") };
|
|
9
9
|
const parent = dirname(dir);
|
|
10
10
|
if (parent === dir)
|
|
11
11
|
return null;
|