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/swarm.js CHANGED
@@ -1,6 +1,47 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ /** Frame header for versioned lifecycle evidence consumed by the scheduler
5
+ * reducer. A distinct prefix from `---swarm` / `---swarm-ctl`, so a report
6
+ * never enters the task delivery paths on either side. */
7
+ export const SWARM_REPORT_PREFIX = "---swarm-report\n";
8
+ /** Report kinds accepted by the scheduler's report parser. */
9
+ export const SWARM_REPORT_KINDS = ["heartbeat", "ready", "turn_started", "snapshot", "complete", "fault"];
10
+ /** Whether a value carries scheduler task identity: a UUID. The session's own
11
+ * composite keys (`session:<handle>`) name no scheduler row, so a report built
12
+ * from one would land as unknown-session noise on that side. */
13
+ export function isSwarmTaskId(value) {
14
+ return UUID_RE.test(value.trim());
15
+ }
16
+ /** Whether a loopback text is a lifecycle report frame. Reports live in the
17
+ * same loopback history and event stream as task wires, so every task scan
18
+ * skips them ahead of its malformed-header check. */
19
+ export function isSwarmReportFrame(text) {
20
+ return text.startsWith(SWARM_REPORT_PREFIX);
21
+ }
22
+ /** u64 ceiling the scheduler validates for generation and seq. */
23
+ const MAX_REPORT_COUNTER = 9_007_199_254_740_991;
24
+ /** Render one lifecycle report frame:
25
+ * `---swarm-report\nprotocol: 2\nop: <kind>\ntask_id: <uuid>\ngeneration: <n>\nseq: <n>\n---\n`
26
+ * Field order, labels, values, and the closing delimiter match the scheduler
27
+ * parser byte for byte; its `fields()` rejects missing, unknown, and duplicate
28
+ * keys, so the frame carries exactly those five fields. The trailing `---\n`
29
+ * closes the header region the parser reads.
30
+ * Invalid input fails here with a diagnostic: a synthetic task id and an
31
+ * out-of-range counter never reach the wire. */
32
+ export function renderSwarmReport(kind, taskId, generation, seq) {
33
+ if (!SWARM_REPORT_KINDS.includes(kind))
34
+ throw new Error(`unknown swarm report kind: ${String(kind)}`);
35
+ const id = taskId.trim();
36
+ if (!isSwarmTaskId(id))
37
+ throw new Error(`swarm report needs a uuid task_id, got ${taskId || "<empty>"}`);
38
+ for (const [name, value] of [["generation", generation], ["seq", seq]]) {
39
+ if (!Number.isSafeInteger(value) || value < 0 || value > MAX_REPORT_COUNTER) {
40
+ throw new Error(`swarm report ${name} must be a u64 counter within ${MAX_REPORT_COUNTER}, got ${value}`);
41
+ }
42
+ }
43
+ return `${SWARM_REPORT_PREFIX}protocol: 2\nop: ${kind}\ntask_id: ${id}\ngeneration: ${generation}\nseq: ${seq}\n---\n`;
44
+ }
4
45
  /** Parse a `---swarm` body header. Returns null for non-swarm messages. */
5
46
  export function parseSwarmHeader(text) {
6
47
  const rest = text.startsWith("---swarm\n") ? text.slice("---swarm\n".length) : null;
@@ -24,38 +65,56 @@ export function parseSwarmHeader(text) {
24
65
  headRaw = rest.slice(0, alt);
25
66
  payload = rest.slice(alt + "\n---".length).replace(/^\n/, "");
26
67
  }
68
+ let protocol;
27
69
  let task_id;
28
- let from = ".";
70
+ let from;
29
71
  let transfer_send_to = "";
30
72
  let attempt = 1;
31
- let sawTransfer = false;
73
+ let delivery = "";
74
+ const seen = new Set();
75
+ const allowed = new Set(["protocol", "task_id", "from", "transfer_send_to", "attempt", "delivery"]);
32
76
  for (const line of headRaw.split("\n")) {
33
77
  const t = line.trim();
34
78
  if (!t || t.startsWith("#"))
35
79
  continue;
36
80
  const i = t.indexOf(":");
37
81
  if (i < 0)
38
- continue;
82
+ return null;
39
83
  const k = t.slice(0, i).trim();
40
84
  const v = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
41
- if (k === "task_id")
85
+ if (!allowed.has(k) || seen.has(k) || !v)
86
+ return null;
87
+ seen.add(k);
88
+ if (k === "protocol") {
89
+ if (!/^\d+$/.test(v) || Number(v) !== 2)
90
+ return null;
91
+ protocol = 2;
92
+ }
93
+ else if (k === "task_id")
42
94
  task_id = v;
43
95
  else if (k === "from")
44
- from = v || ".";
96
+ from = v;
45
97
  else if (k === "transfer_send_to") {
98
+ if (!UUID_RE.test(v))
99
+ return null;
46
100
  transfer_send_to = v;
47
- sawTransfer = true;
48
101
  }
49
- else if (k === "reply_to")
50
- return null;
51
- else if (k === "attempt")
52
- attempt = Number.parseInt(v, 10) || 1;
102
+ else if (k === "attempt") {
103
+ if (!/^\d+$/.test(v))
104
+ return null;
105
+ attempt = Number(v);
106
+ if (!Number.isSafeInteger(attempt) || attempt < 1)
107
+ return null;
108
+ }
109
+ else if (k === "delivery") {
110
+ if (v !== "scheduler")
111
+ return null;
112
+ delivery = v;
113
+ }
53
114
  }
54
- if (!sawTransfer)
115
+ if (protocol !== 2 || !task_id || !from || !UUID_RE.test(task_id.trim()))
55
116
  return null;
56
- if (!task_id || !UUID_RE.test(task_id.trim()))
57
- return null;
58
- return { header: { task_id: task_id.trim(), from, transfer_send_to, attempt }, payload };
117
+ return { header: { task_id: task_id.trim(), from, transfer_send_to, attempt }, delivery, payload };
59
118
  }
60
119
  export function parseSwarmCtl(text) {
61
120
  if (!text.startsWith("---swarm-ctl\n"))
@@ -89,9 +148,17 @@ export function parseSwarmCtl(text) {
89
148
  }
90
149
  /** Render a swarm body header in front of a Markdown payload. */
91
150
  export function renderSwarmHeader(header, role, payloadMarkdown) {
92
- let s = `---swarm\ntask_id: ${header.task_id}\nfrom: ${header.from}\ntransfer_send_to: ${header.transfer_send_to}\nattempt: ${header.attempt}\n---\n`;
93
- if (role)
94
- s += `## role: ${role}\n\n${role}\n`;
151
+ // Raw relay and out wires intentionally omit `delivery`. Scheduler owns the
152
+ // second delivery step and adds `delivery: scheduler` there.
153
+ let s = `---swarm\nprotocol: 2\ntask_id: ${header.task_id}\nfrom: ${header.from}\n`;
154
+ const transfer = header.transfer_send_to.trim();
155
+ if (transfer)
156
+ s += `transfer_send_to: ${transfer}\n`;
157
+ s += `attempt: ${header.attempt}\n---\n`;
158
+ // Flat role text follows the delimiter once, with no duplicate echo.
159
+ if (role) {
160
+ s += role.endsWith("\n") ? role + "\n" : role + "\n\n";
161
+ }
95
162
  s += payloadMarkdown;
96
163
  if (!s.endsWith("\n"))
97
164
  s += "\n";
@@ -184,7 +251,46 @@ export function treePathForWorkspace(workspaceRoot) {
184
251
  export function terminalHandle() {
185
252
  return process.env.ORCA_TERMINAL_HANDLE || process.env.ONLYNE_TERMINAL_HANDLE || "";
186
253
  }
254
+ /** Choose a scheduler-delivered history wire for a fresh swarm session.
255
+ *
256
+ * A terminal with ONLYNE_SWARM_TASK has one assigned task. History may still
257
+ * contain arbitrary old unclosed wires for the same workspace, so it may
258
+ * replay only that exact id. If the assigned delivery has not reached history
259
+ * yet, return undefined and let the live inbound event deliver it. Sessions
260
+ * without an env assignment (manual ready pool) retain newest-unclosed
261
+ * fallback behavior. This is pure so the replay race is regression-tested.
262
+ */
263
+ export function selectHistoryDelivery(expectedTaskId, inbounds, outs) {
264
+ if (expectedTaskId) {
265
+ return inbounds.find((text) => parseSwarmHeader(text)?.header.task_id === expectedTaskId);
266
+ }
267
+ for (let i = inbounds.length - 1; i >= 0; i--) {
268
+ const text = inbounds[i];
269
+ const taskId = parseSwarmHeader(text)?.header.task_id;
270
+ if (taskId && !outs.has(taskId))
271
+ return text;
272
+ }
273
+ return undefined;
274
+ }
187
275
  /** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
188
276
  export function envTaskId() {
189
277
  return process.env.ONLYNE_SWARM_TASK || "";
190
278
  }
279
+ /** R4 hop-activity decision, pure so the guard and the pending_exit bit are
280
+ * unit-tested without a live daemon. Returns the daemon op body, or null when
281
+ * nothing should be reported: busy/idle only make sense for a swarm session
282
+ * that has a workspace and a live claimed task. An idle report always carries
283
+ * pending_exit=true — the plugin never sends idle after swarm_complete cleared
284
+ * the task (that path writes no event; the scheduler owns the recycle). */
285
+ export function hopActivityBody(kind, opts) {
286
+ if (!opts.swarm || !opts.workspaceRoot || !opts.taskId)
287
+ return null;
288
+ const body = {
289
+ workspace: opts.workspaceRoot,
290
+ terminal_handle: opts.terminal,
291
+ task_id: opts.taskId,
292
+ };
293
+ if (kind === "idle")
294
+ body.pending_exit = true;
295
+ return body;
296
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension tools for sending messages through Onlyne.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",