pi-onlyne 0.7.3 → 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 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 exits with `swarm_complete` (done signal) or `swarm_quit` (silent, scheduler records failed). Downstream results travel through files and the ledger; nothing waits.
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
- This checkout is version 0.6.0 with swarm support already merged into the `dev` branch. npm currently publishes 0.4.0 as the latest tag. Run `npm run check` before any release so the build and tests regenerate `dist/`. Publish with `npm publish` after reviewing the generated tarball.
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). `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.
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
@@ -3,10 +3,10 @@ import { randomUUID } from "node:crypto";
3
3
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { Type } from "typebox";
6
- 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";
7
7
  import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
8
8
  import { findWorkspace } from "./workspace.js";
9
- import { envTaskId, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
9
+ import { envTaskId, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
10
10
  import { SwarmSlot } from "./swarm-slot.js";
11
11
  const swarmSlot = new SwarmSlot();
12
12
  const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
@@ -72,6 +72,8 @@ async function historyTaskDone(taskId) {
72
72
  const text = m?.text ?? "";
73
73
  if (typeof text !== "string" || !text.startsWith("---swarm"))
74
74
  continue;
75
+ if (text.startsWith("---swarm-ctl"))
76
+ continue;
75
77
  if (m?.direction === "outbound") {
76
78
  const p = parseSwarmHeader(text);
77
79
  if (p)
@@ -111,6 +113,8 @@ async function catchUpSwarmHistory(pi, ctx) {
111
113
  const text = m?.text ?? m?.content ?? "";
112
114
  if (typeof text !== "string" || !text.startsWith("---swarm"))
113
115
  continue;
116
+ if (text.startsWith("---swarm-ctl"))
117
+ continue;
114
118
  const p = parseSwarmHeader(text);
115
119
  if (!p)
116
120
  continue;
@@ -163,7 +167,7 @@ function handleSwarmInbound(pi, text, eventSeq, ctx) {
163
167
  // Slot transitions live in SwarmSlot (unit-tested); here we only mirror
164
168
  // the claimed task into session state. A claimed session never accepts
165
169
  // another task; downstream work spawns new tasks via swarm_send.
166
- const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined);
170
+ const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined, state.workspace?.root);
167
171
  if (outcome === "claimed" || outcome === "yielded") {
168
172
  const cur = swarmSlot.task();
169
173
  state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
@@ -206,6 +210,11 @@ async function startWatch(pi) {
206
210
  // task was already delivered (scheduler writes in before the
207
211
  // session finishes starting). History replay is idempotent via
208
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
+ }
209
218
  void catchUpSwarmHistory(pi);
210
219
  handleSwarmInbound(pi, inbound.text, line.event_seq);
211
220
  }, () => { if (state.socket === socket)
@@ -279,12 +288,54 @@ async function swarmComplete(text) {
279
288
  }
280
289
  return { ...res, taskId: task.taskId };
281
290
  }
282
- /** swarm_quit: silent exit. No out written; the scheduler records failed. */
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. */
283
296
  async function swarmQuit(reason) {
284
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 */ }
311
+ state.swarmTask = undefined;
312
+ swarmSlot.clear();
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 */ }
285
335
  state.swarmTask = undefined;
286
336
  swarmSlot.clear();
287
- return { quit: true, taskId: task?.taskId, reason: reason ?? "" };
337
+ setTimeout(() => process.exit(0), 300);
338
+ return true;
288
339
  }
289
340
  /** swarm_send: spawn downstream. New UUID, transfer_send_to = current task, fire-and-forget. */
290
341
  async function swarmSend(to, text) {
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
+ }
@@ -21,7 +21,7 @@ export declare class SwarmSlot {
21
21
  private transfer;
22
22
  private attempt;
23
23
  private sentChildIds;
24
- handle(pi: SwarmPi, text: string, preferredTaskId?: string): SwarmHandleResult;
24
+ handle(pi: SwarmPi, text: string, preferredTaskId?: string, workspaceRoot?: string): SwarmHandleResult;
25
25
  task(): {
26
26
  taskId?: string;
27
27
  from: string;
@@ -35,7 +35,7 @@ export declare class SwarmSlot {
35
35
  }
36
36
  /** Test seam: fresh slot without touching module-global pi-onlyne state. */
37
37
  export declare function __swarmSlotForTest(): {
38
- handle: (pi: SwarmPi, text: string, preferredTaskId?: string) => SwarmHandleResult;
38
+ handle: (pi: SwarmPi, text: string, preferredTaskId?: string, workspaceRoot?: string) => SwarmHandleResult;
39
39
  task: () => {
40
40
  taskId?: string;
41
41
  from: string;
@@ -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, preferredTaskId) {
14
+ handle(pi, text, preferredTaskId, workspaceRoot) {
14
15
  const parsed = parseSwarmHeader(text);
15
16
  if (!parsed)
16
17
  return "not-swarm";
@@ -24,7 +25,7 @@ export class SwarmSlot {
24
25
  // Cold start needs triggerTurn: followUp alone only queues behind an
25
26
  // active turn, and a fresh session has none. sendMessage with
26
27
  // 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" });
28
+ pi.sendMessage({ customType: "onlyne-swarm-task", content: resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, display: true }, { triggerTurn: true, deliverAs: "followUp" });
28
29
  return "claimed";
29
30
  }
30
31
  // Env-task preemption: the scheduler injected ONLYNE_SWARM_TASK for
@@ -37,7 +38,7 @@ export class SwarmSlot {
37
38
  this.transfer = header.transfer_send_to;
38
39
  this.attempt = header.attempt;
39
40
  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
+ pi.sendMessage({ customType: "onlyne-swarm-task", content: resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, display: true }, { triggerTurn: true, deliverAs: "followUp" });
41
42
  return "yielded";
42
43
  }
43
44
  return "not-swarm";
@@ -63,7 +64,7 @@ export class SwarmSlot {
63
64
  export function __swarmSlotForTest() {
64
65
  const slot = new SwarmSlot();
65
66
  return {
66
- handle: (pi, text, preferredTaskId) => slot.handle(pi, text, preferredTaskId),
67
+ handle: (pi, text, preferredTaskId, workspaceRoot) => slot.handle(pi, text, preferredTaskId, workspaceRoot),
67
68
  task: () => slot.task(),
68
69
  taskId: () => slot.taskIdOf(),
69
70
  noteSpawned: (childId) => slot.noteSpawned(childId),
package/dist/swarm.d.ts CHANGED
@@ -11,6 +11,15 @@ 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. */
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`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension tools for sending messages through Onlyne.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",