pi-onlyne 0.6.0 → 0.7.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
@@ -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 task atomically, receives child callbacks through `followUp`, and completes with `onlyne_swarm_reply`. `onlyne_mark_no_reply` closes a task without an outbound result.
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/onlyne.sock`; if unavailable, it starts a Pi-owned workspace daemon.
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 task (atomic slot). A new
63
- header claims the slot; headers with matching `reply_to`/`task_id` arrive as
64
- followUp callbacks for the suspended parent; headers for other tasks while
65
- busy are ignored with an `[onlyne-internal]` notice.
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: `onlyne_swarm_reply({text, rawText?})` writes the out message carrying
71
- the task header (the scheduler's success signal) and ends the task slot.
72
- `onlyne_mark_no_reply` additionally clears the swarm slot.
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
@@ -6,8 +6,7 @@ import { findWorkspace } from "./workspace.js";
6
6
  import { envTaskId, parseSwarmHeader, readSwarmEnabled, terminalHandle } from "./swarm.js";
7
7
  import { SwarmSlot } from "./swarm-slot.js";
8
8
  const swarmSlot = new SwarmSlot();
9
- const slotTaskId = () => swarmSlot.task().taskId;
10
- const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false, swarmPending: 0 };
9
+ const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
11
10
  const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
12
11
  const currentConfig = () => loadConfig(state.cwd);
13
12
  function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
@@ -54,7 +53,7 @@ function scheduleReconnect(pi) {
54
53
  }
55
54
  }, 1000);
56
55
  }
57
- /** Swarm-mode inbound path: single atomic task slot + followUp callbacks. */
56
+ /** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
58
57
  function handleSwarmInbound(pi, text, eventSeq) {
59
58
  const parsed = parseSwarmHeader(text);
60
59
  if (!parsed)
@@ -62,19 +61,14 @@ function handleSwarmInbound(pi, text, eventSeq) {
62
61
  if (state.socket && eventSeq !== undefined)
63
62
  void consumeEvent(state.socket, eventSeq).catch(() => { });
64
63
  // Slot transitions live in SwarmSlot (unit-tested); here we only mirror
65
- // the outcome into session state (pending counter + task record).
66
- const before = slotTaskId();
64
+ // the claimed task into session state. A claimed session never accepts
65
+ // another task; downstream work spawns new tasks via swarm_send.
67
66
  const outcome = swarmSlot.handle(pi, text);
68
- const after = slotTaskId();
69
67
  if (outcome === "claimed") {
70
68
  const cur = swarmSlot.task();
71
- state.swarmTask = { taskId: cur.taskId, from: cur.from, replyTo: cur.replyTo, attempt: cur.attempt, pendingReplies: 0 };
72
- state.swarmPending = 0;
69
+ state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
73
70
  }
74
- else if (outcome === "callback" && before !== undefined && before === after) {
75
- state.swarmPending = Math.max(0, state.swarmPending - 1);
76
- }
77
- return true;
71
+ return outcome === "claimed";
78
72
  }
79
73
  async function startWatch(pi) {
80
74
  state.workspace = findWorkspace(state.cwd);
@@ -136,7 +130,7 @@ async function startWatch(pi) {
136
130
  return `watching ${state.workspace.root} (${state.owner})`;
137
131
  }
138
132
  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; state.swarmPending = 0; swarmSlot.clear(); return "watch stopped"; }
133
+ 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
134
  async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
141
135
  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
136
  async function stopDaemon() { if (!state.workspace)
@@ -149,28 +143,77 @@ async function reply(text) { if (!state.workspace)
149
143
  inbound.replied = true;
150
144
  clearReminder();
151
145
  } return res; }
152
- /** Swarm reply: writes the out message carrying the task header (success signal), ends the task slot. */
153
- async function swarmReply(text, rawText = false) {
146
+ /** Resolve the current workspace tree path from the workspace root dir name. Root itself is ".". */
147
+ function swarmTreePath() {
148
+ if (!state.workspace)
149
+ throw new Error("onlyne workspace not found");
150
+ const { basename } = require("node:path");
151
+ return state.workspace.root === process.cwd() ? "." : basename(state.workspace.root);
152
+ }
153
+ /** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
154
+ async function swarmComplete(text) {
154
155
  if (!state.workspace)
155
156
  throw new Error("onlyne workspace not found");
156
157
  const task = state.swarmTask;
157
158
  if (!task)
158
159
  throw new Error("no active swarm task");
159
160
  const { renderSwarmHeader } = await import("./swarm.js");
160
- const wire = renderSwarmHeader({ task_id: task.taskId, from: ".", reply_to: task.replyTo, attempt: task.attempt }, "", text);
161
- const res = await sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, currentConfig().outbound.retry.attempts, rawText);
161
+ const wire = renderSwarmHeader({ task_id: task.taskId, from: swarmTreePath(), transfer_send_to: task.transferSendTo, attempt: task.attempt }, "", text);
162
+ const res = await sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, currentConfig().outbound.retry.attempts);
162
163
  if (res.ok) {
163
164
  state.swarmTask = undefined;
164
- state.swarmPending = 0;
165
165
  swarmSlot.clear();
166
166
  }
167
167
  return { ...res, taskId: task.taskId };
168
168
  }
169
+ /** swarm_quit: silent exit. No out written; the scheduler records failed. */
170
+ async function swarmQuit(reason) {
171
+ const task = state.swarmTask;
172
+ state.swarmTask = undefined;
173
+ swarmSlot.clear();
174
+ return { quit: true, taskId: task?.taskId, reason: reason ?? "" };
175
+ }
176
+ /** swarm_send: spawn downstream. New UUID, transfer_send_to = current task, fire-and-forget. */
177
+ async function swarmSend(to, text) {
178
+ if (!state.workspace)
179
+ throw new Error("onlyne workspace not found");
180
+ const task = state.swarmTask;
181
+ if (!task)
182
+ throw new Error("no active swarm task");
183
+ const { randomUUID } = await import("node:crypto");
184
+ const { renderSwarmHeader } = await import("./swarm.js");
185
+ const { existsSync } = await import("node:fs");
186
+ const { join, resolve } = await import("node:path");
187
+ // Resolve the target workspace dir: root "." or a tree path under the swarm tree.
188
+ // The scheduler owns the tree; here we only verify the send-side symlink exists
189
+ // (missing target = dangling link = error, never a blind FIFO write).
190
+ const wsRoot = state.workspace.root;
191
+ const linkPath = to === "." || to === "_root"
192
+ ? join(wsRoot, "onlyne_in", "_root")
193
+ : join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
194
+ const resolved = resolve(wsRoot);
195
+ const linkDir = resolve(linkPath);
196
+ if (!linkDir.startsWith(resolved))
197
+ throw new Error(`invalid swarm_send target: ${to}`);
198
+ if (!existsSync(linkPath))
199
+ throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
200
+ const childId = randomUUID();
201
+ const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
202
+ const { writeFileSync } = await import("node:fs");
203
+ try {
204
+ writeFileSync(linkPath, wire);
205
+ }
206
+ catch (e) {
207
+ throw new Error(`swarm_send failed for ${to}: ${e instanceof Error ? e.message : String(e)}`);
208
+ }
209
+ swarmSlot.noteSpawned(childId);
210
+ return { childId, to };
211
+ }
169
212
  export default function onlyne(pi) {
170
213
  pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
171
214
  await stopDaemon().catch(() => { });
172
215
  else
173
- stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; state.swarmTask = undefined; state.swarmPending = 0; swarmSlot.clear(); refreshSwarmFlag(); ctx.ui.setStatus("onlyne", state.workspace ? (state.swarm ? "onlyne: swarm" : "onlyne: ready") : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
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) {
174
217
  try {
175
218
  ctx.ui.notify(await startWatch(pi), "info");
176
219
  }
@@ -187,7 +230,10 @@ export default function onlyne(pi) {
187
230
  pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
188
231
  state.lastValidOutput = text; });
189
232
  pi.on("agent_start", async () => clearReminder());
190
- pi.on("agent_end", async () => scheduleReminder(pi));
233
+ pi.on("agent_end", async () => { if (state.swarm)
234
+ scheduleSwarmExitReminder(pi);
235
+ else
236
+ scheduleReminder(pi); });
191
237
  pi.registerCommand("onlyne", {
192
238
  description: "Onlyne watch/status/config commands",
193
239
  getArgumentCompletions: (prefix) => {
@@ -211,8 +257,10 @@ export default function onlyne(pi) {
211
257
  ctx.ui.notify(await restartDaemon(), "info");
212
258
  else if (cmd === "status")
213
259
  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
- ctx.ui.notify(`swarm=${state.swarm ? "on" : "off"}; task=${state.swarmTask?.taskId ?? "none"}; pending=${state.swarmPending}`, "info");
260
+ else if (cmd === "swarm" && sub === "status") {
261
+ const cur = swarmSlot.task();
262
+ ctx.ui.notify(`swarm=${state.swarm ? "on" : "off"}; task=${cur.taskId ?? "none"}; spawned=${cur.sentChildIds.length}`, "info");
263
+ }
216
264
  else if (cmd === "swarm" && (sub === "on" || sub === "off")) {
217
265
  ctx.ui.notify(await setSwarm(pi, sub === "on"), "info");
218
266
  }
@@ -230,11 +278,32 @@ export default function onlyne(pi) {
230
278
  }
231
279
  },
232
280
  });
281
+ registerNormalTools(pi);
282
+ registerSwarmTools(pi);
283
+ applyToolSurface(pi);
284
+ }
285
+ const NORMAL_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "onlyne_reply", "onlyne_send", "onlyne_broadcast", "onlyne_loopback", "onlyne_mark_no_reply"];
286
+ const SWARM_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "swarm_complete", "swarm_quit", "swarm_send", "swarm_status"];
287
+ /** Tool surface follows [swarm] at session start: one mode, one toolset. */
288
+ function applyToolSurface(pi) {
289
+ try {
290
+ const active = new Set(typeof pi.getActiveTools === "function" ? pi.getActiveTools() : []);
291
+ const all = typeof pi.getAllTools === "function" ? pi.getAllTools().map((t) => t.name) : [];
292
+ const keep = state.swarm ? SWARM_TOOLS : NORMAL_TOOLS;
293
+ const next = [...active].filter((n) => all.includes(n) && !(NORMAL_TOOLS.includes(n) || SWARM_TOOLS.includes(n)));
294
+ for (const n of keep)
295
+ if (all.includes(n) && !next.includes(n))
296
+ next.push(n);
297
+ if (typeof pi.setActiveTools === "function")
298
+ pi.setActiveTools(next);
299
+ }
300
+ catch { /* older pi without tool-surface API: both sets stay registered */ }
301
+ }
302
+ function registerNormalTools(pi) {
233
303
  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
304
  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
305
  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
306
  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
307
  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
308
  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
309
  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,12 +313,33 @@ export default function onlyne(pi) {
244
313
  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
314
  state.currentInbound.noReply = true;
246
315
  clearReminder();
247
- } if (state.swarmTask) {
248
- state.swarmTask = undefined;
249
- state.swarmPending = 0;
250
- swarmSlot.clear();
251
316
  } return textResult("marked no reply", params); } }));
252
317
  }
318
+ function registerSwarmTools(pi) {
319
+ 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))); } }));
320
+ 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))); } }));
321
+ 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))); } }));
322
+ 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 })); } }));
323
+ }
324
+ /** Swarm exit guard: at agent end, an unfinished hop gets one reminder, then auto-quit. */
325
+ function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
326
+ clearReminder();
327
+ if (!state.swarmTask)
328
+ return;
329
+ state.reminderTimer = setTimeout(() => {
330
+ state.reminderTimer = undefined;
331
+ if (!state.swarmTask)
332
+ return;
333
+ if (state.swarmTask) {
334
+ 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" });
335
+ state.reminderTimer = setTimeout(() => {
336
+ state.reminderTimer = undefined;
337
+ if (state.swarmTask)
338
+ void swarmQuit("auto-quit: no explicit exit").catch(() => { });
339
+ }, delayMs);
340
+ }
341
+ }, delayMs);
342
+ }
253
343
  /** Toggle swarm mode: persists to .onlyne/config.toml [swarm] enabled, restarts watch. */
254
344
  async function setSwarm(pi, enabled) {
255
345
  if (!state.workspace)
@@ -1,4 +1,4 @@
1
- export type SwarmHandleResult = "claimed" | "callback" | "busy-ignored" | "not-swarm";
1
+ export type SwarmHandleResult = "claimed" | "not-swarm";
2
2
  export interface SwarmSlotMessage {
3
3
  text: string;
4
4
  deliverAs: "followUp";
@@ -16,21 +16,32 @@ export interface SwarmPi {
16
16
  export declare class SwarmSlot {
17
17
  private taskId?;
18
18
  private from;
19
- private replyTo;
19
+ private transfer;
20
20
  private attempt;
21
+ private sentChildIds;
21
22
  handle(pi: SwarmPi, text: string): SwarmHandleResult;
22
23
  task(): {
23
24
  taskId?: string;
24
25
  from: string;
25
- replyTo: string;
26
+ transferSendTo: string;
26
27
  attempt: number;
28
+ sentChildIds: string[];
27
29
  };
30
+ noteSpawned(childId: string): void;
28
31
  taskIdOf(): string | undefined;
29
32
  clear(): void;
30
33
  }
31
34
  /** Test seam: fresh slot without touching module-global pi-onlyne state. */
32
35
  export declare function __swarmSlotForTest(): {
33
36
  handle: (pi: SwarmPi, text: string) => SwarmHandleResult;
37
+ task: () => {
38
+ taskId?: string;
39
+ from: string;
40
+ transferSendTo: string;
41
+ attempt: number;
42
+ sentChildIds: string[];
43
+ };
34
44
  taskId: () => string | undefined;
45
+ noteSpawned: (childId: string) => void;
35
46
  clear: () => void;
36
47
  };
@@ -7,8 +7,9 @@ import { parseSwarmHeader } from "./swarm.js";
7
7
  export class SwarmSlot {
8
8
  taskId;
9
9
  from = ".";
10
- replyTo = "";
10
+ transfer = "";
11
11
  attempt = 1;
12
+ sentChildIds = [];
12
13
  handle(pi, text) {
13
14
  const parsed = parseSwarmHeader(text);
14
15
  if (!parsed)
@@ -17,20 +18,19 @@ export class SwarmSlot {
17
18
  if (!this.taskId) {
18
19
  this.taskId = header.task_id;
19
20
  this.from = header.from;
20
- this.replyTo = header.reply_to;
21
+ this.transfer = header.transfer_send_to;
21
22
  this.attempt = header.attempt;
22
- pi.sendUserMessage(`Onlyne swarm task ${header.task_id} (from ${header.from}):\n\n${payload}\n\nWork this task atomically. Child task callbacks arrive as followUp messages. Finish with onlyne_swarm_reply carrying the reply Markdown, or onlyne_mark_no_reply to end without output.`, { deliverAs: "followUp" });
23
+ this.sentChildIds = [];
24
+ pi.sendUserMessage(`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.`, { deliverAs: "followUp" });
23
25
  return "claimed";
24
26
  }
25
- if (header.reply_to === this.taskId || header.task_id === this.taskId) {
26
- pi.sendUserMessage(`Onlyne swarm callback for ${this.taskId} (from ${header.from}):\n\n${payload}`, { deliverAs: "followUp" });
27
- return "callback";
28
- }
29
- pi.sendUserMessage(`[onlyne-internal] swarm task ${header.task_id} ignored: session busy with ${this.taskId}.`, { deliverAs: "followUp" });
30
- return "busy-ignored";
27
+ return "not-swarm";
31
28
  }
32
29
  task() {
33
- return { taskId: this.taskId, from: this.from, replyTo: this.replyTo, attempt: this.attempt };
30
+ return { taskId: this.taskId, from: this.from, transferSendTo: this.transfer, attempt: this.attempt, sentChildIds: [...this.sentChildIds] };
31
+ }
32
+ noteSpawned(childId) {
33
+ this.sentChildIds.push(childId);
34
34
  }
35
35
  taskIdOf() {
36
36
  return this.taskId;
@@ -38,8 +38,9 @@ export class SwarmSlot {
38
38
  clear() {
39
39
  this.taskId = undefined;
40
40
  this.from = ".";
41
- this.replyTo = "";
41
+ this.transfer = "";
42
42
  this.attempt = 1;
43
+ this.sentChildIds = [];
43
44
  }
44
45
  }
45
46
  /** Test seam: fresh slot without touching module-global pi-onlyne state. */
@@ -47,7 +48,9 @@ export function __swarmSlotForTest() {
47
48
  const slot = new SwarmSlot();
48
49
  return {
49
50
  handle: (pi, text) => slot.handle(pi, text),
51
+ task: () => slot.task(),
50
52
  taskId: () => slot.taskIdOf(),
53
+ noteSpawned: (childId) => slot.noteSpawned(childId),
51
54
  clear: () => slot.clear(),
52
55
  };
53
56
  }
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
- reply_to: string;
5
+ transfer_send_to: string;
6
6
  attempt: number;
7
7
  }
8
8
  export interface SwarmMessage {
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 reply_to = "";
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
- reply_to = v;
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, reply_to, attempt }, payload };
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}\nreply_to: ${header.reply_to}\nattempt: ${header.attempt}\n---\n`;
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;
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", "onlyne.sock") };
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Pi extension tools for sending messages through Onlyne.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",