pi-onlyne 0.7.0 → 0.7.3

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,12 +1,16 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
2
5
  import { Type } from "typebox";
3
6
  import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady } from "./onlyne.js";
4
7
  import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
5
8
  import { findWorkspace } from "./workspace.js";
6
- import { envTaskId, parseSwarmHeader, readSwarmEnabled, terminalHandle } from "./swarm.js";
9
+ import { envTaskId, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
7
10
  import { SwarmSlot } from "./swarm-slot.js";
8
11
  const swarmSlot = new SwarmSlot();
9
12
  const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
13
+ let cachedCtx = undefined;
10
14
  const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
11
15
  const currentConfig = () => loadConfig(state.cwd);
12
16
  function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
@@ -53,8 +57,104 @@ function scheduleReconnect(pi) {
53
57
  }
54
58
  }, 1000);
55
59
  }
60
+ /** Whether a history task already has an out (done) in this workspace. */
61
+ async function historyTaskDone(taskId) {
62
+ try {
63
+ const { request } = await import("./onlyne.js");
64
+ if (!state.workspace)
65
+ return false;
66
+ const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
67
+ const items = res?.data ?? res ?? [];
68
+ if (!Array.isArray(items))
69
+ return false;
70
+ const outs = new Set();
71
+ for (const m of items) {
72
+ const text = m?.text ?? "";
73
+ if (typeof text !== "string" || !text.startsWith("---swarm"))
74
+ continue;
75
+ if (m?.direction === "outbound") {
76
+ const p = parseSwarmHeader(text);
77
+ if (p)
78
+ outs.add(p.header.task_id);
79
+ }
80
+ }
81
+ if (outs.has(taskId))
82
+ return true;
83
+ // Stale-claim guard: an inbound older than the newest outbound batch
84
+ // belongs to a previous hop generation. Only the scheduler-assigned
85
+ // env task bypasses this check.
86
+ return false;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ /** Replay the newest unclaimed swarm task from loopback history. Idempotent.
93
+ * Prefers the scheduler-assigned env task (ONLYNE_SWARM_TASK); never claims
94
+ * an already-done task unless it is the env task (env wins, scheduler owns
95
+ * the truth about what this terminal must run). */
96
+ async function catchUpSwarmHistory(pi, ctx) {
97
+ if (swarmSlot.task().taskId)
98
+ return;
99
+ try {
100
+ const { request } = await import("./onlyne.js");
101
+ if (!state.workspace)
102
+ return;
103
+ const env = envTaskId();
104
+ const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
105
+ const items = res?.data ?? res ?? [];
106
+ if (!Array.isArray(items))
107
+ return;
108
+ const inbounds = [];
109
+ const outs = new Set();
110
+ for (const m of items) {
111
+ const text = m?.text ?? m?.content ?? "";
112
+ if (typeof text !== "string" || !text.startsWith("---swarm"))
113
+ continue;
114
+ const p = parseSwarmHeader(text);
115
+ if (!p)
116
+ continue;
117
+ if (m?.direction === "outbound")
118
+ outs.add(p.header.task_id);
119
+ else
120
+ inbounds.push(text);
121
+ }
122
+ // Env task first: exact task_id match on inbound, even if an out exists
123
+ // (out may belong to a previous generation; scheduler owns the truth).
124
+ if (env) {
125
+ for (const text of inbounds) {
126
+ const p = parseSwarmHeader(text);
127
+ if (p.header.task_id === env) {
128
+ if (handleSwarmInbound(pi, text, undefined, ctx))
129
+ return;
130
+ }
131
+ }
132
+ }
133
+ // Fallback: newest inbound with no out yet.
134
+ for (let i = inbounds.length - 1; i >= 0; i--) {
135
+ const p = parseSwarmHeader(inbounds[i]);
136
+ if (outs.has(p.header.task_id))
137
+ continue;
138
+ if (p.header.task_id === env)
139
+ continue; // already tried above
140
+ if (handleSwarmInbound(pi, inbounds[i]))
141
+ return;
142
+ }
143
+ }
144
+ catch { /* best effort; live events still arrive */ }
145
+ }
146
+ /** Pin the tab title for a claimed hop. Pi rewrites the create-time title on
147
+ * boot and on idle transitions, and the scheduler-side rename races those
148
+ * writes. Last writer wins, so the session re-asserts its own title on claim
149
+ * and again on agent_end (idle), which is pi's other rewrite point. */
150
+ function pinSwarmTitle(ctx, taskId) {
151
+ try {
152
+ ctx?.ui?.setTitle?.(`swarm:${swarmTreePath()}:${taskId.slice(0, 8)}`);
153
+ }
154
+ catch { /* title is cosmetic */ }
155
+ }
56
156
  /** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
57
- function handleSwarmInbound(pi, text, eventSeq) {
157
+ function handleSwarmInbound(pi, text, eventSeq, ctx) {
58
158
  const parsed = parseSwarmHeader(text);
59
159
  if (!parsed)
60
160
  return false;
@@ -63,12 +163,14 @@ function handleSwarmInbound(pi, text, eventSeq) {
63
163
  // Slot transitions live in SwarmSlot (unit-tested); here we only mirror
64
164
  // the claimed task into session state. A claimed session never accepts
65
165
  // another task; downstream work spawns new tasks via swarm_send.
66
- const outcome = swarmSlot.handle(pi, text);
67
- if (outcome === "claimed") {
166
+ const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined);
167
+ if (outcome === "claimed" || outcome === "yielded") {
68
168
  const cur = swarmSlot.task();
69
169
  state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
170
+ if (ctx)
171
+ void pinSwarmTitle(ctx, cur.taskId);
70
172
  }
71
- return outcome === "claimed";
173
+ return outcome === "claimed" || outcome === "yielded";
72
174
  }
73
175
  async function startWatch(pi) {
74
176
  state.workspace = findWorkspace(state.cwd);
@@ -89,6 +191,9 @@ async function startWatch(pi) {
89
191
  // Report readiness so the scheduler can match a pending task (fork+exec: any
90
192
  // clean ready session on this workspace path may take it).
91
193
  const ws = state.workspace;
194
+ // Priority 1 (above default 0): the scheduler consumes at MAX, then we
195
+ // take the event at tier 1 so it also reaches the session. A plain
196
+ // tier-0 subscription would starve whenever the scheduler consumes.
92
197
  const socket = subscribe(ws.socketPath, (line) => {
93
198
  if (!line?.event)
94
199
  return;
@@ -97,9 +202,14 @@ async function startWatch(pi) {
97
202
  const inbound = inboundText(line);
98
203
  if (!inbound || inbound.channelId !== "loopback")
99
204
  return;
205
+ // Catch up on history first: a fresh watch may subscribe after the
206
+ // task was already delivered (scheduler writes in before the
207
+ // session finishes starting). History replay is idempotent via
208
+ // the slot guard + scheduler task_id dedup.
209
+ void catchUpSwarmHistory(pi);
100
210
  handleSwarmInbound(pi, inbound.text, line.event_seq);
101
211
  }, () => { if (state.socket === socket)
102
- scheduleReconnect(pi); });
212
+ scheduleReconnect(pi); }, { priority: 1 });
103
213
  state.socket = socket;
104
214
  state.watching = true;
105
215
  const task = envTaskId();
@@ -107,6 +217,10 @@ async function startWatch(pi) {
107
217
  await swarmReady(ws.socketPath, ws.root, terminalHandle());
108
218
  }
109
219
  catch { /* scheduler may read env fallback */ }
220
+ // The task may already sit in history (scheduler wrote in before this
221
+ // session finished starting). Claim it now instead of waiting for a
222
+ // live event that already fired.
223
+ await catchUpSwarmHistory(pi);
110
224
  return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
111
225
  }
112
226
  const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
@@ -143,12 +257,11 @@ async function reply(text) { if (!state.workspace)
143
257
  inbound.replied = true;
144
258
  clearReminder();
145
259
  } return res; }
146
- /** Resolve the current workspace tree path from the workspace root dir name. Root itself is ".". */
260
+ /** Resolve the current workspace tree path: segment after /.ws/, root is ".". */
147
261
  function swarmTreePath() {
148
262
  if (!state.workspace)
149
263
  throw new Error("onlyne workspace not found");
150
- const { basename } = require("node:path");
151
- return state.workspace.root === process.cwd() ? "." : basename(state.workspace.root);
264
+ return treePathForWorkspace(state.workspace.root);
152
265
  }
153
266
  /** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
154
267
  async function swarmComplete(text) {
@@ -180,10 +293,7 @@ async function swarmSend(to, text) {
180
293
  const task = state.swarmTask;
181
294
  if (!task)
182
295
  throw new Error("no active swarm task");
183
- const { randomUUID } = await import("node:crypto");
184
296
  const { renderSwarmHeader } = await import("./swarm.js");
185
- const { existsSync } = await import("node:fs");
186
- const { join, resolve } = await import("node:path");
187
297
  // Resolve the target workspace dir: root "." or a tree path under the swarm tree.
188
298
  // The scheduler owns the tree; here we only verify the send-side symlink exists
189
299
  // (missing target = dangling link = error, never a blind FIFO write).
@@ -199,7 +309,6 @@ async function swarmSend(to, text) {
199
309
  throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
200
310
  const childId = randomUUID();
201
311
  const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
202
- const { writeFileSync } = await import("node:fs");
203
312
  try {
204
313
  writeFileSync(linkPath, wire);
205
314
  }
@@ -209,11 +318,42 @@ async function swarmSend(to, text) {
209
318
  swarmSlot.noteSpawned(childId);
210
319
  return { childId, to };
211
320
  }
321
+ /** Swarm mode: apply the workspace snapshot model triple to this session. */
322
+ async function applySwarmModel(pi, ctx) {
323
+ if (!state.swarm || !state.workspace)
324
+ return;
325
+ const m = readSwarmModel(state.workspace.onlyneDir);
326
+ if (!m)
327
+ return;
328
+ if (ctx?.model && m.provider && ctx.model.provider === m.provider && m.model && ctx.model.id === m.model && (!m.effort || m.effort === ctx.thinkingLevel))
329
+ return;
330
+ try {
331
+ if (m.provider && m.model) {
332
+ const found = ctx.modelRegistry?.find?.(m.provider, m.model);
333
+ if (found) {
334
+ const ok = await pi.setModel(found);
335
+ if (!ok) {
336
+ ctx.ui?.notify(`swarm model ${m.provider}/${m.model}: no auth`, "warning");
337
+ return;
338
+ }
339
+ }
340
+ else
341
+ ctx.ui?.notify(`swarm model ${m.provider}/${m.model} not in registry; keeping default`, "warning");
342
+ }
343
+ const eff = m.effort;
344
+ if (eff && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(eff))
345
+ pi.setThinkingLevel(eff);
346
+ ctx.ui?.setStatus?.("onlyne-model", `model:${m.provider ?? ""}/${m.model ?? ""}${m.effort ? ":" + m.effort : ""}`);
347
+ }
348
+ catch (e) {
349
+ ctx.ui?.notify(`swarm model apply failed: ${e}`, "warning");
350
+ }
351
+ }
212
352
  export default function onlyne(pi) {
213
- pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
353
+ pi.on("session_start", async (_event, ctx) => { cachedCtx = ctx; const resumeWatch = state.watching; if (state.owner === "extension")
214
354
  await stopDaemon().catch(() => { });
215
355
  else
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) {
356
+ stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; state.swarmTask = undefined; swarmSlot.clear(); refreshSwarmFlag(); applyToolSurface(pi); void applySwarmModel(pi, ctx); ctx.ui.setStatus("onlyne", state.workspace ? (state.swarm ? "onlyne: swarm" : "onlyne: ready") : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
217
357
  try {
218
358
  ctx.ui.notify(await startWatch(pi), "info");
219
359
  }
@@ -230,8 +370,12 @@ export default function onlyne(pi) {
230
370
  pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
231
371
  state.lastValidOutput = text; });
232
372
  pi.on("agent_start", async () => clearReminder());
233
- pi.on("agent_end", async () => { if (state.swarm)
373
+ pi.on("agent_end", async () => { if (state.swarm) {
374
+ const cur = swarmSlot.task();
375
+ if (cur.taskId && cachedCtx)
376
+ void pinSwarmTitle(cachedCtx, cur.taskId);
234
377
  scheduleSwarmExitReminder(pi);
378
+ }
235
379
  else
236
380
  scheduleReminder(pi); });
237
381
  pi.registerCommand("onlyne", {
@@ -331,7 +475,13 @@ function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
331
475
  if (!state.swarmTask)
332
476
  return;
333
477
  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" });
478
+ {
479
+ const p = pi;
480
+ if (typeof p.sendMessage === "function")
481
+ p.sendMessage({ customType: "onlyne-swarm-reminder", content: `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.`, display: true }, { triggerTurn: true, deliverAs: "followUp" });
482
+ else
483
+ 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" });
484
+ }
335
485
  state.reminderTimer = setTimeout(() => {
336
486
  state.reminderTimer = undefined;
337
487
  if (state.swarmTask)
@@ -344,8 +494,6 @@ function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
344
494
  async function setSwarm(pi, enabled) {
345
495
  if (!state.workspace)
346
496
  throw new Error("onlyne workspace not found");
347
- const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
348
- const { join } = await import("node:path");
349
497
  const cfgPath = join(state.workspace.onlyneDir, "config.toml");
350
498
  let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
351
499
  if (text.includes("[swarm]")) {
@@ -1,10 +1,12 @@
1
- export type SwarmHandleResult = "claimed" | "not-swarm";
2
- export interface SwarmSlotMessage {
3
- text: string;
4
- deliverAs: "followUp";
5
- }
1
+ export type SwarmHandleResult = "claimed" | "yielded" | "not-swarm";
6
2
  export interface SwarmPi {
7
- sendUserMessage: (text: string, opts: {
3
+ sendMessage: (message: {
4
+ customType: string;
5
+ content: string;
6
+ display: boolean;
7
+ details?: unknown;
8
+ }, opts: {
9
+ triggerTurn: true;
8
10
  deliverAs: "followUp";
9
11
  }) => void;
10
12
  }
@@ -19,7 +21,7 @@ export declare class SwarmSlot {
19
21
  private transfer;
20
22
  private attempt;
21
23
  private sentChildIds;
22
- handle(pi: SwarmPi, text: string): SwarmHandleResult;
24
+ handle(pi: SwarmPi, text: string, preferredTaskId?: string): SwarmHandleResult;
23
25
  task(): {
24
26
  taskId?: string;
25
27
  from: string;
@@ -33,7 +35,7 @@ export declare class SwarmSlot {
33
35
  }
34
36
  /** Test seam: fresh slot without touching module-global pi-onlyne state. */
35
37
  export declare function __swarmSlotForTest(): {
36
- handle: (pi: SwarmPi, text: string) => SwarmHandleResult;
38
+ handle: (pi: SwarmPi, text: string, preferredTaskId?: string) => SwarmHandleResult;
37
39
  task: () => {
38
40
  taskId?: string;
39
41
  from: string;
@@ -10,7 +10,7 @@ export class SwarmSlot {
10
10
  transfer = "";
11
11
  attempt = 1;
12
12
  sentChildIds = [];
13
- handle(pi, text) {
13
+ handle(pi, text, preferredTaskId) {
14
14
  const parsed = parseSwarmHeader(text);
15
15
  if (!parsed)
16
16
  return "not-swarm";
@@ -21,9 +21,25 @@ export class SwarmSlot {
21
21
  this.transfer = header.transfer_send_to;
22
22
  this.attempt = header.attempt;
23
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" });
24
+ // Cold start needs triggerTurn: followUp alone only queues behind an
25
+ // active turn, and a fresh session has none. sendMessage with
26
+ // triggerTurn starts the model turn immediately.
27
+ pi.sendMessage({ customType: "onlyne-swarm-task", content: `Onlyne swarm task ${header.task_id} (from ${header.from}):\n\n${payload}\n\nThis session carries this one task only. Restore context from files, work, spawn continuations with swarm_send when another unit must continue, then exit with swarm_complete. Downstream results travel through files and the ledger; nothing waits here.`, display: true }, { triggerTurn: true, deliverAs: "followUp" });
25
28
  return "claimed";
26
29
  }
30
+ // Env-task preemption: the scheduler injected ONLYNE_SWARM_TASK for
31
+ // this terminal. If the slot holds a stale claim (history catchup
32
+ // grabbed an already-done task) and the env task arrives, yield the
33
+ // slot silently — the stale claim never did work, so nothing is lost.
34
+ if (preferredTaskId && header.task_id === preferredTaskId && this.taskId !== preferredTaskId) {
35
+ this.taskId = header.task_id;
36
+ this.from = header.from;
37
+ this.transfer = header.transfer_send_to;
38
+ this.attempt = header.attempt;
39
+ this.sentChildIds = [];
40
+ pi.sendMessage({ customType: "onlyne-swarm-task", content: `Onlyne swarm task ${header.task_id} (from ${header.from}):\n\n${payload}\n\nThis session carries this one task only. Restore context from files, work, spawn continuations with swarm_send when another unit must continue, then exit with swarm_complete. Downstream results travel through files and the ledger; nothing waits here.`, display: true }, { triggerTurn: true, deliverAs: "followUp" });
41
+ return "yielded";
42
+ }
27
43
  return "not-swarm";
28
44
  }
29
45
  task() {
@@ -47,7 +63,7 @@ export class SwarmSlot {
47
63
  export function __swarmSlotForTest() {
48
64
  const slot = new SwarmSlot();
49
65
  return {
50
- handle: (pi, text) => slot.handle(pi, text),
66
+ handle: (pi, text, preferredTaskId) => slot.handle(pi, text, preferredTaskId),
51
67
  task: () => slot.task(),
52
68
  taskId: () => slot.taskIdOf(),
53
69
  noteSpawned: (childId) => slot.noteSpawned(childId),
package/dist/swarm.d.ts CHANGED
@@ -15,6 +15,18 @@ export declare function parseSwarmHeader(text: string): SwarmMessage | null;
15
15
  export declare function renderSwarmHeader(header: SwarmHeader, role: string, payloadMarkdown: string): string;
16
16
  /** Read the `[swarm] enabled` flag from the workspace .onlyne/config.toml. */
17
17
  export declare function readSwarmEnabled(onlyneDir: string): boolean;
18
+ /** Strip // line comments and trailing commas so jsonc parses as JSON. */
19
+ export declare function stripJsonc(text: string): string;
20
+ /** Read the model triple from the workspace .onlyne/swarm.workspace.jsonc snapshot. */
21
+ export declare function readSwarmModel(onlyneDir: string): {
22
+ provider?: string;
23
+ model?: string;
24
+ effort?: string;
25
+ } | null;
26
+ /** Tree path of a workspace inside the swarm tree: the segment after the
27
+ * nearest `/.ws/` marker; the swarm root itself is "." .
28
+ * Pure function so quoting/edge cases are unit-tested without a live session. */
29
+ export declare function treePathForWorkspace(workspaceRoot: string): string;
18
30
  /** Terminal handle for swarm_ready correlation (injected by the scheduler). */
19
31
  export declare function terminalHandle(): string;
20
32
  /** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
package/dist/swarm.js CHANGED
@@ -89,6 +89,67 @@ export function readSwarmEnabled(onlyneDir) {
89
89
  catch { /* unreadable -> disabled */ }
90
90
  return false;
91
91
  }
92
+ /** Strip // line comments and trailing commas so jsonc parses as JSON. */
93
+ export function stripJsonc(text) {
94
+ const out = [];
95
+ let inStr = false;
96
+ let esc = false;
97
+ for (const line of text.split("\n")) {
98
+ let res = "";
99
+ for (let i = 0; i < line.length; i++) {
100
+ const c = line[i];
101
+ if (inStr) {
102
+ res += c;
103
+ if (esc)
104
+ esc = false;
105
+ else if (c === "\\")
106
+ esc = true;
107
+ else if (c === '"')
108
+ inStr = false;
109
+ continue;
110
+ }
111
+ if (c === '"') {
112
+ inStr = true;
113
+ res += c;
114
+ continue;
115
+ }
116
+ if (c === "/" && line[i + 1] === "/")
117
+ break;
118
+ res += c;
119
+ }
120
+ out.push(res);
121
+ }
122
+ return out.join("\n").replace(/,(\s*[}\]])/g, "$1");
123
+ }
124
+ /** Read the model triple from the workspace .onlyne/swarm.workspace.jsonc snapshot. */
125
+ export function readSwarmModel(onlyneDir) {
126
+ const path = join(onlyneDir, "swarm.workspace.jsonc");
127
+ if (!existsSync(path))
128
+ return null;
129
+ try {
130
+ const parsed = JSON.parse(stripJsonc(readFileSync(path, "utf8")));
131
+ const m = parsed?.model;
132
+ if (!m || typeof m !== "object")
133
+ return null;
134
+ const provider = typeof m.provider === "string" && m.provider ? m.provider : undefined;
135
+ const model = typeof m.model === "string" && m.model ? m.model : undefined;
136
+ const effort = typeof m.effort === "string" && m.effort ? m.effort : undefined;
137
+ if (!provider && !model)
138
+ return null;
139
+ return { provider, model, effort };
140
+ }
141
+ catch { /* malformed -> no model override */ }
142
+ return null;
143
+ }
144
+ /** Tree path of a workspace inside the swarm tree: the segment after the
145
+ * nearest `/.ws/` marker; the swarm root itself is "." .
146
+ * Pure function so quoting/edge cases are unit-tested without a live session. */
147
+ export function treePathForWorkspace(workspaceRoot) {
148
+ const i = workspaceRoot.lastIndexOf("/.ws/");
149
+ if (i >= 0)
150
+ return workspaceRoot.slice(i + "/.ws/".length) || ".";
151
+ return ".";
152
+ }
92
153
  /** Terminal handle for swarm_ready correlation (injected by the scheduler). */
93
154
  export function terminalHandle() {
94
155
  return process.env.ORCA_TERMINAL_HANDLE || process.env.ONLYNE_TERMINAL_HANDLE || "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "Pi extension tools for sending messages through Onlyne.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",