pi-onlyne 0.7.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/dist/index.js +167 -19
- package/dist/swarm-slot.d.ts +10 -8
- package/dist/swarm-slot.js +19 -3
- package/dist/swarm.d.ts +12 -0
- package/dist/swarm.js +61 -0
- package/package.json +1 -1
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
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
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,104 @@ 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 (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
|
+
}
|
|
56
156
|
/** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
|
|
57
|
-
function handleSwarmInbound(pi, text, eventSeq) {
|
|
157
|
+
function handleSwarmInbound(pi, text, eventSeq, ctx) {
|
|
58
158
|
const parsed = parseSwarmHeader(text);
|
|
59
159
|
if (!parsed)
|
|
60
160
|
return false;
|
|
@@ -63,12 +163,14 @@ function handleSwarmInbound(pi, text, eventSeq) {
|
|
|
63
163
|
// Slot transitions live in SwarmSlot (unit-tested); here we only mirror
|
|
64
164
|
// the claimed task into session state. A claimed session never accepts
|
|
65
165
|
// another task; downstream work spawns new tasks via swarm_send.
|
|
66
|
-
const outcome = swarmSlot.handle(pi, text);
|
|
67
|
-
if (outcome === "claimed") {
|
|
166
|
+
const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined);
|
|
167
|
+
if (outcome === "claimed" || outcome === "yielded") {
|
|
68
168
|
const cur = swarmSlot.task();
|
|
69
169
|
state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
|
|
170
|
+
if (ctx)
|
|
171
|
+
void pinSwarmTitle(ctx, cur.taskId);
|
|
70
172
|
}
|
|
71
|
-
return outcome === "claimed";
|
|
173
|
+
return outcome === "claimed" || outcome === "yielded";
|
|
72
174
|
}
|
|
73
175
|
async function startWatch(pi) {
|
|
74
176
|
state.workspace = findWorkspace(state.cwd);
|
|
@@ -89,6 +191,9 @@ async function startWatch(pi) {
|
|
|
89
191
|
// Report readiness so the scheduler can match a pending task (fork+exec: any
|
|
90
192
|
// clean ready session on this workspace path may take it).
|
|
91
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.
|
|
92
197
|
const socket = subscribe(ws.socketPath, (line) => {
|
|
93
198
|
if (!line?.event)
|
|
94
199
|
return;
|
|
@@ -97,9 +202,14 @@ async function startWatch(pi) {
|
|
|
97
202
|
const inbound = inboundText(line);
|
|
98
203
|
if (!inbound || inbound.channelId !== "loopback")
|
|
99
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);
|
|
100
210
|
handleSwarmInbound(pi, inbound.text, line.event_seq);
|
|
101
211
|
}, () => { if (state.socket === socket)
|
|
102
|
-
scheduleReconnect(pi); });
|
|
212
|
+
scheduleReconnect(pi); }, { priority: 1 });
|
|
103
213
|
state.socket = socket;
|
|
104
214
|
state.watching = true;
|
|
105
215
|
const task = envTaskId();
|
|
@@ -107,6 +217,10 @@ async function startWatch(pi) {
|
|
|
107
217
|
await swarmReady(ws.socketPath, ws.root, terminalHandle());
|
|
108
218
|
}
|
|
109
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);
|
|
110
224
|
return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
|
|
111
225
|
}
|
|
112
226
|
const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
|
|
@@ -143,12 +257,11 @@ async function reply(text) { if (!state.workspace)
|
|
|
143
257
|
inbound.replied = true;
|
|
144
258
|
clearReminder();
|
|
145
259
|
} return res; }
|
|
146
|
-
/** Resolve the current workspace tree path
|
|
260
|
+
/** Resolve the current workspace tree path: segment after /.ws/, root is ".". */
|
|
147
261
|
function swarmTreePath() {
|
|
148
262
|
if (!state.workspace)
|
|
149
263
|
throw new Error("onlyne workspace not found");
|
|
150
|
-
|
|
151
|
-
return state.workspace.root === process.cwd() ? "." : basename(state.workspace.root);
|
|
264
|
+
return treePathForWorkspace(state.workspace.root);
|
|
152
265
|
}
|
|
153
266
|
/** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
|
|
154
267
|
async function swarmComplete(text) {
|
|
@@ -180,10 +293,7 @@ async function swarmSend(to, text) {
|
|
|
180
293
|
const task = state.swarmTask;
|
|
181
294
|
if (!task)
|
|
182
295
|
throw new Error("no active swarm task");
|
|
183
|
-
const { randomUUID } = await import("node:crypto");
|
|
184
296
|
const { renderSwarmHeader } = await import("./swarm.js");
|
|
185
|
-
const { existsSync } = await import("node:fs");
|
|
186
|
-
const { join, resolve } = await import("node:path");
|
|
187
297
|
// Resolve the target workspace dir: root "." or a tree path under the swarm tree.
|
|
188
298
|
// The scheduler owns the tree; here we only verify the send-side symlink exists
|
|
189
299
|
// (missing target = dangling link = error, never a blind FIFO write).
|
|
@@ -199,7 +309,6 @@ async function swarmSend(to, text) {
|
|
|
199
309
|
throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
|
|
200
310
|
const childId = randomUUID();
|
|
201
311
|
const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
|
|
202
|
-
const { writeFileSync } = await import("node:fs");
|
|
203
312
|
try {
|
|
204
313
|
writeFileSync(linkPath, wire);
|
|
205
314
|
}
|
|
@@ -209,11 +318,42 @@ async function swarmSend(to, text) {
|
|
|
209
318
|
swarmSlot.noteSpawned(childId);
|
|
210
319
|
return { childId, to };
|
|
211
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
|
+
}
|
|
212
352
|
export default function onlyne(pi) {
|
|
213
|
-
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")
|
|
214
354
|
await stopDaemon().catch(() => { });
|
|
215
355
|
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) {
|
|
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) {
|
|
217
357
|
try {
|
|
218
358
|
ctx.ui.notify(await startWatch(pi), "info");
|
|
219
359
|
}
|
|
@@ -230,8 +370,12 @@ export default function onlyne(pi) {
|
|
|
230
370
|
pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
|
|
231
371
|
state.lastValidOutput = text; });
|
|
232
372
|
pi.on("agent_start", async () => clearReminder());
|
|
233
|
-
pi.on("agent_end", async () => { if (state.swarm)
|
|
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);
|
|
234
377
|
scheduleSwarmExitReminder(pi);
|
|
378
|
+
}
|
|
235
379
|
else
|
|
236
380
|
scheduleReminder(pi); });
|
|
237
381
|
pi.registerCommand("onlyne", {
|
|
@@ -331,7 +475,13 @@ function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
|
331
475
|
if (!state.swarmTask)
|
|
332
476
|
return;
|
|
333
477
|
if (state.swarmTask) {
|
|
334
|
-
|
|
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
|
+
}
|
|
335
485
|
state.reminderTimer = setTimeout(() => {
|
|
336
486
|
state.reminderTimer = undefined;
|
|
337
487
|
if (state.swarmTask)
|
|
@@ -344,8 +494,6 @@ function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
|
344
494
|
async function setSwarm(pi, enabled) {
|
|
345
495
|
if (!state.workspace)
|
|
346
496
|
throw new Error("onlyne workspace not found");
|
|
347
|
-
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
348
|
-
const { join } = await import("node:path");
|
|
349
497
|
const cfgPath = join(state.workspace.onlyneDir, "config.toml");
|
|
350
498
|
let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
351
499
|
if (text.includes("[swarm]")) {
|
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): 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) => SwarmHandleResult;
|
|
37
39
|
task: () => {
|
|
38
40
|
taskId?: string;
|
|
39
41
|
from: string;
|
package/dist/swarm-slot.js
CHANGED
|
@@ -10,7 +10,7 @@ export class SwarmSlot {
|
|
|
10
10
|
transfer = "";
|
|
11
11
|
attempt = 1;
|
|
12
12
|
sentChildIds = [];
|
|
13
|
-
handle(pi, text) {
|
|
13
|
+
handle(pi, text, preferredTaskId) {
|
|
14
14
|
const parsed = parseSwarmHeader(text);
|
|
15
15
|
if (!parsed)
|
|
16
16
|
return "not-swarm";
|
|
@@ -21,9 +21,25 @@ export class SwarmSlot {
|
|
|
21
21
|
this.transfer = header.transfer_send_to;
|
|
22
22
|
this.attempt = header.attempt;
|
|
23
23
|
this.sentChildIds = [];
|
|
24
|
-
|
|
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" });
|
|
25
28
|
return "claimed";
|
|
26
29
|
}
|
|
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";
|
|
42
|
+
}
|
|
27
43
|
return "not-swarm";
|
|
28
44
|
}
|
|
29
45
|
task() {
|
|
@@ -47,7 +63,7 @@ export class SwarmSlot {
|
|
|
47
63
|
export function __swarmSlotForTest() {
|
|
48
64
|
const slot = new SwarmSlot();
|
|
49
65
|
return {
|
|
50
|
-
handle: (pi, text) => slot.handle(pi, text),
|
|
66
|
+
handle: (pi, text, preferredTaskId) => slot.handle(pi, text, preferredTaskId),
|
|
51
67
|
task: () => slot.task(),
|
|
52
68
|
taskId: () => slot.taskIdOf(),
|
|
53
69
|
noteSpawned: (childId) => slot.noteSpawned(childId),
|
package/dist/swarm.d.ts
CHANGED
|
@@ -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
|
@@ -89,6 +89,67 @@ export function readSwarmEnabled(onlyneDir) {
|
|
|
89
89
|
catch { /* unreadable -> disabled */ }
|
|
90
90
|
return false;
|
|
91
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
|
+
}
|
|
92
153
|
/** Terminal handle for swarm_ready correlation (injected by the scheduler). */
|
|
93
154
|
export function terminalHandle() {
|
|
94
155
|
return process.env.ORCA_TERMINAL_HANDLE || process.env.ONLYNE_TERMINAL_HANDLE || "";
|