pi-onlyne 0.7.0 → 0.8.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 +10 -2
- package/SPEC.md +21 -8
- package/dist/index.js +221 -22
- package/dist/onlyne.d.ts +1 -0
- package/dist/onlyne.js +3 -0
- package/dist/swarm-prompt.d.ts +12 -0
- package/dist/swarm-prompt.js +45 -0
- package/dist/swarm-slot.d.ts +10 -8
- package/dist/swarm-slot.js +20 -3
- package/dist/swarm.d.ts +21 -0
- package/dist/swarm.js +91 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,6 +151,12 @@ One session sees one toolset, chosen at session start. Generic send/reply
|
|
|
151
151
|
tools stay out of the swarm surface so unheaded writes cannot pollute the
|
|
152
152
|
protocol.
|
|
153
153
|
|
|
154
|
+
Reclaim uses a control wire on the same loopback path: the scheduler writes a
|
|
155
|
+
header-only `---swarm-ctl` message (`op: recycle`), pi-onlyne intercepts it
|
|
156
|
+
before delivery, acks `swarm_recycled { task_id, reason }`, stops watching,
|
|
157
|
+
clears the slot, and exits its own process. The scheduler then closes the
|
|
158
|
+
Orca tab. Missing acks are logged and the tab still closes.
|
|
159
|
+
|
|
154
160
|
Messages use Markdown by default. `rawText: true` preserves literal text for scripts and protocol payloads.
|
|
155
161
|
|
|
156
162
|
### Send one message
|
|
@@ -190,7 +196,7 @@ Swarm mode lets `onlyne-swarm` own task routing for a generated agent workspace.
|
|
|
190
196
|
enabled = true
|
|
191
197
|
```
|
|
192
198
|
|
|
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
|
|
199
|
+
A swarm Pi session subscribes to loopback events, reports `swarm_ready`, accepts one hop atomically, spawns continuations with `swarm_send` (fire-and-forget), and writes `swarm_complete` (done signal). Scheduler then sends a header-only recycle control wire; pi-onlyne intercepts it before model delivery, sends `swarm_recycled`, stops the watch, clears the slot, and self-exits. `swarm_quit` sends the same ack with `quit:<reason>` then self-exits, so the scheduler records failed immediately. Downstream results travel through files and the ledger; nothing waits.
|
|
194
200
|
|
|
195
201
|
For automatic startup in a generated workspace, add `.pi/onlyne.json`:
|
|
196
202
|
|
|
@@ -212,7 +218,9 @@ Pi-side settings live at `.pi/onlyne.json`. Onlyne stores credentials, history,
|
|
|
212
218
|
|
|
213
219
|
## Release notes
|
|
214
220
|
|
|
215
|
-
|
|
221
|
+
The checkout in this repository tracks the published version in `package.json`.
|
|
222
|
+
Run `npm run check` before any release so the build and tests regenerate
|
|
223
|
+
`dist/`. Publish with `npm publish` after reviewing the generated tarball.
|
|
216
224
|
|
|
217
225
|
## Development
|
|
218
226
|
|
package/SPEC.md
CHANGED
|
@@ -32,10 +32,21 @@ Stored in project `.pi/onlyne.json`:
|
|
|
32
32
|
"defaultReplyMode": "guarded-explicit",
|
|
33
33
|
"guardedExplicit": { "reminders": 2, "noOutputFallbackText": "Onlyne/Pi error: no valid reply was produced." },
|
|
34
34
|
"retry": { "attempts": 2, "concurrency": 8 }
|
|
35
|
-
}
|
|
35
|
+
},
|
|
36
|
+
"swarm_prompt": { "template": "prompts/swarm-task.md" }
|
|
36
37
|
}
|
|
37
38
|
```
|
|
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
|
+
|
|
39
50
|
## Tools
|
|
40
51
|
|
|
41
52
|
Normal mode (default):
|
|
@@ -82,17 +93,19 @@ One session sees one toolset. The surface is chosen at `session_start` from
|
|
|
82
93
|
`ONLYNE_SWARM_TASK` env and `ORCA_TERMINAL_HANDLE`/`ONLYNE_TERMINAL_HANDLE`
|
|
83
94
|
provide fallback correlation.
|
|
84
95
|
- Tools: `swarm_complete({text})` writes the out message carrying this hop's
|
|
85
|
-
header (the scheduler's done signal).
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
header (the scheduler's done signal). Scheduler sends a `---swarm-ctl`
|
|
97
|
+
recycle wire; pi-onlyne intercepts it, sends `swarm_recycled`, stops the
|
|
98
|
+
watch, clears the slot, and exits its own process. `swarm_quit({reason?})`
|
|
99
|
+
sends `swarm_recycled` with `quit:<reason>` then self-exits; scheduler marks
|
|
100
|
+
the hop failed with no retry. The second empty-exit guard runs this same path.
|
|
101
|
+
- `swarm_send({to, text})` spawns a downstream task with
|
|
102
|
+
`transfer_send_to` set to the current task and returns the child id without
|
|
103
|
+
waiting. `swarm_status()` reports the current task and spawned ids. Daemon
|
|
104
|
+
lifecycle tools stay available in both modes.
|
|
90
105
|
- Generic send/reply tools are not registered in the swarm surface: unheaded
|
|
91
106
|
or misheaded writes would pollute the protocol. All swarm IO goes through
|
|
92
107
|
the `swarm_*` tools, whose headers are constructed inside the plugin
|
|
93
108
|
(`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).
|
|
96
109
|
- Body protocol lives in `src/swarm.ts` (`parseSwarmHeader`,
|
|
97
110
|
`renderSwarmHeader`, `readSwarmEnabled`); covered by `test/swarm.test.mjs`.
|
|
98
111
|
Old `reply_to` headers parse as ordinary (non-swarm) messages.
|
package/dist/index.js
CHANGED
|
@@ -1,12 +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
|
-
import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady } from "./onlyne.js";
|
|
6
|
+
import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady, swarmRecycled } 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, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
|
|
7
10
|
import { SwarmSlot } from "./swarm-slot.js";
|
|
8
11
|
const swarmSlot = new SwarmSlot();
|
|
9
12
|
const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
|
|
13
|
+
let cachedCtx = undefined;
|
|
10
14
|
const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
|
|
11
15
|
const currentConfig = () => loadConfig(state.cwd);
|
|
12
16
|
function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
|
|
@@ -53,8 +57,108 @@ function scheduleReconnect(pi) {
|
|
|
53
57
|
}
|
|
54
58
|
}, 1000);
|
|
55
59
|
}
|
|
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 (text.startsWith("---swarm-ctl"))
|
|
76
|
+
continue;
|
|
77
|
+
if (m?.direction === "outbound") {
|
|
78
|
+
const p = parseSwarmHeader(text);
|
|
79
|
+
if (p)
|
|
80
|
+
outs.add(p.header.task_id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (outs.has(taskId))
|
|
84
|
+
return true;
|
|
85
|
+
// Stale-claim guard: an inbound older than the newest outbound batch
|
|
86
|
+
// belongs to a previous hop generation. Only the scheduler-assigned
|
|
87
|
+
// env task bypasses this check.
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Replay the newest unclaimed swarm task from loopback history. Idempotent.
|
|
95
|
+
* Prefers the scheduler-assigned env task (ONLYNE_SWARM_TASK); never claims
|
|
96
|
+
* an already-done task unless it is the env task (env wins, scheduler owns
|
|
97
|
+
* the truth about what this terminal must run). */
|
|
98
|
+
async function catchUpSwarmHistory(pi, ctx) {
|
|
99
|
+
if (swarmSlot.task().taskId)
|
|
100
|
+
return;
|
|
101
|
+
try {
|
|
102
|
+
const { request } = await import("./onlyne.js");
|
|
103
|
+
if (!state.workspace)
|
|
104
|
+
return;
|
|
105
|
+
const env = envTaskId();
|
|
106
|
+
const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
|
|
107
|
+
const items = res?.data ?? res ?? [];
|
|
108
|
+
if (!Array.isArray(items))
|
|
109
|
+
return;
|
|
110
|
+
const inbounds = [];
|
|
111
|
+
const outs = new Set();
|
|
112
|
+
for (const m of items) {
|
|
113
|
+
const text = m?.text ?? m?.content ?? "";
|
|
114
|
+
if (typeof text !== "string" || !text.startsWith("---swarm"))
|
|
115
|
+
continue;
|
|
116
|
+
if (text.startsWith("---swarm-ctl"))
|
|
117
|
+
continue;
|
|
118
|
+
const p = parseSwarmHeader(text);
|
|
119
|
+
if (!p)
|
|
120
|
+
continue;
|
|
121
|
+
if (m?.direction === "outbound")
|
|
122
|
+
outs.add(p.header.task_id);
|
|
123
|
+
else
|
|
124
|
+
inbounds.push(text);
|
|
125
|
+
}
|
|
126
|
+
// Env task first: exact task_id match on inbound, even if an out exists
|
|
127
|
+
// (out may belong to a previous generation; scheduler owns the truth).
|
|
128
|
+
if (env) {
|
|
129
|
+
for (const text of inbounds) {
|
|
130
|
+
const p = parseSwarmHeader(text);
|
|
131
|
+
if (p.header.task_id === env) {
|
|
132
|
+
if (handleSwarmInbound(pi, text, undefined, ctx))
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Fallback: newest inbound with no out yet.
|
|
138
|
+
for (let i = inbounds.length - 1; i >= 0; i--) {
|
|
139
|
+
const p = parseSwarmHeader(inbounds[i]);
|
|
140
|
+
if (outs.has(p.header.task_id))
|
|
141
|
+
continue;
|
|
142
|
+
if (p.header.task_id === env)
|
|
143
|
+
continue; // already tried above
|
|
144
|
+
if (handleSwarmInbound(pi, inbounds[i]))
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch { /* best effort; live events still arrive */ }
|
|
149
|
+
}
|
|
150
|
+
/** Pin the tab title for a claimed hop. Pi rewrites the create-time title on
|
|
151
|
+
* boot and on idle transitions, and the scheduler-side rename races those
|
|
152
|
+
* writes. Last writer wins, so the session re-asserts its own title on claim
|
|
153
|
+
* and again on agent_end (idle), which is pi's other rewrite point. */
|
|
154
|
+
function pinSwarmTitle(ctx, taskId) {
|
|
155
|
+
try {
|
|
156
|
+
ctx?.ui?.setTitle?.(`swarm:${swarmTreePath()}:${taskId.slice(0, 8)}`);
|
|
157
|
+
}
|
|
158
|
+
catch { /* title is cosmetic */ }
|
|
159
|
+
}
|
|
56
160
|
/** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
|
|
57
|
-
function handleSwarmInbound(pi, text, eventSeq) {
|
|
161
|
+
function handleSwarmInbound(pi, text, eventSeq, ctx) {
|
|
58
162
|
const parsed = parseSwarmHeader(text);
|
|
59
163
|
if (!parsed)
|
|
60
164
|
return false;
|
|
@@ -63,12 +167,14 @@ function handleSwarmInbound(pi, text, eventSeq) {
|
|
|
63
167
|
// Slot transitions live in SwarmSlot (unit-tested); here we only mirror
|
|
64
168
|
// the claimed task into session state. A claimed session never accepts
|
|
65
169
|
// another task; downstream work spawns new tasks via swarm_send.
|
|
66
|
-
const outcome = swarmSlot.handle(pi, text);
|
|
67
|
-
if (outcome === "claimed") {
|
|
170
|
+
const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined, state.workspace?.root);
|
|
171
|
+
if (outcome === "claimed" || outcome === "yielded") {
|
|
68
172
|
const cur = swarmSlot.task();
|
|
69
173
|
state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
|
|
174
|
+
if (ctx)
|
|
175
|
+
void pinSwarmTitle(ctx, cur.taskId);
|
|
70
176
|
}
|
|
71
|
-
return outcome === "claimed";
|
|
177
|
+
return outcome === "claimed" || outcome === "yielded";
|
|
72
178
|
}
|
|
73
179
|
async function startWatch(pi) {
|
|
74
180
|
state.workspace = findWorkspace(state.cwd);
|
|
@@ -89,6 +195,9 @@ async function startWatch(pi) {
|
|
|
89
195
|
// Report readiness so the scheduler can match a pending task (fork+exec: any
|
|
90
196
|
// clean ready session on this workspace path may take it).
|
|
91
197
|
const ws = state.workspace;
|
|
198
|
+
// Priority 1 (above default 0): the scheduler consumes at MAX, then we
|
|
199
|
+
// take the event at tier 1 so it also reaches the session. A plain
|
|
200
|
+
// tier-0 subscription would starve whenever the scheduler consumes.
|
|
92
201
|
const socket = subscribe(ws.socketPath, (line) => {
|
|
93
202
|
if (!line?.event)
|
|
94
203
|
return;
|
|
@@ -97,9 +206,19 @@ async function startWatch(pi) {
|
|
|
97
206
|
const inbound = inboundText(line);
|
|
98
207
|
if (!inbound || inbound.channelId !== "loopback")
|
|
99
208
|
return;
|
|
209
|
+
// Catch up on history first: a fresh watch may subscribe after the
|
|
210
|
+
// task was already delivered (scheduler writes in before the
|
|
211
|
+
// session finishes starting). History replay is idempotent via
|
|
212
|
+
// the slot guard + scheduler task_id dedup.
|
|
213
|
+
if (parseSwarmCtl(inbound.text)) {
|
|
214
|
+
const ctl = parseSwarmCtl(inbound.text);
|
|
215
|
+
void handleSwarmRecycle(ctl.task_id, ctl.reason);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
void catchUpSwarmHistory(pi);
|
|
100
219
|
handleSwarmInbound(pi, inbound.text, line.event_seq);
|
|
101
220
|
}, () => { if (state.socket === socket)
|
|
102
|
-
scheduleReconnect(pi); });
|
|
221
|
+
scheduleReconnect(pi); }, { priority: 1 });
|
|
103
222
|
state.socket = socket;
|
|
104
223
|
state.watching = true;
|
|
105
224
|
const task = envTaskId();
|
|
@@ -107,6 +226,10 @@ async function startWatch(pi) {
|
|
|
107
226
|
await swarmReady(ws.socketPath, ws.root, terminalHandle());
|
|
108
227
|
}
|
|
109
228
|
catch { /* scheduler may read env fallback */ }
|
|
229
|
+
// The task may already sit in history (scheduler wrote in before this
|
|
230
|
+
// session finished starting). Claim it now instead of waiting for a
|
|
231
|
+
// live event that already fired.
|
|
232
|
+
await catchUpSwarmHistory(pi);
|
|
110
233
|
return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
|
|
111
234
|
}
|
|
112
235
|
const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
|
|
@@ -143,12 +266,11 @@ async function reply(text) { if (!state.workspace)
|
|
|
143
266
|
inbound.replied = true;
|
|
144
267
|
clearReminder();
|
|
145
268
|
} return res; }
|
|
146
|
-
/** Resolve the current workspace tree path
|
|
269
|
+
/** Resolve the current workspace tree path: segment after /.ws/, root is ".". */
|
|
147
270
|
function swarmTreePath() {
|
|
148
271
|
if (!state.workspace)
|
|
149
272
|
throw new Error("onlyne workspace not found");
|
|
150
|
-
|
|
151
|
-
return state.workspace.root === process.cwd() ? "." : basename(state.workspace.root);
|
|
273
|
+
return treePathForWorkspace(state.workspace.root);
|
|
152
274
|
}
|
|
153
275
|
/** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
|
|
154
276
|
async function swarmComplete(text) {
|
|
@@ -166,12 +288,54 @@ async function swarmComplete(text) {
|
|
|
166
288
|
}
|
|
167
289
|
return { ...res, taskId: task.taskId };
|
|
168
290
|
}
|
|
169
|
-
/** swarm_quit:
|
|
291
|
+
/** swarm_quit: speak, then die by our own hand. Sends the exit notice
|
|
292
|
+
* (same op the recycle path uses, reason quit:<reason>) so the scheduler
|
|
293
|
+
* records failed immediately instead of leaving the task pinned running;
|
|
294
|
+
* then releases the watch and exits this process. The scheduler only ever
|
|
295
|
+
* closes the tab. */
|
|
170
296
|
async function swarmQuit(reason) {
|
|
171
297
|
const task = state.swarmTask;
|
|
298
|
+
const taskId = task?.taskId ?? "";
|
|
299
|
+
const why = `quit:${reason ?? ""}`;
|
|
300
|
+
try {
|
|
301
|
+
if (state.workspace && taskId) {
|
|
302
|
+
await swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch { /* daemon may be gone; exit anyway */ }
|
|
306
|
+
clearReminder();
|
|
307
|
+
try {
|
|
308
|
+
stopWatch();
|
|
309
|
+
}
|
|
310
|
+
catch { /* already stopped */ }
|
|
172
311
|
state.swarmTask = undefined;
|
|
173
312
|
swarmSlot.clear();
|
|
174
|
-
|
|
313
|
+
setTimeout(() => process.exit(0), 300);
|
|
314
|
+
return { quit: true, taskId, reason: reason ?? "" };
|
|
315
|
+
}
|
|
316
|
+
/** Handle a downlink recycle signal: accept, ack, release, self-terminate.
|
|
317
|
+
* No out is written (the scheduler already recorded the terminal state). */
|
|
318
|
+
async function handleSwarmRecycle(ctlTaskId, reason) {
|
|
319
|
+
const claimed = swarmSlot.task().taskId;
|
|
320
|
+
const taskId = ctlTaskId === "*" ? (claimed ?? "") : ctlTaskId;
|
|
321
|
+
if (claimed && ctlTaskId !== "*" && claimed !== ctlTaskId)
|
|
322
|
+
return false;
|
|
323
|
+
const why = `recycle:${reason}`;
|
|
324
|
+
try {
|
|
325
|
+
if (state.workspace && taskId) {
|
|
326
|
+
await swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch { /* daemon may be gone; exit anyway */ }
|
|
330
|
+
clearReminder();
|
|
331
|
+
try {
|
|
332
|
+
stopWatch();
|
|
333
|
+
}
|
|
334
|
+
catch { /* already stopped */ }
|
|
335
|
+
state.swarmTask = undefined;
|
|
336
|
+
swarmSlot.clear();
|
|
337
|
+
setTimeout(() => process.exit(0), 300);
|
|
338
|
+
return true;
|
|
175
339
|
}
|
|
176
340
|
/** swarm_send: spawn downstream. New UUID, transfer_send_to = current task, fire-and-forget. */
|
|
177
341
|
async function swarmSend(to, text) {
|
|
@@ -180,10 +344,7 @@ async function swarmSend(to, text) {
|
|
|
180
344
|
const task = state.swarmTask;
|
|
181
345
|
if (!task)
|
|
182
346
|
throw new Error("no active swarm task");
|
|
183
|
-
const { randomUUID } = await import("node:crypto");
|
|
184
347
|
const { renderSwarmHeader } = await import("./swarm.js");
|
|
185
|
-
const { existsSync } = await import("node:fs");
|
|
186
|
-
const { join, resolve } = await import("node:path");
|
|
187
348
|
// Resolve the target workspace dir: root "." or a tree path under the swarm tree.
|
|
188
349
|
// The scheduler owns the tree; here we only verify the send-side symlink exists
|
|
189
350
|
// (missing target = dangling link = error, never a blind FIFO write).
|
|
@@ -199,7 +360,6 @@ async function swarmSend(to, text) {
|
|
|
199
360
|
throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
|
|
200
361
|
const childId = randomUUID();
|
|
201
362
|
const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
|
|
202
|
-
const { writeFileSync } = await import("node:fs");
|
|
203
363
|
try {
|
|
204
364
|
writeFileSync(linkPath, wire);
|
|
205
365
|
}
|
|
@@ -209,11 +369,42 @@ async function swarmSend(to, text) {
|
|
|
209
369
|
swarmSlot.noteSpawned(childId);
|
|
210
370
|
return { childId, to };
|
|
211
371
|
}
|
|
372
|
+
/** Swarm mode: apply the workspace snapshot model triple to this session. */
|
|
373
|
+
async function applySwarmModel(pi, ctx) {
|
|
374
|
+
if (!state.swarm || !state.workspace)
|
|
375
|
+
return;
|
|
376
|
+
const m = readSwarmModel(state.workspace.onlyneDir);
|
|
377
|
+
if (!m)
|
|
378
|
+
return;
|
|
379
|
+
if (ctx?.model && m.provider && ctx.model.provider === m.provider && m.model && ctx.model.id === m.model && (!m.effort || m.effort === ctx.thinkingLevel))
|
|
380
|
+
return;
|
|
381
|
+
try {
|
|
382
|
+
if (m.provider && m.model) {
|
|
383
|
+
const found = ctx.modelRegistry?.find?.(m.provider, m.model);
|
|
384
|
+
if (found) {
|
|
385
|
+
const ok = await pi.setModel(found);
|
|
386
|
+
if (!ok) {
|
|
387
|
+
ctx.ui?.notify(`swarm model ${m.provider}/${m.model}: no auth`, "warning");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else
|
|
392
|
+
ctx.ui?.notify(`swarm model ${m.provider}/${m.model} not in registry; keeping default`, "warning");
|
|
393
|
+
}
|
|
394
|
+
const eff = m.effort;
|
|
395
|
+
if (eff && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(eff))
|
|
396
|
+
pi.setThinkingLevel(eff);
|
|
397
|
+
ctx.ui?.setStatus?.("onlyne-model", `model:${m.provider ?? ""}/${m.model ?? ""}${m.effort ? ":" + m.effort : ""}`);
|
|
398
|
+
}
|
|
399
|
+
catch (e) {
|
|
400
|
+
ctx.ui?.notify(`swarm model apply failed: ${e}`, "warning");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
212
403
|
export default function onlyne(pi) {
|
|
213
|
-
pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
|
|
404
|
+
pi.on("session_start", async (_event, ctx) => { cachedCtx = ctx; const resumeWatch = state.watching; if (state.owner === "extension")
|
|
214
405
|
await stopDaemon().catch(() => { });
|
|
215
406
|
else
|
|
216
|
-
stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; state.swarmTask = undefined; swarmSlot.clear(); refreshSwarmFlag(); applyToolSurface(pi); ctx.ui.setStatus("onlyne", state.workspace ? (state.swarm ? "onlyne: swarm" : "onlyne: ready") : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
|
|
407
|
+
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) {
|
|
217
408
|
try {
|
|
218
409
|
ctx.ui.notify(await startWatch(pi), "info");
|
|
219
410
|
}
|
|
@@ -230,8 +421,12 @@ export default function onlyne(pi) {
|
|
|
230
421
|
pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
|
|
231
422
|
state.lastValidOutput = text; });
|
|
232
423
|
pi.on("agent_start", async () => clearReminder());
|
|
233
|
-
pi.on("agent_end", async () => { if (state.swarm)
|
|
424
|
+
pi.on("agent_end", async () => { if (state.swarm) {
|
|
425
|
+
const cur = swarmSlot.task();
|
|
426
|
+
if (cur.taskId && cachedCtx)
|
|
427
|
+
void pinSwarmTitle(cachedCtx, cur.taskId);
|
|
234
428
|
scheduleSwarmExitReminder(pi);
|
|
429
|
+
}
|
|
235
430
|
else
|
|
236
431
|
scheduleReminder(pi); });
|
|
237
432
|
pi.registerCommand("onlyne", {
|
|
@@ -331,7 +526,13 @@ function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
|
331
526
|
if (!state.swarmTask)
|
|
332
527
|
return;
|
|
333
528
|
if (state.swarmTask) {
|
|
334
|
-
|
|
529
|
+
{
|
|
530
|
+
const p = pi;
|
|
531
|
+
if (typeof p.sendMessage === "function")
|
|
532
|
+
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" });
|
|
533
|
+
else
|
|
534
|
+
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" });
|
|
535
|
+
}
|
|
335
536
|
state.reminderTimer = setTimeout(() => {
|
|
336
537
|
state.reminderTimer = undefined;
|
|
337
538
|
if (state.swarmTask)
|
|
@@ -344,8 +545,6 @@ function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
|
344
545
|
async function setSwarm(pi, enabled) {
|
|
345
546
|
if (!state.workspace)
|
|
346
547
|
throw new Error("onlyne workspace not found");
|
|
347
|
-
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
348
|
-
const { join } = await import("node:path");
|
|
349
548
|
const cfgPath = join(state.workspace.onlyneDir, "config.toml");
|
|
350
549
|
let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
351
550
|
if (text.includes("[swarm]")) {
|
package/dist/onlyne.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean):
|
|
|
34
34
|
export declare function shutdownDaemon(ws: Workspace, child?: ChildProcess): Promise<void>;
|
|
35
35
|
export declare function stopProcess(child?: ChildProcess): void;
|
|
36
36
|
export declare function swarmReady(socketPath: string, workspace: string, terminalHandle: string): Promise<any>;
|
|
37
|
+
export declare function swarmRecycled(socketPath: string, taskId: string, terminalHandle: string, reason: string): Promise<any>;
|
|
37
38
|
export declare function consumeEvent(socket: Socket, eventSeq: number): Promise<void>;
|
|
38
39
|
export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
|
|
39
40
|
export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
|
package/dist/onlyne.js
CHANGED
|
@@ -112,6 +112,9 @@ catch { /* ignore */ } }
|
|
|
112
112
|
export async function swarmReady(socketPath, workspace, terminalHandle) {
|
|
113
113
|
return request(socketPath, { id: `swarm-ready-${Date.now()}`, op: "swarm_ready", text: JSON.stringify({ workspace, terminal_handle: terminalHandle }) });
|
|
114
114
|
}
|
|
115
|
+
export async function swarmRecycled(socketPath, taskId, terminalHandle, reason) {
|
|
116
|
+
return request(socketPath, { id: `swarm-recycled-${Date.now()}`, op: "swarm_recycled", text: JSON.stringify({ task_id: taskId, terminal_handle: terminalHandle, reason }) });
|
|
117
|
+
}
|
|
115
118
|
export async function consumeEvent(socket, eventSeq) {
|
|
116
119
|
return new Promise((resolve) => { try {
|
|
117
120
|
socket.write(`${JSON.stringify({ id: `consume-${Date.now()}`, op: "consume", event_seq: eventSeq })}\n`, () => resolve());
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SwarmPromptConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
template?: string;
|
|
4
|
+
fallback?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function readSwarmPromptConfig(workspaceRoot: string): SwarmPromptConfig;
|
|
7
|
+
export declare function resolveSwarmPrompt(workspaceRoot: string, header: {
|
|
8
|
+
task_id: string;
|
|
9
|
+
from: string;
|
|
10
|
+
transfer_send_to: string;
|
|
11
|
+
attempt: number;
|
|
12
|
+
}, payload: string): string | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
const DEFAULT_PROMPT = `Onlyne swarm task {task_id} (from {from}):
|
|
4
|
+
|
|
5
|
+
{payload}
|
|
6
|
+
|
|
7
|
+
This 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.`;
|
|
8
|
+
export function readSwarmPromptConfig(workspaceRoot) {
|
|
9
|
+
const cfgPath = join(workspaceRoot, ".pi", "onlyne.json");
|
|
10
|
+
if (!existsSync(cfgPath)) {
|
|
11
|
+
return { enabled: true, fallback: DEFAULT_PROMPT };
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
15
|
+
const sw = parsed?.swarm_prompt;
|
|
16
|
+
if (sw === false || (sw && sw.enabled === false))
|
|
17
|
+
return { enabled: false, fallback: DEFAULT_PROMPT };
|
|
18
|
+
return {
|
|
19
|
+
enabled: sw?.enabled !== false,
|
|
20
|
+
template: sw?.template,
|
|
21
|
+
fallback: sw?.fallback ?? DEFAULT_PROMPT,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { enabled: true, fallback: DEFAULT_PROMPT };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function resolveSwarmPrompt(workspaceRoot, header, payload) {
|
|
29
|
+
const cfg = readSwarmPromptConfig(workspaceRoot);
|
|
30
|
+
if (!cfg.enabled)
|
|
31
|
+
return null;
|
|
32
|
+
let tmpl = cfg.fallback ?? DEFAULT_PROMPT;
|
|
33
|
+
if (cfg.template) {
|
|
34
|
+
const abs = join(workspaceRoot, cfg.template);
|
|
35
|
+
if (existsSync(abs)) {
|
|
36
|
+
tmpl = readFileSync(abs, "utf8");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return tmpl
|
|
40
|
+
.replace(/{task_id}/g, header.task_id)
|
|
41
|
+
.replace(/{from}/g, header.from)
|
|
42
|
+
.replace(/{transfer_send_to}/g, header.transfer_send_to)
|
|
43
|
+
.replace(/{attempt}/g, String(header.attempt))
|
|
44
|
+
.replace(/{payload}/g, payload);
|
|
45
|
+
}
|
package/dist/swarm-slot.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
export type SwarmHandleResult = "claimed" | "not-swarm";
|
|
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
|
}
|
|
@@ -19,7 +21,7 @@ export declare class SwarmSlot {
|
|
|
19
21
|
private transfer;
|
|
20
22
|
private attempt;
|
|
21
23
|
private sentChildIds;
|
|
22
|
-
handle(pi: SwarmPi, text: string): SwarmHandleResult;
|
|
24
|
+
handle(pi: SwarmPi, text: string, preferredTaskId?: string, workspaceRoot?: string): SwarmHandleResult;
|
|
23
25
|
task(): {
|
|
24
26
|
taskId?: string;
|
|
25
27
|
from: string;
|
|
@@ -33,7 +35,7 @@ export declare class SwarmSlot {
|
|
|
33
35
|
}
|
|
34
36
|
/** Test seam: fresh slot without touching module-global pi-onlyne state. */
|
|
35
37
|
export declare function __swarmSlotForTest(): {
|
|
36
|
-
handle: (pi: SwarmPi, text: string) => SwarmHandleResult;
|
|
38
|
+
handle: (pi: SwarmPi, text: string, preferredTaskId?: string, workspaceRoot?: string) => SwarmHandleResult;
|
|
37
39
|
task: () => {
|
|
38
40
|
taskId?: string;
|
|
39
41
|
from: string;
|
package/dist/swarm-slot.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseSwarmHeader } from "./swarm.js";
|
|
2
|
+
import { resolveSwarmPrompt } from "./swarm-prompt.js";
|
|
2
3
|
/**
|
|
3
4
|
* Single atomic task slot for swarm mode, extracted for unit testing.
|
|
4
5
|
* index.ts delegates its state transitions here; the only coupling is the
|
|
@@ -10,7 +11,7 @@ export class SwarmSlot {
|
|
|
10
11
|
transfer = "";
|
|
11
12
|
attempt = 1;
|
|
12
13
|
sentChildIds = [];
|
|
13
|
-
handle(pi, text) {
|
|
14
|
+
handle(pi, text, preferredTaskId, workspaceRoot) {
|
|
14
15
|
const parsed = parseSwarmHeader(text);
|
|
15
16
|
if (!parsed)
|
|
16
17
|
return "not-swarm";
|
|
@@ -21,9 +22,25 @@ export class SwarmSlot {
|
|
|
21
22
|
this.transfer = header.transfer_send_to;
|
|
22
23
|
this.attempt = header.attempt;
|
|
23
24
|
this.sentChildIds = [];
|
|
24
|
-
|
|
25
|
+
// Cold start needs triggerTurn: followUp alone only queues behind an
|
|
26
|
+
// active turn, and a fresh session has none. sendMessage with
|
|
27
|
+
// triggerTurn starts the model turn immediately.
|
|
28
|
+
pi.sendMessage({ customType: "onlyne-swarm-task", content: resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
25
29
|
return "claimed";
|
|
26
30
|
}
|
|
31
|
+
// Env-task preemption: the scheduler injected ONLYNE_SWARM_TASK for
|
|
32
|
+
// this terminal. If the slot holds a stale claim (history catchup
|
|
33
|
+
// grabbed an already-done task) and the env task arrives, yield the
|
|
34
|
+
// slot silently — the stale claim never did work, so nothing is lost.
|
|
35
|
+
if (preferredTaskId && header.task_id === preferredTaskId && this.taskId !== preferredTaskId) {
|
|
36
|
+
this.taskId = header.task_id;
|
|
37
|
+
this.from = header.from;
|
|
38
|
+
this.transfer = header.transfer_send_to;
|
|
39
|
+
this.attempt = header.attempt;
|
|
40
|
+
this.sentChildIds = [];
|
|
41
|
+
pi.sendMessage({ customType: "onlyne-swarm-task", content: resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
42
|
+
return "yielded";
|
|
43
|
+
}
|
|
27
44
|
return "not-swarm";
|
|
28
45
|
}
|
|
29
46
|
task() {
|
|
@@ -47,7 +64,7 @@ export class SwarmSlot {
|
|
|
47
64
|
export function __swarmSlotForTest() {
|
|
48
65
|
const slot = new SwarmSlot();
|
|
49
66
|
return {
|
|
50
|
-
handle: (pi, text) => slot.handle(pi, text),
|
|
67
|
+
handle: (pi, text, preferredTaskId, workspaceRoot) => slot.handle(pi, text, preferredTaskId, workspaceRoot),
|
|
51
68
|
task: () => slot.task(),
|
|
52
69
|
taskId: () => slot.taskIdOf(),
|
|
53
70
|
noteSpawned: (childId) => slot.noteSpawned(childId),
|
package/dist/swarm.d.ts
CHANGED
|
@@ -11,10 +11,31 @@ export interface SwarmMessage {
|
|
|
11
11
|
}
|
|
12
12
|
/** Parse a `---swarm` body header. Returns null for non-swarm messages. */
|
|
13
13
|
export declare function parseSwarmHeader(text: string): SwarmMessage | null;
|
|
14
|
+
/** Downlink control wire (scheduler -> session). Header-only marker the
|
|
15
|
+
* session intercepts before the model sees anything. `task_id` is "*" for
|
|
16
|
+
* "whatever this session holds". */
|
|
17
|
+
export interface SwarmCtl {
|
|
18
|
+
op: string;
|
|
19
|
+
task_id: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function parseSwarmCtl(text: string): SwarmCtl | null;
|
|
14
23
|
/** Render a swarm body header in front of a Markdown payload. */
|
|
15
24
|
export declare function renderSwarmHeader(header: SwarmHeader, role: string, payloadMarkdown: string): string;
|
|
16
25
|
/** Read the `[swarm] enabled` flag from the workspace .onlyne/config.toml. */
|
|
17
26
|
export declare function readSwarmEnabled(onlyneDir: string): boolean;
|
|
27
|
+
/** Strip // line comments and trailing commas so jsonc parses as JSON. */
|
|
28
|
+
export declare function stripJsonc(text: string): string;
|
|
29
|
+
/** Read the model triple from the workspace .onlyne/swarm.workspace.jsonc snapshot. */
|
|
30
|
+
export declare function readSwarmModel(onlyneDir: string): {
|
|
31
|
+
provider?: string;
|
|
32
|
+
model?: string;
|
|
33
|
+
effort?: string;
|
|
34
|
+
} | null;
|
|
35
|
+
/** Tree path of a workspace inside the swarm tree: the segment after the
|
|
36
|
+
* nearest `/.ws/` marker; the swarm root itself is "." .
|
|
37
|
+
* Pure function so quoting/edge cases are unit-tested without a live session. */
|
|
38
|
+
export declare function treePathForWorkspace(workspaceRoot: string): string;
|
|
18
39
|
/** Terminal handle for swarm_ready correlation (injected by the scheduler). */
|
|
19
40
|
export declare function terminalHandle(): string;
|
|
20
41
|
/** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
|
package/dist/swarm.js
CHANGED
|
@@ -57,6 +57,36 @@ export function parseSwarmHeader(text) {
|
|
|
57
57
|
return null;
|
|
58
58
|
return { header: { task_id: task_id.trim(), from, transfer_send_to, attempt }, payload };
|
|
59
59
|
}
|
|
60
|
+
export function parseSwarmCtl(text) {
|
|
61
|
+
if (!text.startsWith("---swarm-ctl\n"))
|
|
62
|
+
return null;
|
|
63
|
+
const rest = text.slice("---swarm-ctl\n".length);
|
|
64
|
+
const end = rest.indexOf("\n---");
|
|
65
|
+
if (end < 0)
|
|
66
|
+
return null;
|
|
67
|
+
let op;
|
|
68
|
+
let task_id = "*";
|
|
69
|
+
let reason = "";
|
|
70
|
+
for (const line of rest.slice(0, end).split("\n")) {
|
|
71
|
+
const t = line.trim();
|
|
72
|
+
if (!t || t.startsWith("#"))
|
|
73
|
+
continue;
|
|
74
|
+
const i = t.indexOf(":");
|
|
75
|
+
if (i < 0)
|
|
76
|
+
continue;
|
|
77
|
+
const k = t.slice(0, i).trim();
|
|
78
|
+
const v = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
79
|
+
if (k === "op")
|
|
80
|
+
op = v;
|
|
81
|
+
else if (k === "task_id")
|
|
82
|
+
task_id = v || "*";
|
|
83
|
+
else if (k === "reason")
|
|
84
|
+
reason = v;
|
|
85
|
+
}
|
|
86
|
+
if (op !== "recycle")
|
|
87
|
+
return null;
|
|
88
|
+
return { op, task_id, reason };
|
|
89
|
+
}
|
|
60
90
|
/** Render a swarm body header in front of a Markdown payload. */
|
|
61
91
|
export function renderSwarmHeader(header, role, payloadMarkdown) {
|
|
62
92
|
let s = `---swarm\ntask_id: ${header.task_id}\nfrom: ${header.from}\ntransfer_send_to: ${header.transfer_send_to}\nattempt: ${header.attempt}\n---\n`;
|
|
@@ -89,6 +119,67 @@ export function readSwarmEnabled(onlyneDir) {
|
|
|
89
119
|
catch { /* unreadable -> disabled */ }
|
|
90
120
|
return false;
|
|
91
121
|
}
|
|
122
|
+
/** Strip // line comments and trailing commas so jsonc parses as JSON. */
|
|
123
|
+
export function stripJsonc(text) {
|
|
124
|
+
const out = [];
|
|
125
|
+
let inStr = false;
|
|
126
|
+
let esc = false;
|
|
127
|
+
for (const line of text.split("\n")) {
|
|
128
|
+
let res = "";
|
|
129
|
+
for (let i = 0; i < line.length; i++) {
|
|
130
|
+
const c = line[i];
|
|
131
|
+
if (inStr) {
|
|
132
|
+
res += c;
|
|
133
|
+
if (esc)
|
|
134
|
+
esc = false;
|
|
135
|
+
else if (c === "\\")
|
|
136
|
+
esc = true;
|
|
137
|
+
else if (c === '"')
|
|
138
|
+
inStr = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (c === '"') {
|
|
142
|
+
inStr = true;
|
|
143
|
+
res += c;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (c === "/" && line[i + 1] === "/")
|
|
147
|
+
break;
|
|
148
|
+
res += c;
|
|
149
|
+
}
|
|
150
|
+
out.push(res);
|
|
151
|
+
}
|
|
152
|
+
return out.join("\n").replace(/,(\s*[}\]])/g, "$1");
|
|
153
|
+
}
|
|
154
|
+
/** Read the model triple from the workspace .onlyne/swarm.workspace.jsonc snapshot. */
|
|
155
|
+
export function readSwarmModel(onlyneDir) {
|
|
156
|
+
const path = join(onlyneDir, "swarm.workspace.jsonc");
|
|
157
|
+
if (!existsSync(path))
|
|
158
|
+
return null;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(stripJsonc(readFileSync(path, "utf8")));
|
|
161
|
+
const m = parsed?.model;
|
|
162
|
+
if (!m || typeof m !== "object")
|
|
163
|
+
return null;
|
|
164
|
+
const provider = typeof m.provider === "string" && m.provider ? m.provider : undefined;
|
|
165
|
+
const model = typeof m.model === "string" && m.model ? m.model : undefined;
|
|
166
|
+
const effort = typeof m.effort === "string" && m.effort ? m.effort : undefined;
|
|
167
|
+
if (!provider && !model)
|
|
168
|
+
return null;
|
|
169
|
+
return { provider, model, effort };
|
|
170
|
+
}
|
|
171
|
+
catch { /* malformed -> no model override */ }
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/** Tree path of a workspace inside the swarm tree: the segment after the
|
|
175
|
+
* nearest `/.ws/` marker; the swarm root itself is "." .
|
|
176
|
+
* Pure function so quoting/edge cases are unit-tested without a live session. */
|
|
177
|
+
export function treePathForWorkspace(workspaceRoot) {
|
|
178
|
+
const i = workspaceRoot.lastIndexOf("/.ws/");
|
|
179
|
+
if (i >= 0)
|
|
180
|
+
return workspaceRoot.slice(i + "/.ws/".length) || ".";
|
|
181
|
+
return ".";
|
|
182
|
+
}
|
|
92
183
|
/** Terminal handle for swarm_ready correlation (injected by the scheduler). */
|
|
93
184
|
export function terminalHandle() {
|
|
94
185
|
return process.env.ORCA_TERMINAL_HANDLE || process.env.ONLYNE_TERMINAL_HANDLE || "";
|