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/onlyne.d.ts CHANGED
@@ -4,12 +4,15 @@ import type { Workspace } from "./workspace.js";
4
4
  export interface OnlyneRequest {
5
5
  id: string;
6
6
  op: string;
7
+ op_id?: string;
7
8
  channel_id?: string;
9
+ conversation_id?: string;
8
10
  message_id?: string;
9
11
  text?: string;
10
12
  format?: "plain" | "markdown";
11
13
  raw_text?: boolean;
12
14
  limit?: number;
15
+ offset?: number;
13
16
  priority?: number;
14
17
  consume_timeout_ms?: number;
15
18
  event_seq?: number;
@@ -22,10 +25,13 @@ export interface SendResult extends SendTarget {
22
25
  error?: string;
23
26
  }
24
27
  export declare function request(socketPath: string, req: OnlyneRequest): Promise<any>;
28
+ export interface SwarmSubscription extends Socket {
29
+ ack: Promise<any>;
30
+ }
25
31
  export declare function subscribe(socketPath: string, onLine: (line: any) => void, onDisconnect?: () => void, opts?: {
26
32
  priority?: number;
27
33
  consumeTimeoutMs?: number;
28
- }): Socket;
34
+ }): SwarmSubscription;
29
35
  export declare function waitForSocket(socketPath: string, timeoutMs?: number): Promise<void>;
30
36
  export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean): Promise<{
31
37
  owner: "external" | "extension";
@@ -33,10 +39,32 @@ export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean):
33
39
  }>;
34
40
  export declare function shutdownDaemon(ws: Workspace, child?: ChildProcess): Promise<void>;
35
41
  export declare function stopProcess(child?: ChildProcess): void;
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>;
42
+ export declare function swarmReady(socketPath: string, workspace: string, terminalHandle: string, snapshot?: unknown, opId?: string): Promise<any>;
43
+ export declare function swarmRecycled(socketPath: string, taskId: string, terminalHandle: string, reason: string, opId?: string): Promise<any>;
44
+ /** R4 hop activity: turn started. Fire-and-forget; the scheduler treats a
45
+ * missing task row as race residue and drops it silently. `body` comes from
46
+ * swarm.ts hopActivityBody (the shared, unit-tested decision). */
47
+ export declare function swarmBusy(socketPath: string, body: {
48
+ workspace: string;
49
+ terminal_handle: string;
50
+ task_id: string;
51
+ }, opId?: string): Promise<any>;
52
+ /** R4 hop activity: turn ended with no out. `body.pending_exit` mirrors whether
53
+ * the hop still awaits swarm_complete — the scheduler only records it. */
54
+ export declare function swarmIdle(socketPath: string, body: {
55
+ workspace: string;
56
+ terminal_handle: string;
57
+ task_id: string;
58
+ pending_exit?: boolean;
59
+ }, opId?: string): Promise<any>;
38
60
  export declare function consumeEvent(socket: Socket, eventSeq: number): Promise<void>;
39
- export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
61
+ export declare function loopback(socketPath: string, text: string, rawText?: boolean, opId?: string): Promise<any>;
62
+ /** Versioned lifecycle evidence for the scheduler reducer. The frame travels as
63
+ * a raw-text loopback inbound: the daemon stores `text` verbatim and publishes
64
+ * it on the same event stream the report consumer reads. `raw_text` keeps the
65
+ * frame out of Markdown processing, and `opId` makes a socket-level retry
66
+ * return the stored receipt. */
67
+ export declare function sendSwarmReport(socketPath: string, frame: string, opId?: string): Promise<any>;
40
68
  export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
41
- export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean): Promise<SendResult>;
69
+ export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean, opId?: string): Promise<SendResult>;
42
70
  export declare function broadcast(socketPath: string, targets: SendTarget[], text: string, attempts: number, concurrency: number, rawText?: boolean): Promise<SendResult[]>;
package/dist/onlyne.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createConnection } from "node:net";
3
+ import { SWARM_REPORT_PREFIX } from "./swarm.js";
3
4
  export function request(socketPath, req) {
4
5
  return new Promise((resolve, reject) => {
5
6
  const socket = createConnection(socketPath);
@@ -10,7 +11,11 @@ export function request(socketPath, req) {
10
11
  socket.on("data", (chunk) => { data += chunk; const idx = data.indexOf("\n"); if (idx >= 0) {
11
12
  socket.end();
12
13
  try {
13
- resolve(JSON.parse(data.slice(0, idx)));
14
+ const response = JSON.parse(data.slice(0, idx));
15
+ if (response && response.ok === false)
16
+ reject(new Error(response.error?.message ?? JSON.stringify(response.error ?? response)));
17
+ else
18
+ resolve(response);
14
19
  }
15
20
  catch (e) {
16
21
  reject(e);
@@ -23,8 +28,11 @@ export function subscribe(socketPath, onLine, onDisconnect, opts) {
23
28
  let buf = "";
24
29
  let closed = false;
25
30
  socket.setEncoding("utf8");
31
+ let resolveAck;
32
+ let rejectAck;
33
+ socket.ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
26
34
  const disconnect = () => { if (closed)
27
- return; closed = true; onDisconnect?.(); };
35
+ return; closed = true; rejectAck(new Error("onlyne subscription disconnected")); onDisconnect?.(); };
28
36
  socket.on("error", disconnect);
29
37
  socket.on("close", disconnect);
30
38
  socket.on("connect", () => { if (!socket.destroyed)
@@ -38,9 +46,16 @@ export function subscribe(socketPath, onLine, onDisconnect, opts) {
38
46
  if (!raw.trim())
39
47
  continue;
40
48
  try {
41
- onLine(JSON.parse(raw));
49
+ const line = JSON.parse(raw);
50
+ if (line.id === "sub") {
51
+ if (line.ok)
52
+ resolveAck(line);
53
+ else
54
+ rejectAck(new Error(line.error?.message ?? "subscribe_events rejected"));
55
+ }
56
+ onLine(line);
42
57
  }
43
- catch { /* ignore */ }
58
+ catch { /* malformed event lines stay outside the lifecycle protocol */ }
44
59
  } });
45
60
  return socket;
46
61
  }
@@ -109,11 +124,22 @@ export function stopProcess(child) { if (!child || child.killed)
109
124
  child.kill("SIGTERM");
110
125
  }
111
126
  catch { /* ignore */ } }
112
- export async function swarmReady(socketPath, workspace, terminalHandle) {
113
- return request(socketPath, { id: `swarm-ready-${Date.now()}`, op: "swarm_ready", text: JSON.stringify({ workspace, terminal_handle: terminalHandle }) });
127
+ export async function swarmReady(socketPath, workspace, terminalHandle, snapshot, opId) {
128
+ return request(socketPath, { id: `swarm-ready-${Date.now()}`, op: "swarm_ready", op_id: opId, text: JSON.stringify({ protocol: 2, workspace, terminal_handle: terminalHandle, snapshot }) });
129
+ }
130
+ export async function swarmRecycled(socketPath, taskId, terminalHandle, reason, opId) {
131
+ return request(socketPath, { id: `swarm-recycled-${Date.now()}`, op: "swarm_recycled", op_id: opId, text: JSON.stringify({ task_id: taskId, terminal_handle: terminalHandle, reason }) });
114
132
  }
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 }) });
133
+ /** R4 hop activity: turn started. Fire-and-forget; the scheduler treats a
134
+ * missing task row as race residue and drops it silently. `body` comes from
135
+ * swarm.ts hopActivityBody (the shared, unit-tested decision). */
136
+ export async function swarmBusy(socketPath, body, opId) {
137
+ return request(socketPath, { id: `swarm-busy-${Date.now()}`, op: "swarm_busy", op_id: opId, text: JSON.stringify(body) });
138
+ }
139
+ /** R4 hop activity: turn ended with no out. `body.pending_exit` mirrors whether
140
+ * the hop still awaits swarm_complete — the scheduler only records it. */
141
+ export async function swarmIdle(socketPath, body, opId) {
142
+ return request(socketPath, { id: `swarm-idle-${Date.now()}`, op: "swarm_idle", op_id: opId, text: JSON.stringify(body) });
117
143
  }
118
144
  export async function consumeEvent(socket, eventSeq) {
119
145
  return new Promise((resolve) => { try {
@@ -123,17 +149,27 @@ export async function consumeEvent(socket, eventSeq) {
123
149
  resolve();
124
150
  } });
125
151
  }
126
- export async function loopback(socketPath, text, rawText = true) {
127
- return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", text, raw_text: rawText });
152
+ export async function loopback(socketPath, text, rawText = true, opId) {
153
+ return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", op_id: opId, text, raw_text: rawText });
154
+ }
155
+ /** Versioned lifecycle evidence for the scheduler reducer. The frame travels as
156
+ * a raw-text loopback inbound: the daemon stores `text` verbatim and publishes
157
+ * it on the same event stream the report consumer reads. `raw_text` keeps the
158
+ * frame out of Markdown processing, and `opId` makes a socket-level retry
159
+ * return the stored receipt. */
160
+ export async function sendSwarmReport(socketPath, frame, opId) {
161
+ if (!frame.startsWith(SWARM_REPORT_PREFIX))
162
+ throw new Error("swarm report frame needs the ---swarm-report header");
163
+ return loopback(socketPath, frame, true, opId);
128
164
  }
129
165
  export async function markConsumed(socketPath, messageId) {
130
166
  return request(socketPath, { id: `consume-${Date.now()}`, op: "mark_io_consumed", message_id: messageId });
131
167
  }
132
- export async function sendWithRetry(socketPath, target, text, attempts, rawText = false) {
168
+ export async function sendWithRetry(socketPath, target, text, attempts, rawText = false, opId) {
133
169
  let error = "unknown error";
134
170
  for (let i = 0; i < Math.max(1, attempts); i++) {
135
171
  try {
136
- const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", channel_id: target.channelId, text, raw_text: rawText });
172
+ const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", op_id: opId, channel_id: target.channelId, text, raw_text: rawText });
137
173
  if (res.ok)
138
174
  return { ...target, ok: true };
139
175
  error = res.error?.message ?? JSON.stringify(res.error ?? res);
@@ -0,0 +1,82 @@
1
+ export declare const SWARM_STATE_ENTRY = "pi-onlyne:swarm-state";
2
+ export declare const SWARM_PROTOCOL = 2;
3
+ export type AgentState = "booting" | "ready" | "running" | "idle" | "gone";
4
+ export type DeliveryState = "none" | "pending" | "retrying" | "accepted" | "exhausted";
5
+ export type ResourceState = "detached" | "attached" | "closing" | "closed";
6
+ export type Lifecycle = "created" | "working" | "idle" | "exited";
7
+ export type RecoveryState = "idle_waiting" | "idle_fault" | "draining";
8
+ export interface SwarmSnapshot {
9
+ protocol: 2;
10
+ task_id: string;
11
+ generation: number;
12
+ seq: number;
13
+ agent: AgentState;
14
+ delivery: DeliveryState;
15
+ resource: ResourceState;
16
+ lifecycle: Lifecycle;
17
+ recovery: RecoveryState;
18
+ pending_intents: string[];
19
+ at: string;
20
+ }
21
+ export interface SwarmIntent {
22
+ op_id: string;
23
+ kind: string;
24
+ payload: unknown;
25
+ attempts: number;
26
+ status: "pending" | "retrying" | "accepted" | "exhausted";
27
+ receipt?: unknown;
28
+ last_error?: string;
29
+ }
30
+ export interface SwarmStateData {
31
+ protocol: 2;
32
+ task_id: string;
33
+ generation: number;
34
+ seq: number;
35
+ snapshot: SwarmSnapshot;
36
+ intents: SwarmIntent[];
37
+ }
38
+ export interface SessionEntry {
39
+ type?: string;
40
+ customType?: string;
41
+ data?: unknown;
42
+ }
43
+ export interface StateContext {
44
+ appendEntry: (customType: string, data?: unknown) => void;
45
+ sessionManager?: {
46
+ getEntries(): readonly SessionEntry[];
47
+ };
48
+ }
49
+ export declare function restoreSwarmState(ctx: StateContext, taskId: string): SwarmStateData | undefined;
50
+ export declare class SwarmSessionState {
51
+ readonly taskId: string;
52
+ private append;
53
+ private _generation;
54
+ private _seq;
55
+ private _snapshot;
56
+ private _intents;
57
+ private timers;
58
+ private heartbeatTimer?;
59
+ constructor(ctx: StateContext, taskId: string, restored?: SwarmStateData);
60
+ startSession(): SwarmSnapshot;
61
+ get generation(): number;
62
+ get seq(): number;
63
+ get intents(): SwarmIntent[];
64
+ snapshot(): SwarmSnapshot;
65
+ private makeSnapshot;
66
+ private persist;
67
+ transition(patch: Partial<SwarmSnapshot>, kind?: string): SwarmSnapshot;
68
+ heartbeat(): SwarmSnapshot;
69
+ recordIntent(kind: string, payload: unknown, opId: string): SwarmIntent;
70
+ receipt(opId: string, receipt: unknown): boolean;
71
+ retry(opId: string, send: (intent: SwarmIntent) => Promise<unknown>, maxAttempts?: number): Promise<SwarmIntent>;
72
+ startHeartbeat(intervalMs?: number, run?: () => void): void;
73
+ stopHeartbeat(): void;
74
+ shutdown(): void;
75
+ }
76
+ export declare function compareSwarmVersion(a: {
77
+ generation: number;
78
+ seq: number;
79
+ }, b: {
80
+ generation: number;
81
+ seq: number;
82
+ }): "stale" | "duplicate" | "new";
@@ -0,0 +1,91 @@
1
+ export const SWARM_STATE_ENTRY = "pi-onlyne:swarm-state";
2
+ export const SWARM_PROTOCOL = 2;
3
+ function isObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
4
+ function validState(v) {
5
+ if (!isObject(v) || v.protocol !== 2 || typeof v.task_id !== "string" || typeof v.generation !== "number" || typeof v.seq !== "number" || !isObject(v.snapshot) || !Array.isArray(v.intents))
6
+ return false;
7
+ return v.snapshot.protocol === 2 && v.snapshot.task_id === v.task_id && v.snapshot.generation === v.generation && typeof v.snapshot.seq === "number";
8
+ }
9
+ export function restoreSwarmState(ctx, taskId) {
10
+ let found;
11
+ for (const entry of ctx.sessionManager?.getEntries() ?? []) {
12
+ if (entry.type !== "custom" || entry.customType !== SWARM_STATE_ENTRY)
13
+ continue;
14
+ if (!validState(entry.data))
15
+ throw new Error("malformed swarm v2 state entry");
16
+ if (entry.data.task_id !== taskId)
17
+ continue;
18
+ if (!found || entry.data.seq > found.seq || entry.data.generation > found.generation)
19
+ found = entry.data;
20
+ }
21
+ return found;
22
+ }
23
+ export class SwarmSessionState {
24
+ taskId;
25
+ append;
26
+ _generation = 0;
27
+ _seq = 0;
28
+ _snapshot;
29
+ _intents = new Map();
30
+ timers = new Set();
31
+ heartbeatTimer;
32
+ constructor(ctx, taskId, restored) {
33
+ this.taskId = taskId;
34
+ this.append = ctx.appendEntry;
35
+ if (restored) {
36
+ this._generation = restored.generation;
37
+ this._seq = restored.seq;
38
+ this._snapshot = structuredClone(restored.snapshot);
39
+ for (const i of restored.intents)
40
+ this._intents.set(i.op_id, structuredClone(i));
41
+ }
42
+ else
43
+ this._snapshot = this.makeSnapshot();
44
+ }
45
+ startSession() { this._generation++; return this.persist("session_start", { generation: this._generation }); }
46
+ get generation() { return this._generation; }
47
+ get seq() { return this._seq; }
48
+ get intents() { return [...this._intents.values()].map((x) => structuredClone(x)); }
49
+ snapshot() { return structuredClone(this._snapshot); }
50
+ makeSnapshot() { return { protocol: 2, task_id: this.taskId, generation: this._generation, seq: this._seq, agent: "booting", delivery: "none", resource: "detached", lifecycle: "created", recovery: "idle_waiting", pending_intents: [...this._intents.keys()], at: new Date().toISOString() }; }
51
+ persist(kind, patch) { this._seq++; this._snapshot = { ...this._snapshot, ...patch, protocol: 2, task_id: this.taskId, generation: this._generation, seq: this._seq, pending_intents: [...this._intents.values()].filter((i) => i.status !== "accepted").map((i) => i.op_id), at: new Date().toISOString() }; this.append(SWARM_STATE_ENTRY, { protocol: 2, task_id: this.taskId, generation: this._generation, seq: this._seq, snapshot: this._snapshot, intents: this.intents, kind }); return this.snapshot(); }
52
+ transition(patch, kind = "snapshot") { return this.persist(kind, patch); }
53
+ heartbeat() { return this.persist("swarm_heartbeat"); }
54
+ recordIntent(kind, payload, opId) { if (this._intents.has(opId))
55
+ return structuredClone(this._intents.get(opId)); const intent = { op_id: opId, kind, payload, attempts: 0, status: "pending" }; this._intents.set(opId, intent); this.persist(kind, { delivery: "pending" }); return structuredClone(intent); }
56
+ receipt(opId, receipt) { const i = this._intents.get(opId); if (!i)
57
+ return false; i.status = "accepted"; i.receipt = receipt; this.persist("intent_receipt", { delivery: "accepted", lifecycle: i.kind === "swarm_complete" ? "exited" : this._snapshot.lifecycle }); return true; }
58
+ async retry(opId, send, maxAttempts = 3) { const i = this._intents.get(opId); if (!i)
59
+ throw new Error(`unknown intent ${opId}`); for (let n = i.attempts; n < maxAttempts && i.status !== "accepted"; n++) {
60
+ i.attempts++;
61
+ i.status = "retrying";
62
+ this.persist("intent_attempt", { delivery: "retrying" });
63
+ try {
64
+ const receipt = await send(structuredClone(i));
65
+ this.receipt(opId, receipt);
66
+ }
67
+ catch (e) {
68
+ i.last_error = e instanceof Error ? e.message : String(e);
69
+ if (i.attempts >= maxAttempts) {
70
+ i.status = "exhausted";
71
+ this.persist("intent_exhausted", { delivery: "exhausted", recovery: "idle_fault" });
72
+ }
73
+ else {
74
+ const delay = 1000 * 2 ** (i.attempts - 1);
75
+ await new Promise((resolve) => { const t = setTimeout(() => { this.timers.delete(t); resolve(); }, delay); this.timers.add(t); });
76
+ }
77
+ }
78
+ } return structuredClone(i); }
79
+ startHeartbeat(intervalMs = 10_000, run = () => this.heartbeat()) {
80
+ this.stopHeartbeat();
81
+ this.heartbeatTimer = setInterval(() => run(), Math.max(1, intervalMs));
82
+ this.heartbeatTimer.unref?.();
83
+ }
84
+ stopHeartbeat() { if (this.heartbeatTimer)
85
+ clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
86
+ shutdown() { this.stopHeartbeat(); for (const t of this.timers)
87
+ clearTimeout(t); this.timers.clear(); }
88
+ }
89
+ export function compareSwarmVersion(a, b) { if (a.generation < b.generation || (a.generation === b.generation && a.seq < b.seq))
90
+ return "stale"; if (a.generation === b.generation && a.seq === b.seq)
91
+ return "duplicate"; return "new"; }
@@ -1,12 +1,6 @@
1
1
  export type SwarmHandleResult = "claimed" | "yielded" | "not-swarm";
2
2
  export interface SwarmPi {
3
- sendMessage: (message: {
4
- customType: string;
5
- content: string;
6
- display: boolean;
7
- details?: unknown;
8
- }, opts: {
9
- triggerTurn: true;
3
+ sendUserMessage: (content: string, opts: {
10
4
  deliverAs: "followUp";
11
5
  }) => void;
12
6
  }
@@ -15,32 +15,35 @@ export class SwarmSlot {
15
15
  const parsed = parseSwarmHeader(text);
16
16
  if (!parsed)
17
17
  return "not-swarm";
18
- const { header, payload } = parsed;
18
+ const { header, delivery, payload } = parsed;
19
+ // A scheduler-created terminal carries ONLYNE_SWARM_TASK. Its task id
20
+ // is a hard delivery binding: history replay or a stale live event for
21
+ // another task must never claim this session. Without this guard a
22
+ // freshly spawned pane can run an expired workspace-local payload
23
+ // before its assigned FIFO write reaches history.
24
+ if (preferredTaskId && header.task_id !== preferredTaskId)
25
+ return "not-swarm";
26
+ // Only the scheduler's second delivery is claimable. A worker's raw
27
+ // swarm_send relay and every out wire omit this structured field, so
28
+ // history catchup cannot claim either one as an executable task.
29
+ if (delivery !== "scheduler")
30
+ return "not-swarm";
19
31
  if (!this.taskId) {
20
32
  this.taskId = header.task_id;
21
33
  this.from = header.from;
22
34
  this.transfer = header.transfer_send_to;
23
35
  this.attempt = header.attempt;
24
36
  this.sentChildIds = [];
25
- // Cold start needs triggerTurn: followUp alone only queues behind an
26
- // active turn, and a fresh session has none. sendMessage with
27
- // triggerTurn starts the model turn immediately.
28
- pi.sendMessage({ customType: "onlyne-swarm-task", content: resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, display: true }, { triggerTurn: true, deliverAs: "followUp" });
37
+ // A scheduler delivery is a real user task. `sendUserMessage` always
38
+ // triggers a turn, including during a fresh Pi session; custom
39
+ // `sendMessage` follow-ups can remain queued until a later event while
40
+ // the session_start hook is still settling.
41
+ pi.sendUserMessage(resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, { deliverAs: "followUp" });
29
42
  return "claimed";
30
43
  }
31
- // Env-task preemption: the scheduler injected ONLYNE_SWARM_TASK for
32
- // this terminal. If the slot holds a stale claim (history catchup
33
- // grabbed an already-done task) and the env task arrives, yield the
34
- // slot silently — the stale claim never did work, so nothing is lost.
35
- if (preferredTaskId && header.task_id === preferredTaskId && this.taskId !== preferredTaskId) {
36
- this.taskId = header.task_id;
37
- this.from = header.from;
38
- this.transfer = header.transfer_send_to;
39
- this.attempt = header.attempt;
40
- this.sentChildIds = [];
41
- pi.sendMessage({ customType: "onlyne-swarm-task", content: resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, display: true }, { triggerTurn: true, deliverAs: "followUp" });
42
- return "yielded";
43
- }
44
+ // A claimed session normally receives no further delivery. The
45
+ // preferred-id guard above already protected an env-bound pane, so this
46
+ // branch only applies to manual ready-pool sessions with no env task.
44
47
  return "not-swarm";
45
48
  }
46
49
  task() {
package/dist/swarm.d.ts CHANGED
@@ -5,10 +5,37 @@ export interface SwarmHeader {
5
5
  transfer_send_to: string;
6
6
  attempt: number;
7
7
  }
8
+ /** `scheduler` identifies the scheduler's second delivery. Raw relay and out
9
+ * wires omit it, so a cold-start history scan cannot claim them. */
8
10
  export interface SwarmMessage {
9
11
  header: SwarmHeader;
12
+ delivery: string;
10
13
  payload: string;
11
14
  }
15
+ /** Frame header for versioned lifecycle evidence consumed by the scheduler
16
+ * reducer. A distinct prefix from `---swarm` / `---swarm-ctl`, so a report
17
+ * never enters the task delivery paths on either side. */
18
+ export declare const SWARM_REPORT_PREFIX = "---swarm-report\n";
19
+ /** Report kinds accepted by the scheduler's report parser. */
20
+ export declare const SWARM_REPORT_KINDS: readonly ["heartbeat", "ready", "turn_started", "snapshot", "complete", "fault"];
21
+ export type SwarmReportKind = (typeof SWARM_REPORT_KINDS)[number];
22
+ /** Whether a value carries scheduler task identity: a UUID. The session's own
23
+ * composite keys (`session:<handle>`) name no scheduler row, so a report built
24
+ * from one would land as unknown-session noise on that side. */
25
+ export declare function isSwarmTaskId(value: string): boolean;
26
+ /** Whether a loopback text is a lifecycle report frame. Reports live in the
27
+ * same loopback history and event stream as task wires, so every task scan
28
+ * skips them ahead of its malformed-header check. */
29
+ export declare function isSwarmReportFrame(text: string): boolean;
30
+ /** Render one lifecycle report frame:
31
+ * `---swarm-report\nprotocol: 2\nop: <kind>\ntask_id: <uuid>\ngeneration: <n>\nseq: <n>\n---\n`
32
+ * Field order, labels, values, and the closing delimiter match the scheduler
33
+ * parser byte for byte; its `fields()` rejects missing, unknown, and duplicate
34
+ * keys, so the frame carries exactly those five fields. The trailing `---\n`
35
+ * closes the header region the parser reads.
36
+ * Invalid input fails here with a diagnostic: a synthetic task id and an
37
+ * out-of-range counter never reach the wire. */
38
+ export declare function renderSwarmReport(kind: SwarmReportKind, taskId: string, generation: number, seq: number): string;
12
39
  /** Parse a `---swarm` body header. Returns null for non-swarm messages. */
13
40
  export declare function parseSwarmHeader(text: string): SwarmMessage | null;
14
41
  /** Downlink control wire (scheduler -> session). Header-only marker the
@@ -38,5 +65,32 @@ export declare function readSwarmModel(onlyneDir: string): {
38
65
  export declare function treePathForWorkspace(workspaceRoot: string): string;
39
66
  /** Terminal handle for swarm_ready correlation (injected by the scheduler). */
40
67
  export declare function terminalHandle(): string;
68
+ /** Choose a scheduler-delivered history wire for a fresh swarm session.
69
+ *
70
+ * A terminal with ONLYNE_SWARM_TASK has one assigned task. History may still
71
+ * contain arbitrary old unclosed wires for the same workspace, so it may
72
+ * replay only that exact id. If the assigned delivery has not reached history
73
+ * yet, return undefined and let the live inbound event deliver it. Sessions
74
+ * without an env assignment (manual ready pool) retain newest-unclosed
75
+ * fallback behavior. This is pure so the replay race is regression-tested.
76
+ */
77
+ export declare function selectHistoryDelivery(expectedTaskId: string | undefined, inbounds: readonly string[], outs: ReadonlySet<string>): string | undefined;
41
78
  /** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
42
79
  export declare function envTaskId(): string;
80
+ /** R4 hop-activity decision, pure so the guard and the pending_exit bit are
81
+ * unit-tested without a live daemon. Returns the daemon op body, or null when
82
+ * nothing should be reported: busy/idle only make sense for a swarm session
83
+ * that has a workspace and a live claimed task. An idle report always carries
84
+ * pending_exit=true — the plugin never sends idle after swarm_complete cleared
85
+ * the task (that path writes no event; the scheduler owns the recycle). */
86
+ export declare function hopActivityBody(kind: "busy" | "idle", opts: {
87
+ swarm: boolean;
88
+ workspaceRoot?: string;
89
+ terminal: string;
90
+ taskId?: string;
91
+ }): {
92
+ workspace: string;
93
+ terminal_handle: string;
94
+ task_id: string;
95
+ pending_exit?: boolean;
96
+ } | null;