pi-onlyne 0.8.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js DELETED
@@ -1,576 +0,0 @@
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";
5
- import { Type } from "typebox";
6
- import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady, swarmRecycled } from "./onlyne.js";
7
- import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
8
- import { findWorkspace } from "./workspace.js";
9
- import { envTaskId, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
10
- import { SwarmSlot } from "./swarm-slot.js";
11
- const swarmSlot = new SwarmSlot();
12
- const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
13
- let cachedCtx = undefined;
14
- const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
15
- const currentConfig = () => loadConfig(state.cwd);
16
- function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
17
- function inboundText(data) { const msg = data?.data?.data ?? data?.data ?? data; const channelId = msg.channel_id ?? msg.channelId; const conversationId = msg.conversation_id ?? msg.conversationId; const messageId = msg.message_id ?? msg.messageId; const text = msg.text ?? msg.content ?? msg.body; return channelId && conversationId && typeof text === "string" ? { channelId, conversationId, messageId, text } : null; }
18
- function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
19
- void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
20
- function clearReminder() { if (state.reminderTimer)
21
- clearTimeout(state.reminderTimer); state.reminderTimer = undefined; }
22
- function needsReply(inbound = state.currentInbound) { return !!inbound && !inbound.replied && !inbound.noReply && !!state.workspace; }
23
- function scheduleReminder(pi, delayMs = 30_000) {
24
- clearReminder();
25
- const inbound = state.currentInbound;
26
- if (!inbound || !needsReply(inbound))
27
- return;
28
- state.reminderTimer = setTimeout(() => {
29
- state.reminderTimer = undefined;
30
- if (!needsReply(inbound) || state.currentInbound !== inbound)
31
- return;
32
- const cfg = currentConfig();
33
- if (cfg.outbound.defaultReplyMode === "explicit-only")
34
- return;
35
- if (cfg.outbound.defaultReplyMode === "guarded-explicit" && inbound.reminders < cfg.outbound.guardedExplicit.reminders) {
36
- if (inbound.reminders === 0)
37
- inbound.fallbackText = state.lastValidOutput;
38
- inbound.reminders++;
39
- pi.sendUserMessage(`Onlyne reminder ${inbound.reminders}/${cfg.outbound.guardedExplicit.reminders}: reply to ${inbound.channelId}/${inbound.conversationId} with onlyne_reply, or call onlyne_mark_no_reply.`, { deliverAs: "followUp" });
40
- return;
41
- }
42
- void reply(inbound.fallbackText || state.lastValidOutput || cfg.outbound.guardedExplicit.noOutputFallbackText).catch(() => { });
43
- }, delayMs);
44
- }
45
- function scheduleReconnect(pi) {
46
- if (state.reconnectTimer || !state.watching || !state.workspace)
47
- return;
48
- state.reconnectTimer = setTimeout(async () => {
49
- state.reconnectTimer = undefined;
50
- if (!state.watching)
51
- return;
52
- try {
53
- await startWatch(pi);
54
- }
55
- catch {
56
- scheduleReconnect(pi);
57
- }
58
- }, 1000);
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 (text.startsWith("---swarm-ctl"))
76
- continue;
77
- if (m?.direction === "outbound") {
78
- const p = parseSwarmHeader(text);
79
- if (p)
80
- outs.add(p.header.task_id);
81
- }
82
- }
83
- if (outs.has(taskId))
84
- return true;
85
- // Stale-claim guard: an inbound older than the newest outbound batch
86
- // belongs to a previous hop generation. Only the scheduler-assigned
87
- // env task bypasses this check.
88
- return false;
89
- }
90
- catch {
91
- return false;
92
- }
93
- }
94
- /** Replay the newest unclaimed swarm task from loopback history. Idempotent.
95
- * Prefers the scheduler-assigned env task (ONLYNE_SWARM_TASK); never claims
96
- * an already-done task unless it is the env task (env wins, scheduler owns
97
- * the truth about what this terminal must run). */
98
- async function catchUpSwarmHistory(pi, ctx) {
99
- if (swarmSlot.task().taskId)
100
- 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;
146
- }
147
- }
148
- catch { /* best effort; live events still arrive */ }
149
- }
150
- /** Pin the tab title for a claimed hop. Pi rewrites the create-time title on
151
- * boot and on idle transitions, and the scheduler-side rename races those
152
- * writes. Last writer wins, so the session re-asserts its own title on claim
153
- * and again on agent_end (idle), which is pi's other rewrite point. */
154
- function pinSwarmTitle(ctx, taskId) {
155
- try {
156
- ctx?.ui?.setTitle?.(`swarm:${swarmTreePath()}:${taskId.slice(0, 8)}`);
157
- }
158
- catch { /* title is cosmetic */ }
159
- }
160
- /** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
161
- function handleSwarmInbound(pi, text, eventSeq, ctx) {
162
- const parsed = parseSwarmHeader(text);
163
- if (!parsed)
164
- return false;
165
- if (state.socket && eventSeq !== undefined)
166
- void consumeEvent(state.socket, eventSeq).catch(() => { });
167
- // Slot transitions live in SwarmSlot (unit-tested); here we only mirror
168
- // the claimed task into session state. A claimed session never accepts
169
- // another task; downstream work spawns new tasks via swarm_send.
170
- const outcome = swarmSlot.handle(pi, text, envTaskId() || undefined, state.workspace?.root);
171
- if (outcome === "claimed" || outcome === "yielded") {
172
- const cur = swarmSlot.task();
173
- state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
174
- if (ctx)
175
- void pinSwarmTitle(ctx, cur.taskId);
176
- }
177
- return outcome === "claimed" || outcome === "yielded";
178
- }
179
- async function startWatch(pi) {
180
- state.workspace = findWorkspace(state.cwd);
181
- if (!state.workspace)
182
- throw new Error("current workspace has no .onlyne configuration");
183
- refreshSwarmFlag();
184
- if (state.reconnectTimer)
185
- clearTimeout(state.reconnectTimer);
186
- state.reconnectTimer = undefined;
187
- state.socket?.destroy();
188
- state.socket = undefined;
189
- const conn = await connectDaemon(state.workspace);
190
- state.owner = conn.owner;
191
- state.child = conn.process;
192
- if (state.swarm) {
193
- // Swarm mode: the scheduler owns in/out. Subscribe without auto-handling generic
194
- // traffic; only swarm headers enter the session, via the followUp task queue.
195
- // Report readiness so the scheduler can match a pending task (fork+exec: any
196
- // clean ready session on this workspace path may take it).
197
- const ws = state.workspace;
198
- // Priority 1 (above default 0): the scheduler consumes at MAX, then we
199
- // take the event at tier 1 so it also reaches the session. A plain
200
- // tier-0 subscription would starve whenever the scheduler consumes.
201
- const socket = subscribe(ws.socketPath, (line) => {
202
- if (!line?.event)
203
- return;
204
- if (line.type !== "inbound_message")
205
- return;
206
- const inbound = inboundText(line);
207
- if (!inbound || inbound.channelId !== "loopback")
208
- return;
209
- // Catch up on history first: a fresh watch may subscribe after the
210
- // task was already delivered (scheduler writes in before the
211
- // session finishes starting). History replay is idempotent via
212
- // the slot guard + scheduler task_id dedup.
213
- if (parseSwarmCtl(inbound.text)) {
214
- const ctl = parseSwarmCtl(inbound.text);
215
- void handleSwarmRecycle(ctl.task_id, ctl.reason);
216
- return;
217
- }
218
- void catchUpSwarmHistory(pi);
219
- handleSwarmInbound(pi, inbound.text, line.event_seq);
220
- }, () => { if (state.socket === socket)
221
- scheduleReconnect(pi); }, { priority: 1 });
222
- state.socket = socket;
223
- state.watching = true;
224
- const task = envTaskId();
225
- try {
226
- await swarmReady(ws.socketPath, ws.root, terminalHandle());
227
- }
228
- catch { /* scheduler may read env fallback */ }
229
- // The task may already sit in history (scheduler wrote in before this
230
- // session finished starting). Claim it now instead of waiting for a
231
- // live event that already fired.
232
- await catchUpSwarmHistory(pi);
233
- return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
234
- }
235
- const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
236
- return; const inbound = inboundText(line); if (!inbound)
237
- return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
238
- return; if (inbound.channelId === "loopback") {
239
- if (mode === "auto-handle")
240
- pi.sendUserMessage(`Onlyne loopback activation${inbound.conversationId ? ` (${inbound.conversationId})` : ""}:\n\n${inbound.text}`, { deliverAs: "followUp" });
241
- consumeIfNotified(inbound);
242
- return;
243
- } if (inbound.text.trim() === "/handshake") {
244
- consumeIfNotified(inbound);
245
- return;
246
- } clearReminder(); state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle") {
247
- pi.sendUserMessage(`Onlyne inbound message from ${inbound.channelId}/${inbound.conversationId}:\n\n${inbound.text}\n\nReply with onlyne_reply, or call onlyne_mark_no_reply if no reply is needed.`, { deliverAs: "followUp" });
248
- consumeIfNotified(inbound);
249
- } }, () => { if (state.socket === socket)
250
- scheduleReconnect(pi); });
251
- state.socket = socket;
252
- state.watching = true;
253
- return `watching ${state.workspace.root} (${state.owner})`;
254
- }
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"; }
257
- async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
258
- 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
- async function stopDaemon() { if (!state.workspace)
260
- state.workspace = findWorkspace(state.cwd); if (!state.workspace)
261
- throw new Error("current workspace has no .onlyne configuration"); clearReminder(); state.socket?.destroy(); state.socket = undefined; await shutdownDaemon(state.workspace, state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; return `daemon stopped for ${state.workspace.root}`; }
262
- async function restartDaemon() { await stopDaemon().catch(() => { }); return startDaemon(); }
263
- async function reply(text) { if (!state.workspace)
264
- throw new Error("onlyne workspace not found"); const inbound = state.currentInbound; if (!inbound)
265
- throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId }, text, currentConfig().outbound.retry.attempts); if (res.ok) {
266
- inbound.replied = true;
267
- clearReminder();
268
- } return res; }
269
- /** Resolve the current workspace tree path: segment after /.ws/, root is ".". */
270
- function swarmTreePath() {
271
- if (!state.workspace)
272
- throw new Error("onlyne workspace not found");
273
- return treePathForWorkspace(state.workspace.root);
274
- }
275
- /** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
276
- async function swarmComplete(text) {
277
- if (!state.workspace)
278
- throw new Error("onlyne workspace not found");
279
- const task = state.swarmTask;
280
- if (!task)
281
- throw new Error("no active swarm task");
282
- const { renderSwarmHeader } = await import("./swarm.js");
283
- 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. */
296
- async function swarmQuit(reason) {
297
- const task = state.swarmTask;
298
- const taskId = task?.taskId ?? "";
299
- const why = `quit:${reason ?? ""}`;
300
- try {
301
- if (state.workspace && taskId) {
302
- await swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why);
303
- }
304
- }
305
- catch { /* daemon may be gone; exit anyway */ }
306
- clearReminder();
307
- try {
308
- stopWatch();
309
- }
310
- catch { /* already stopped */ }
311
- state.swarmTask = undefined;
312
- swarmSlot.clear();
313
- setTimeout(() => process.exit(0), 300);
314
- return { quit: true, taskId, reason: reason ?? "" };
315
- }
316
- /** Handle a downlink recycle signal: accept, ack, release, self-terminate.
317
- * No out is written (the scheduler already recorded the terminal state). */
318
- async function handleSwarmRecycle(ctlTaskId, reason) {
319
- const claimed = swarmSlot.task().taskId;
320
- const taskId = ctlTaskId === "*" ? (claimed ?? "") : ctlTaskId;
321
- if (claimed && ctlTaskId !== "*" && claimed !== ctlTaskId)
322
- return false;
323
- const why = `recycle:${reason}`;
324
- try {
325
- if (state.workspace && taskId) {
326
- await swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why);
327
- }
328
- }
329
- catch { /* daemon may be gone; exit anyway */ }
330
- clearReminder();
331
- try {
332
- stopWatch();
333
- }
334
- catch { /* already stopped */ }
335
- state.swarmTask = undefined;
336
- swarmSlot.clear();
337
- setTimeout(() => process.exit(0), 300);
338
- return true;
339
- }
340
- /** swarm_send: spawn downstream. New UUID, transfer_send_to = current task, fire-and-forget. */
341
- async function swarmSend(to, text) {
342
- if (!state.workspace)
343
- throw new Error("onlyne workspace not found");
344
- const task = state.swarmTask;
345
- if (!task)
346
- throw new Error("no active swarm task");
347
- 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
- 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}`);
361
- const childId = randomUUID();
362
- 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
- }
369
- swarmSlot.noteSpawned(childId);
370
- return { childId, to };
371
- }
372
- /** Swarm mode: apply the workspace snapshot model triple to this session. */
373
- async function applySwarmModel(pi, ctx) {
374
- if (!state.swarm || !state.workspace)
375
- return;
376
- const m = readSwarmModel(state.workspace.onlyneDir);
377
- if (!m)
378
- return;
379
- if (ctx?.model && m.provider && ctx.model.provider === m.provider && m.model && ctx.model.id === m.model && (!m.effort || m.effort === ctx.thinkingLevel))
380
- return;
381
- try {
382
- if (m.provider && m.model) {
383
- const found = ctx.modelRegistry?.find?.(m.provider, m.model);
384
- if (found) {
385
- const ok = await pi.setModel(found);
386
- if (!ok) {
387
- ctx.ui?.notify(`swarm model ${m.provider}/${m.model}: no auth`, "warning");
388
- return;
389
- }
390
- }
391
- else
392
- ctx.ui?.notify(`swarm model ${m.provider}/${m.model} not in registry; keeping default`, "warning");
393
- }
394
- const eff = m.effort;
395
- if (eff && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(eff))
396
- pi.setThinkingLevel(eff);
397
- ctx.ui?.setStatus?.("onlyne-model", `model:${m.provider ?? ""}/${m.model ?? ""}${m.effort ? ":" + m.effort : ""}`);
398
- }
399
- catch (e) {
400
- ctx.ui?.notify(`swarm model apply failed: ${e}`, "warning");
401
- }
402
- }
403
- export default function onlyne(pi) {
404
- pi.on("session_start", async (_event, ctx) => { cachedCtx = ctx; const resumeWatch = state.watching; if (state.owner === "extension")
405
- await stopDaemon().catch(() => { });
406
- else
407
- 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) {
408
- try {
409
- ctx.ui.notify(await startWatch(pi), "info");
410
- }
411
- catch (e) {
412
- ctx.ui.notify(String(e), "warning");
413
- }
414
- } });
415
- pi.on("session_shutdown", async () => { if (state.owner === "extension")
416
- await stopDaemon().catch(() => { });
417
- else
418
- stopWatch(); });
419
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
420
- process.once(sig, () => stopWatch());
421
- pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
422
- state.lastValidOutput = text; });
423
- pi.on("agent_start", async () => clearReminder());
424
- pi.on("agent_end", async () => { if (state.swarm) {
425
- const cur = swarmSlot.task();
426
- if (cur.taskId && cachedCtx)
427
- void pinSwarmTitle(cachedCtx, cur.taskId);
428
- scheduleSwarmExitReminder(pi);
429
- }
430
- else
431
- scheduleReminder(pi); });
432
- pi.registerCommand("onlyne", {
433
- description: "Onlyne watch/status/config commands",
434
- getArgumentCompletions: (prefix) => {
435
- const commands = ["status", "watch on", "watch off", "daemon start", "daemon stop", "daemon restart", "config auto-start", "swarm on", "swarm off", "swarm status"];
436
- const p = prefix.trimStart();
437
- const filtered = commands.filter((c) => c.startsWith(p));
438
- return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
439
- },
440
- handler: async (argLine, ctx) => {
441
- const [cmd, sub] = argLine.trim().split(/\s+/);
442
- try {
443
- if (cmd === "watch" && sub === "on")
444
- ctx.ui.notify(await startWatch(pi), "info");
445
- else if (cmd === "watch" && sub === "off")
446
- ctx.ui.notify(stopWatch(), "info");
447
- else if (cmd === "daemon" && sub === "start")
448
- ctx.ui.notify(await startDaemon(), "info");
449
- else if (cmd === "daemon" && sub === "stop")
450
- ctx.ui.notify(await stopDaemon(), "info");
451
- else if (cmd === "daemon" && sub === "restart")
452
- ctx.ui.notify(await restartDaemon(), "info");
453
- else if (cmd === "status")
454
- ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; swarm=${state.swarm ? "on" : "off"}; workspace=${state.workspace?.root ?? "none"}`, "info");
455
- else if (cmd === "swarm" && sub === "status") {
456
- const cur = swarmSlot.task();
457
- ctx.ui.notify(`swarm=${state.swarm ? "on" : "off"}; task=${cur.taskId ?? "none"}; spawned=${cur.sentChildIds.length}`, "info");
458
- }
459
- else if (cmd === "swarm" && (sub === "on" || sub === "off")) {
460
- ctx.ui.notify(await setSwarm(pi, sub === "on"), "info");
461
- }
462
- else if (cmd === "config" && sub === "auto-start") {
463
- const cfg = currentConfig();
464
- cfg.watch.autoStart = !cfg.watch.autoStart;
465
- saveConfig(state.cwd, cfg);
466
- ctx.ui.notify(`autoStart=${cfg.watch.autoStart}`, "info");
467
- }
468
- else
469
- ctx.ui.notify("usage: /onlyne status | watch on|off | daemon start|stop|restart | swarm on|off|status | config auto-start", "info");
470
- }
471
- catch (e) {
472
- ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
473
- }
474
- },
475
- });
476
- registerNormalTools(pi);
477
- registerSwarmTools(pi);
478
- applyToolSurface(pi);
479
- }
480
- const NORMAL_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "onlyne_reply", "onlyne_send", "onlyne_broadcast", "onlyne_loopback", "onlyne_mark_no_reply"];
481
- const SWARM_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "swarm_complete", "swarm_quit", "swarm_send", "swarm_status"];
482
- /** Tool surface follows [swarm] at session start: one mode, one toolset. */
483
- function applyToolSurface(pi) {
484
- try {
485
- const active = new Set(typeof pi.getActiveTools === "function" ? pi.getActiveTools() : []);
486
- const all = typeof pi.getAllTools === "function" ? pi.getAllTools().map((t) => t.name) : [];
487
- const keep = state.swarm ? SWARM_TOOLS : NORMAL_TOOLS;
488
- const next = [...active].filter((n) => all.includes(n) && !(NORMAL_TOOLS.includes(n) || SWARM_TOOLS.includes(n)));
489
- for (const n of keep)
490
- if (all.includes(n) && !next.includes(n))
491
- next.push(n);
492
- if (typeof pi.setActiveTools === "function")
493
- pi.setActiveTools(next);
494
- }
495
- catch { /* older pi without tool-surface API: both sets stay registered */ }
496
- }
497
- function registerNormalTools(pi) {
498
- 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 }); } }));
500
- 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))); } }));
502
- 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
- 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
- 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)
505
- throw new Error("onlyne workspace not found"); const cfg = currentConfig(); const results = await broadcast(state.workspace.socketPath, params.targets, params.text, cfg.outbound.retry.attempts, cfg.outbound.retry.concurrency, params.rawText ?? false); return textResult(JSON.stringify({ ok: results.every((r) => r.ok), results }), results); } }));
506
- pi.registerTool(defineTool({ name: "onlyne_loopback", label: "Onlyne loopback", description: "Inject a local loopback activation message so scripts can wake the current Pi session. Set rawText=false for Markdown. FIFO alternative: write to .onlyne/channels/loopback/in.", parameters: Type.Object({ text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
507
- throw new Error("onlyne workspace not found"); const res = await loopback(state.workspace.socketPath, params.text, params.rawText ?? true); return textResult(JSON.stringify(res), res); } }));
508
- pi.registerTool(defineTool({ name: "onlyne_mark_no_reply", label: "Onlyne no reply", description: "Mark the current Onlyne inbound message as intentionally not replied.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { if (state.currentInbound) {
509
- state.currentInbound.noReply = true;
510
- clearReminder();
511
- } return textResult("marked no reply", params); } }));
512
- }
513
- function registerSwarmTools(pi) {
514
- 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
- 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))); } }));
517
- 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
- }
519
- /** Swarm exit guard: at agent end, an unfinished hop gets one reminder, then auto-quit. */
520
- function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
521
- clearReminder();
522
- if (!state.swarmTask)
523
- return;
524
- state.reminderTimer = setTimeout(() => {
525
- state.reminderTimer = undefined;
526
- if (!state.swarmTask)
527
- return;
528
- if (state.swarmTask) {
529
- {
530
- const p = pi;
531
- if (typeof p.sendMessage === "function")
532
- 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" });
533
- else
534
- 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" });
535
- }
536
- state.reminderTimer = setTimeout(() => {
537
- state.reminderTimer = undefined;
538
- if (state.swarmTask)
539
- void swarmQuit("auto-quit: no explicit exit").catch(() => { });
540
- }, delayMs);
541
- }
542
- }, delayMs);
543
- }
544
- /** Toggle swarm mode: persists to .onlyne/config.toml [swarm] enabled, restarts watch. */
545
- async function setSwarm(pi, enabled) {
546
- if (!state.workspace)
547
- throw new Error("onlyne workspace not found");
548
- const cfgPath = join(state.workspace.onlyneDir, "config.toml");
549
- let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
550
- if (text.includes("[swarm]")) {
551
- const lines = text.split("\n");
552
- let inSwarm = false;
553
- text = lines.map((line) => {
554
- const t = line.trim();
555
- if (t.startsWith("[")) {
556
- inSwarm = t === "[swarm]";
557
- return line;
558
- }
559
- if (inSwarm && t.startsWith("enabled"))
560
- return `enabled = ${enabled}`;
561
- return line;
562
- }).join("\n");
563
- }
564
- else {
565
- if (text && !text.endsWith("\n"))
566
- text += "\n";
567
- text += `\n[swarm]\nenabled = ${enabled}\n`;
568
- }
569
- writeFileSync(cfgPath, text);
570
- refreshSwarmFlag();
571
- if (state.watching) {
572
- stopWatch();
573
- return `${enabled ? "swarm on; " : "swarm off; "}${await startWatch(pi)}`;
574
- }
575
- return `swarm ${enabled ? "on" : "off"} (watch not running)`;
576
- }
package/dist/onlyne.d.ts DELETED
@@ -1,42 +0,0 @@
1
- import { type ChildProcess } from "node:child_process";
2
- import { type Socket } from "node:net";
3
- import type { Workspace } from "./workspace.js";
4
- export interface OnlyneRequest {
5
- id: string;
6
- op: string;
7
- channel_id?: string;
8
- message_id?: string;
9
- text?: string;
10
- format?: "plain" | "markdown";
11
- raw_text?: boolean;
12
- limit?: number;
13
- priority?: number;
14
- consume_timeout_ms?: number;
15
- event_seq?: number;
16
- }
17
- export interface SendTarget {
18
- channelId: string;
19
- }
20
- export interface SendResult extends SendTarget {
21
- ok: boolean;
22
- error?: string;
23
- }
24
- export declare function request(socketPath: string, req: OnlyneRequest): Promise<any>;
25
- export declare function subscribe(socketPath: string, onLine: (line: any) => void, onDisconnect?: () => void, opts?: {
26
- priority?: number;
27
- consumeTimeoutMs?: number;
28
- }): Socket;
29
- export declare function waitForSocket(socketPath: string, timeoutMs?: number): Promise<void>;
30
- export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean): Promise<{
31
- owner: "external" | "extension";
32
- process?: ChildProcess;
33
- }>;
34
- export declare function shutdownDaemon(ws: Workspace, child?: ChildProcess): Promise<void>;
35
- 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>;
38
- export declare function consumeEvent(socket: Socket, eventSeq: number): Promise<void>;
39
- export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
40
- 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>;
42
- export declare function broadcast(socketPath: string, targets: SendTarget[], text: string, attempts: number, concurrency: number, rawText?: boolean): Promise<SendResult[]>;