pi-onlyne 0.8.0 → 0.9.1

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 CHANGED
@@ -1,13 +1,14 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { randomUUID } from "node:crypto";
3
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
- import { join, resolve } from "node:path";
3
+ import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { Type } from "typebox";
6
- import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady, swarmRecycled } from "./onlyne.js";
6
+ import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, sendSwarmReport, shutdownDaemon, stopProcess, subscribe, swarmBusy, swarmIdle, swarmReady, swarmRecycled } from "./onlyne.js";
7
7
  import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
8
8
  import { findWorkspace } from "./workspace.js";
9
- import { envTaskId, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
9
+ import { envTaskId, hopActivityBody, isSwarmReportFrame, isSwarmTaskId, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, renderSwarmReport, selectHistoryDelivery, terminalHandle, treePathForWorkspace } from "./swarm.js";
10
10
  import { SwarmSlot } from "./swarm-slot.js";
11
+ import { SwarmSessionState, restoreSwarmState } from "./session.js";
11
12
  const swarmSlot = new SwarmSlot();
12
13
  const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
13
14
  let cachedCtx = undefined;
@@ -19,6 +20,122 @@ function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
19
20
  void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
20
21
  function clearReminder() { if (state.reminderTimer)
21
22
  clearTimeout(state.reminderTimer); state.reminderTimer = undefined; }
23
+ function swarmOpId(kind, suffix) {
24
+ const taskId = state.swarmSession?.taskId ?? state.swarmTask?.taskId ?? state.swarmTaskKey ?? `session:${terminalHandle() || state.workspace?.root || "unknown"}`;
25
+ const generation = state.swarmSession?.generation ?? 0;
26
+ return `swarm:${taskId}:g${generation}:${kind}${suffix ? `:${suffix}` : ""}`;
27
+ }
28
+ async function awaitSubscriptionAck(socket, timeoutMs = 5_000) {
29
+ let timer;
30
+ try {
31
+ return await Promise.race([
32
+ socket.ack,
33
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("swarm subscribe_events ack timeout")), timeoutMs); }),
34
+ ]);
35
+ }
36
+ finally {
37
+ if (timer)
38
+ clearTimeout(timer);
39
+ }
40
+ }
41
+ function awaitIntent(intent) { void Promise.resolve(intent).catch(() => { }); }
42
+ /** Task identity the scheduler owns, for lifecycle evidence. The claimed hop
43
+ * wins; before a claim an env-assigned task is already a scheduler row. A
44
+ * synthetic session key returns null and the report stays home: a frame the
45
+ * ledger cannot attribute raises an unknown-session alert on that side. */
46
+ function swarmReportTaskId() {
47
+ for (const candidate of [state.swarmTask?.taskId, state.swarmSession?.taskId]) {
48
+ if (candidate && isSwarmTaskId(candidate))
49
+ return candidate.trim();
50
+ }
51
+ return null;
52
+ }
53
+ /** A report that failed to build or reach the daemon is recorded locally. The
54
+ * entry names the kind and the error, so a gap in the scheduler's view of this
55
+ * session is traceable to a frame this side can show. */
56
+ function reportFault(kind, e) {
57
+ const message = e instanceof Error ? e.message : String(e);
58
+ try {
59
+ state.swarmSession?.transition({}, `swarm_report_fault:${kind}:${message}`);
60
+ }
61
+ catch { /* ledger unavailable */ }
62
+ }
63
+ /** Send the frame for an already-persisted watermark. One attempt per point:
64
+ * the op_id names (task, seq), and the next beat carries a fresh watermark, so
65
+ * a dropped frame costs one step of liveness evidence. */
66
+ async function sendSwarmReportFrame(kind, seq) {
67
+ const session = state.swarmSession;
68
+ const ws = state.workspace;
69
+ const taskId = swarmReportTaskId();
70
+ if (!session || !ws || !taskId)
71
+ return false;
72
+ let frame;
73
+ try {
74
+ frame = renderSwarmReport(kind, taskId, session.generation, seq);
75
+ }
76
+ catch (e) {
77
+ reportFault(kind, e);
78
+ return false;
79
+ }
80
+ try {
81
+ await sendSwarmReport(ws.socketPath, frame, swarmOpId(`report_${kind}`, `${taskId}:${seq}`));
82
+ return true;
83
+ }
84
+ catch (e) {
85
+ reportFault(kind, e);
86
+ return false;
87
+ }
88
+ }
89
+ /** Advance the durable watermark and carry it to the daemon. Persisting under
90
+ * the report's own kind names the lifecycle point in the local ledger, and
91
+ * persisting before sending gives every point its own strictly increasing seq
92
+ * within this generation — the ordering the reducer's watermark compares
93
+ * against. */
94
+ async function emitSwarmReport(kind) {
95
+ const session = state.swarmSession;
96
+ if (!session)
97
+ return false;
98
+ try {
99
+ return await sendSwarmReportFrame(kind, session.transition({}, `swarm_report_${kind}`).seq);
100
+ }
101
+ catch (e) {
102
+ reportFault(kind, e);
103
+ return false;
104
+ }
105
+ }
106
+ function swarmHeartbeat() {
107
+ const session = state.swarmSession;
108
+ if (!session)
109
+ return;
110
+ let seq;
111
+ try {
112
+ seq = session.heartbeat().seq;
113
+ }
114
+ catch (e) {
115
+ session.transition({ recovery: "idle_fault" }, `swarm_heartbeat_fault:${e instanceof Error ? e.message : String(e)}`);
116
+ session.stopHeartbeat();
117
+ return;
118
+ }
119
+ awaitIntent(sendSwarmReportFrame("heartbeat", seq));
120
+ }
121
+ function swarmSessionTransition(patch, kind) { state.swarmSession?.transition(patch, kind); }
122
+ async function sendSwarmIntent(kind, payload, send, maxAttempts = 3, opId = swarmOpId(kind)) {
123
+ const session = state.swarmSession;
124
+ if (!session)
125
+ return send(opId);
126
+ session.recordIntent(kind, payload, opId);
127
+ const intent = await session.retry(opId, async () => {
128
+ const receipt = await send(opId);
129
+ if (receipt && typeof receipt === "object" && receipt.ok === false)
130
+ throw new Error(JSON.stringify(receipt.error ?? receipt));
131
+ return receipt;
132
+ }, maxAttempts);
133
+ if (intent.status !== "accepted") {
134
+ session.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `${kind}_fault`);
135
+ throw new Error(`${kind} intent exhausted${intent.last_error ? `: ${intent.last_error}` : ""}`);
136
+ }
137
+ return intent.receipt;
138
+ }
22
139
  function needsReply(inbound = state.currentInbound) { return !!inbound && !inbound.replied && !inbound.noReply && !!state.workspace; }
23
140
  function scheduleReminder(pi, delayMs = 30_000) {
24
141
  clearReminder();
@@ -50,7 +167,7 @@ function scheduleReconnect(pi) {
50
167
  if (!state.watching)
51
168
  return;
52
169
  try {
53
- await startWatch(pi);
170
+ await startWatch(pi, state.swarmContext);
54
171
  }
55
172
  catch {
56
173
  scheduleReconnect(pi);
@@ -98,54 +215,45 @@ async function historyTaskDone(taskId) {
98
215
  async function catchUpSwarmHistory(pi, ctx) {
99
216
  if (swarmSlot.task().taskId)
100
217
  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;
218
+ const { request } = await import("./onlyne.js");
219
+ if (!state.workspace)
220
+ return;
221
+ const env = envTaskId();
222
+ const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
223
+ const items = res?.data ?? res ?? [];
224
+ if (!Array.isArray(items))
225
+ return;
226
+ const inbounds = [];
227
+ const outs = new Set();
228
+ for (const m of items) {
229
+ const text = m?.text ?? m?.content ?? "";
230
+ if (typeof text !== "string" || !text.startsWith("---swarm"))
231
+ continue;
232
+ if (text.startsWith("---swarm-ctl"))
233
+ continue;
234
+ // Own lifecycle evidence shares this history. A report frame carries no
235
+ // task header, so it must pass before the malformed-header check.
236
+ if (isSwarmReportFrame(text))
237
+ continue;
238
+ const p = parseSwarmHeader(text);
239
+ if (!p) {
240
+ if (text.startsWith("---swarm"))
241
+ state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "swarm_protocol_fault");
242
+ continue;
146
243
  }
244
+ if (m?.direction === "outbound")
245
+ outs.add(p.header.task_id);
246
+ else
247
+ inbounds.push(text);
147
248
  }
148
- catch { /* best effort; live events still arrive */ }
249
+ // A scheduler-created terminal has an env task id. In that case this
250
+ // selector returns only the matching wire or undefined: it must never
251
+ // fall back to an old history task while the new FIFO write races the
252
+ // initial catchup. Manual ready-pool sessions have no env and retain
253
+ // newest-unclosed fallback selection.
254
+ const selected = selectHistoryDelivery(env || undefined, inbounds, outs);
255
+ if (selected)
256
+ handleSwarmInbound(pi, selected, undefined, ctx);
149
257
  }
150
258
  /** Pin the tab title for a claimed hop. Pi rewrites the create-time title on
151
259
  * boot and on idle transitions, and the scheduler-side rename races those
@@ -159,27 +267,58 @@ function pinSwarmTitle(ctx, taskId) {
159
267
  }
160
268
  /** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
161
269
  function handleSwarmInbound(pi, text, eventSeq, ctx) {
162
- const parsed = parseSwarmHeader(text);
163
- if (!parsed)
270
+ // A report frame is this session's own evidence relayed back on the event
271
+ // stream. It is neither a task delivery nor a protocol fault.
272
+ if (isSwarmReportFrame(text))
273
+ return false;
274
+ let parsed;
275
+ try {
276
+ parsed = parseSwarmHeader(text);
277
+ }
278
+ catch (e) {
279
+ state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `swarm_protocol_fault:${e instanceof Error ? e.message : String(e)}`);
280
+ return false;
281
+ }
282
+ if (!parsed) {
283
+ if (text.startsWith("---swarm"))
284
+ state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "swarm_protocol_fault");
164
285
  return false;
286
+ }
165
287
  if (state.socket && eventSeq !== undefined)
166
- void consumeEvent(state.socket, eventSeq).catch(() => { });
288
+ awaitIntent(consumeEvent(state.socket, eventSeq).catch(() => { }));
167
289
  // Slot transitions live in SwarmSlot (unit-tested); here we only mirror
168
290
  // the claimed task into session state. A claimed session never accepts
169
291
  // another task; downstream work spawns new tasks via swarm_send.
170
- const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined, state.workspace?.root);
292
+ const outcome = (() => { try {
293
+ return swarmSlot.handle(pi, text, envTaskId() || undefined, state.workspace?.root);
294
+ }
295
+ catch (e) {
296
+ state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `swarm_slot_fault:${e instanceof Error ? e.message : String(e)}`);
297
+ return "fault";
298
+ } })();
171
299
  if (outcome === "claimed" || outcome === "yielded") {
172
300
  const cur = swarmSlot.task();
173
301
  state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
302
+ state.swarmTaskKey = cur.taskId;
303
+ const sessionCtx = state.swarmContext;
304
+ if (sessionCtx?.appendEntry) {
305
+ const restored = restoreSwarmState(sessionCtx, cur.taskId);
306
+ state.swarmSession?.stopHeartbeat();
307
+ state.swarmSession = new SwarmSessionState(sessionCtx, cur.taskId, restored);
308
+ state.swarmSession.startSession();
309
+ state.swarmSession.transition({ agent: "ready", resource: "attached", lifecycle: "working", recovery: "idle_waiting" }, "swarm_task_claimed");
310
+ state.swarmSession.startHeartbeat(10_000, swarmHeartbeat);
311
+ }
174
312
  if (ctx)
175
313
  void pinSwarmTitle(ctx, cur.taskId);
176
314
  }
177
315
  return outcome === "claimed" || outcome === "yielded";
178
316
  }
179
- async function startWatch(pi) {
317
+ async function startWatch(pi, ctx) {
180
318
  state.workspace = findWorkspace(state.cwd);
181
319
  if (!state.workspace)
182
320
  throw new Error("current workspace has no .onlyne configuration");
321
+ state.swarmContext = ctx ?? state.swarmContext ?? cachedCtx;
183
322
  refreshSwarmFlag();
184
323
  if (state.reconnectTimer)
185
324
  clearTimeout(state.reconnectTimer);
@@ -212,20 +351,41 @@ async function startWatch(pi) {
212
351
  // the slot guard + scheduler task_id dedup.
213
352
  if (parseSwarmCtl(inbound.text)) {
214
353
  const ctl = parseSwarmCtl(inbound.text);
215
- void handleSwarmRecycle(ctl.task_id, ctl.reason);
354
+ awaitIntent(handleSwarmRecycle(ctl.task_id, ctl.reason));
216
355
  return;
217
356
  }
218
- void catchUpSwarmHistory(pi);
357
+ void Promise.resolve(catchUpSwarmHistory(pi)).catch((e) => state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `history_catchup_fault:${e instanceof Error ? e.message : String(e)}`));
219
358
  handleSwarmInbound(pi, inbound.text, line.event_seq);
220
359
  }, () => { if (state.socket === socket)
221
360
  scheduleReconnect(pi); }, { priority: 1 });
222
361
  state.socket = socket;
223
362
  state.watching = true;
224
- const task = envTaskId();
225
363
  try {
226
- await swarmReady(ws.socketPath, ws.root, terminalHandle());
364
+ await awaitSubscriptionAck(socket);
365
+ }
366
+ catch (e) {
367
+ state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "subscribe_events_fault");
368
+ throw e;
369
+ }
370
+ const task = envTaskId();
371
+ const sessionTaskId = task || state.swarmTaskKey || `session:${terminalHandle() || randomUUID()}`;
372
+ state.swarmTaskKey = sessionTaskId;
373
+ const sessionCtx = state.swarmContext;
374
+ if (sessionCtx?.appendEntry) {
375
+ const restored = restoreSwarmState(sessionCtx, sessionTaskId);
376
+ state.swarmSession?.stopHeartbeat();
377
+ state.swarmSession = new SwarmSessionState(sessionCtx, sessionTaskId, restored);
378
+ state.swarmSession.startSession();
379
+ }
380
+ const readyPayload = { workspace: ws.root, terminal_handle: terminalHandle() };
381
+ await sendSwarmIntent("swarm_ready", readyPayload, (opId) => swarmReady(ws.socketPath, ws.root, terminalHandle(), state.swarmSession?.snapshot(), opId), currentConfig().outbound.retry.attempts);
382
+ if (state.swarmSession) {
383
+ state.swarmSession.transition({ agent: "ready", resource: "attached", lifecycle: "idle", recovery: "idle_waiting" }, "swarm_ready");
384
+ // Awaits the daemon receipt: the scheduler holds this session's first
385
+ // watermark before it reads anything else from the workspace.
386
+ await emitSwarmReport("ready");
387
+ state.swarmSession.startHeartbeat(10_000, swarmHeartbeat);
227
388
  }
228
- catch { /* scheduler may read env fallback */ }
229
389
  // The task may already sit in history (scheduler wrote in before this
230
390
  // session finished starting). Claim it now instead of waiting for a
231
391
  // live event that already fired.
@@ -252,8 +412,13 @@ async function startWatch(pi) {
252
412
  state.watching = true;
253
413
  return `watching ${state.workspace.root} (${state.owner})`;
254
414
  }
255
- function stopWatch() { if (state.reconnectTimer)
256
- 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"; }
415
+ function stopWatch(clearSession = true) { if (state.reconnectTimer)
416
+ clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; if (clearSession) {
417
+ state.swarmSession?.shutdown();
418
+ state.swarmSession = undefined;
419
+ }
420
+ else
421
+ state.swarmSession?.stopHeartbeat(); state.watching = false; state.owner = "stopped"; state.swarmTask = undefined; swarmSlot.clear(); return "watch stopped"; }
257
422
  async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
258
423
  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}`; }
259
424
  async function stopDaemon() { if (!state.workspace)
@@ -272,7 +437,6 @@ function swarmTreePath() {
272
437
  throw new Error("onlyne workspace not found");
273
438
  return treePathForWorkspace(state.workspace.root);
274
439
  }
275
- /** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
276
440
  async function swarmComplete(text) {
277
441
  if (!state.workspace)
278
442
  throw new Error("onlyne workspace not found");
@@ -281,63 +445,80 @@ async function swarmComplete(text) {
281
445
  throw new Error("no active swarm task");
282
446
  const { renderSwarmHeader } = await import("./swarm.js");
283
447
  const wire = renderSwarmHeader({ task_id: task.taskId, from: swarmTreePath(), transfer_send_to: task.transferSendTo, attempt: task.attempt }, "", text);
284
- const res = await sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, currentConfig().outbound.retry.attempts);
285
- if (res.ok) {
286
- state.swarmTask = undefined;
287
- swarmSlot.clear();
288
- }
289
- return { ...res, taskId: task.taskId };
290
- }
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. */
448
+ const payload = { task_id: task.taskId, text: wire };
449
+ const result = await sendSwarmIntent("swarm_complete", payload, (opId) => sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, 1, false, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_complete", task.taskId));
450
+ state.swarmSession?.transition({ agent: "ready", resource: "closing", lifecycle: "exited", recovery: "draining" }, "swarm_complete");
451
+ // Awaits the daemon receipt on the terminal evidence for this generation.
452
+ await emitSwarmReport("complete");
453
+ state.swarmTask = undefined;
454
+ swarmSlot.clear();
455
+ clearReminder();
456
+ try {
457
+ stopWatch(false);
458
+ }
459
+ catch { /* lifecycle cleanup */ }
460
+ setTimeout(() => process.exit(0), 300);
461
+ return { ...(result && typeof result === "object" ? result : { ok: true }), taskId: task.taskId };
462
+ }
296
463
  async function swarmQuit(reason) {
297
464
  const task = state.swarmTask;
298
465
  const taskId = task?.taskId ?? "";
299
466
  const why = `quit:${reason ?? ""}`;
300
- try {
301
- if (state.workspace && taskId) {
302
- await swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why);
467
+ if (state.workspace && taskId) {
468
+ try {
469
+ await sendSwarmIntent("swarm_recycled", { task_id: taskId, reason: why }, (opId) => swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_recycled", `quit:${taskId}`));
303
470
  }
471
+ catch { /* fault persists; quit still exits */ }
304
472
  }
305
- catch { /* daemon may be gone; exit anyway */ }
473
+ // A quit writes no out, so Failed is the accurate projection of this hop.
474
+ // The report carries this session's own watermark next to the swarm_recycled
475
+ // ack above, and its receipt is collected before the exit path runs.
476
+ await emitSwarmReport("fault");
306
477
  clearReminder();
307
478
  try {
308
- stopWatch();
479
+ stopWatch(false);
309
480
  }
310
- catch { /* already stopped */ }
481
+ catch { /* lifecycle cleanup */ }
311
482
  state.swarmTask = undefined;
312
483
  swarmSlot.clear();
313
484
  setTimeout(() => process.exit(0), 300);
314
485
  return { quit: true, taskId, reason: reason ?? "" };
315
486
  }
316
- /** Handle a downlink recycle signal: accept, ack, release, self-terminate.
317
- * No out is written (the scheduler already recorded the terminal state). */
318
487
  async function handleSwarmRecycle(ctlTaskId, reason) {
319
488
  const claimed = swarmSlot.task().taskId;
320
489
  const taskId = ctlTaskId === "*" ? (claimed ?? "") : ctlTaskId;
321
490
  if (claimed && ctlTaskId !== "*" && claimed !== ctlTaskId)
322
491
  return false;
323
492
  const why = `recycle:${reason}`;
324
- try {
325
- if (state.workspace && taskId) {
326
- await swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why);
493
+ // The scheduler initiated this recycle, so its own ledger already holds the
494
+ // truth about the hop. A report from here would restate a transition that
495
+ // side drove, hence no lifecycle report on this path.
496
+ if (state.workspace && taskId) {
497
+ try {
498
+ await sendSwarmIntent("swarm_recycled", { task_id: taskId, reason: why }, (opId) => swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_recycled", `recycle:${taskId}`));
327
499
  }
500
+ catch { /* fault persists; recycle still exits */ }
328
501
  }
329
- catch { /* daemon may be gone; exit anyway */ }
330
502
  clearReminder();
331
503
  try {
332
- stopWatch();
504
+ stopWatch(false);
333
505
  }
334
- catch { /* already stopped */ }
506
+ catch { /* lifecycle cleanup */ }
335
507
  state.swarmTask = undefined;
336
508
  swarmSlot.clear();
337
509
  setTimeout(() => process.exit(0), 300);
338
510
  return true;
339
511
  }
340
- /** swarm_send: spawn downstream. New UUID, transfer_send_to = current task, fire-and-forget. */
512
+ /** Resolve a scheduler target symlink to that workspace's daemon socket. */
513
+ function swarmTargetSocket(linkPath, workspaceRoot) {
514
+ const targetIn = realpathSync(linkPath);
515
+ const root = resolve(workspaceRoot);
516
+ if (!targetIn.startsWith(root + "/") || !targetIn.endsWith("/.onlyne/channels/loopback/in"))
517
+ throw new Error(`invalid swarm_send target: ${linkPath}`);
518
+ const targetOnlyne = dirname(dirname(dirname(targetIn)));
519
+ return join(targetOnlyne, "run", "s");
520
+ }
521
+ /** swarm_send: spawn downstream through the target daemon's loopback RPC. */
341
522
  async function swarmSend(to, text) {
342
523
  if (!state.workspace)
343
524
  throw new Error("onlyne workspace not found");
@@ -345,27 +526,13 @@ async function swarmSend(to, text) {
345
526
  if (!task)
346
527
  throw new Error("no active swarm task");
347
528
  const { renderSwarmHeader } = await import("./swarm.js");
348
- // Resolve the target workspace dir: root "." or a tree path under the swarm tree.
349
- // The scheduler owns the tree; here we only verify the send-side symlink exists
350
- // (missing target = dangling link = error, never a blind FIFO write).
351
529
  const wsRoot = state.workspace.root;
352
- const linkPath = to === "." || to === "_root"
353
- ? join(wsRoot, "onlyne_in", "_root")
354
- : join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
355
- const resolved = resolve(wsRoot);
356
- const linkDir = resolve(linkPath);
357
- if (!linkDir.startsWith(resolved))
358
- throw new Error(`invalid swarm_send target: ${to}`);
359
- if (!existsSync(linkPath))
360
- throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
530
+ const linkPath = to === "." || to === "_root" ? join(wsRoot, "onlyne_in", "_root") : join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
361
531
  const childId = randomUUID();
362
532
  const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
363
- try {
364
- writeFileSync(linkPath, wire);
365
- }
366
- catch (e) {
367
- throw new Error(`swarm_send failed for ${to}: ${e instanceof Error ? e.message : String(e)}`);
368
- }
533
+ const targetSocket = swarmTargetSocket(linkPath, wsRoot);
534
+ const payload = { child_id: childId, to, task_id: task.taskId, text: wire };
535
+ await sendSwarmIntent("swarm_send", payload, (opId) => loopback(targetSocket, wire, true, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_send", childId));
369
536
  swarmSlot.noteSpawned(childId);
370
537
  return { childId, to };
371
538
  }
@@ -420,15 +587,16 @@ export default function onlyne(pi) {
420
587
  process.once(sig, () => stopWatch());
421
588
  pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
422
589
  state.lastValidOutput = text; });
423
- pi.on("agent_start", async () => clearReminder());
590
+ pi.on("agent_start", async () => { clearReminder(); swarmSessionTransition({ agent: "running", lifecycle: "working", recovery: "idle_waiting" }, "turn_start"); awaitIntent(emitSwarmReport("turn_started")); reportHopBusy().catch(() => { }); });
424
591
  pi.on("agent_end", async () => { if (state.swarm) {
425
592
  const cur = swarmSlot.task();
426
593
  if (cur.taskId && cachedCtx)
427
594
  void pinSwarmTitle(cachedCtx, cur.taskId);
595
+ swarmSessionTransition({ agent: "idle", lifecycle: "idle", recovery: "idle_waiting" }, "turn_idle");
428
596
  scheduleSwarmExitReminder(pi);
429
597
  }
430
598
  else
431
- scheduleReminder(pi); });
599
+ scheduleReminder(pi); reportHopIdle().catch(() => { }); });
432
600
  pi.registerCommand("onlyne", {
433
601
  description: "Onlyne watch/status/config commands",
434
602
  getArgumentCompletions: (prefix) => {
@@ -496,9 +664,9 @@ function applyToolSurface(pi) {
496
664
  }
497
665
  function registerNormalTools(pi) {
498
666
  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 }); } }));
499
- 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 }); } }));
667
+ pi.registerTool(defineTool({ name: "onlyne_daemon_stop", label: "Onlyne daemon stop", description: "Stop the current workspace-local Onlyne daemon through pi-onlyne lifecycle management. The tool also sends shutdown to a daemon already running in this workspace.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await stopDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
500
668
  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 }); } }));
501
- 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))); } }));
669
+ pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with Markdown to the current Onlyne inbound message. Use Markdown by default and preserve the message as one document.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await reply(params.text))); } }));
502
670
  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)
503
671
  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); } }));
504
672
  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)
@@ -513,9 +681,27 @@ function registerNormalTools(pi) {
513
681
  function registerSwarmTools(pi) {
514
682
  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))); } }));
515
683
  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))); } }));
516
- 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))); } }));
684
+ pi.registerTool(defineTool({ name: "swarm_send", label: "Swarm send", description: "Spawn a downstream swarm task through the target workspace daemon loopback RPC with transfer_send_to set to the current task. Stable op_id makes retries idempotent; 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))); } }));
517
685
  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 })); } }));
518
686
  }
687
+ /** R4 hop activity reporters: turn-granularity busy/idle over the existing
688
+ * swarm event channel. Fire-and-forget (failures silent): the scheduler
689
+ * treats missing rows as race residue, and its TTLs are the backstop.
690
+ * busy fires only with a live claimed task; idle fires only when the turn
691
+ * ended without an out (swarmTask still set). The decision (guard +
692
+ * pending_exit) is the shared, unit-tested hopActivityBody. */
693
+ async function reportHopBusy() {
694
+ const body = hopActivityBody("busy", { swarm: state.swarm, workspaceRoot: state.workspace?.root, terminal: terminalHandle(), taskId: state.swarmTask?.taskId });
695
+ if (!body || !state.workspace)
696
+ return;
697
+ await sendSwarmIntent("swarm_busy", body, (opId) => swarmBusy(state.workspace.socketPath, body, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_busy", `${state.swarmTask?.taskId ?? ""}:${state.swarmSession?.seq ?? 0}`));
698
+ }
699
+ async function reportHopIdle() {
700
+ const body = hopActivityBody("idle", { swarm: state.swarm, workspaceRoot: state.workspace?.root, terminal: terminalHandle(), taskId: state.swarmTask?.taskId });
701
+ if (!body || !state.workspace)
702
+ return;
703
+ await sendSwarmIntent("swarm_idle", body, (opId) => swarmIdle(state.workspace.socketPath, body, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_idle", `${state.swarmTask?.taskId ?? ""}:${state.swarmSession?.seq ?? 0}`));
704
+ }
519
705
  /** Swarm exit guard: at agent end, an unfinished hop gets one reminder, then auto-quit. */
520
706
  function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
521
707
  clearReminder();
@@ -574,3 +760,21 @@ async function setSwarm(pi, enabled) {
574
760
  }
575
761
  return `swarm ${enabled ? "on" : "off"} (watch not running)`;
576
762
  }
763
+ /** Test seam: drive the lifecycle-report wiring against a stub daemon socket.
764
+ * No live pi session and no real daemon are involved; the seam exposes the
765
+ * same closures the hooks call. Mirrors `__swarmSlotForTest`. */
766
+ export function __swarmReportForTest() {
767
+ return {
768
+ setWorkspace: (ws) => { state.workspace = ws; },
769
+ beginSession: (appendEntry, taskId) => {
770
+ state.swarmSession?.shutdown();
771
+ state.swarmSession = new SwarmSessionState({ appendEntry }, taskId);
772
+ state.swarmSession.startSession();
773
+ return { generation: state.swarmSession.generation, seq: state.swarmSession.seq };
774
+ },
775
+ setTask: (task) => { state.swarmTask = task; },
776
+ reportTaskId: swarmReportTaskId,
777
+ emit: emitSwarmReport,
778
+ beat: swarmHeartbeat,
779
+ };
780
+ }