pi-onlyne 0.9.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/README.md +325 -203
- package/README.zh.md +322 -0
- package/onlyne.json.example +6 -0
- package/package.json +19 -34
- package/relay.toml.example +18 -0
- package/src/agent.live.test.mjs +112 -0
- package/src/agent.mjs +923 -0
- package/src/agent.test.mjs +1079 -0
- package/src/config.mjs +74 -0
- package/src/config.test.mjs +88 -0
- package/src/frame.mjs +99 -0
- package/src/frame.test.mjs +97 -0
- package/src/index.ts +300 -0
- package/src/pi-surface.mjs +140 -0
- package/src/protocol.mjs +364 -0
- package/src/protocol.test.mjs +296 -0
- package/src/relay.mjs +299 -0
- package/src/relay.test.mjs +210 -0
- package/LICENSE +0 -21
- package/SPEC.md +0 -124
- package/dist/config.d.ts +0 -32
- package/dist/config.js +0 -19
- package/dist/index.d.ts +0 -27
- package/dist/index.js +0 -780
- package/dist/onlyne.d.ts +0 -70
- package/dist/onlyne.js +0 -194
- package/dist/session.d.ts +0 -82
- package/dist/session.js +0 -91
- package/dist/swarm-prompt.d.ts +0 -12
- package/dist/swarm-prompt.js +0 -45
- package/dist/swarm-slot.d.ts +0 -43
- package/dist/swarm-slot.js +0 -76
- package/dist/swarm.d.ts +0 -96
- package/dist/swarm.js +0 -296
- package/dist/workspace.d.ts +0 -6
- package/dist/workspace.js +0 -14
package/dist/index.js
DELETED
|
@@ -1,780 +0,0 @@
|
|
|
1
|
-
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import { Type } from "typebox";
|
|
6
|
-
import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, sendSwarmReport, shutdownDaemon, stopProcess, subscribe, swarmBusy, swarmIdle, swarmReady, swarmRecycled } from "./onlyne.js";
|
|
7
|
-
import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
|
|
8
|
-
import { findWorkspace } from "./workspace.js";
|
|
9
|
-
import { envTaskId, hopActivityBody, isSwarmReportFrame, isSwarmTaskId, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, renderSwarmReport, selectHistoryDelivery, terminalHandle, treePathForWorkspace } from "./swarm.js";
|
|
10
|
-
import { SwarmSlot } from "./swarm-slot.js";
|
|
11
|
-
import { SwarmSessionState, restoreSwarmState } from "./session.js";
|
|
12
|
-
const swarmSlot = new SwarmSlot();
|
|
13
|
-
const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
|
|
14
|
-
let cachedCtx = undefined;
|
|
15
|
-
const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
|
|
16
|
-
const currentConfig = () => loadConfig(state.cwd);
|
|
17
|
-
function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
|
|
18
|
-
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; }
|
|
19
|
-
function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
|
|
20
|
-
void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
|
|
21
|
-
function clearReminder() { if (state.reminderTimer)
|
|
22
|
-
clearTimeout(state.reminderTimer); state.reminderTimer = undefined; }
|
|
23
|
-
function swarmOpId(kind, suffix) {
|
|
24
|
-
const taskId = state.swarmSession?.taskId ?? state.swarmTask?.taskId ?? state.swarmTaskKey ?? `session:${terminalHandle() || state.workspace?.root || "unknown"}`;
|
|
25
|
-
const generation = state.swarmSession?.generation ?? 0;
|
|
26
|
-
return `swarm:${taskId}:g${generation}:${kind}${suffix ? `:${suffix}` : ""}`;
|
|
27
|
-
}
|
|
28
|
-
async function awaitSubscriptionAck(socket, timeoutMs = 5_000) {
|
|
29
|
-
let timer;
|
|
30
|
-
try {
|
|
31
|
-
return await Promise.race([
|
|
32
|
-
socket.ack,
|
|
33
|
-
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("swarm subscribe_events ack timeout")), timeoutMs); }),
|
|
34
|
-
]);
|
|
35
|
-
}
|
|
36
|
-
finally {
|
|
37
|
-
if (timer)
|
|
38
|
-
clearTimeout(timer);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
function awaitIntent(intent) { void Promise.resolve(intent).catch(() => { }); }
|
|
42
|
-
/** Task identity the scheduler owns, for lifecycle evidence. The claimed hop
|
|
43
|
-
* wins; before a claim an env-assigned task is already a scheduler row. A
|
|
44
|
-
* synthetic session key returns null and the report stays home: a frame the
|
|
45
|
-
* ledger cannot attribute raises an unknown-session alert on that side. */
|
|
46
|
-
function swarmReportTaskId() {
|
|
47
|
-
for (const candidate of [state.swarmTask?.taskId, state.swarmSession?.taskId]) {
|
|
48
|
-
if (candidate && isSwarmTaskId(candidate))
|
|
49
|
-
return candidate.trim();
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
/** A report that failed to build or reach the daemon is recorded locally. The
|
|
54
|
-
* entry names the kind and the error, so a gap in the scheduler's view of this
|
|
55
|
-
* session is traceable to a frame this side can show. */
|
|
56
|
-
function reportFault(kind, e) {
|
|
57
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
58
|
-
try {
|
|
59
|
-
state.swarmSession?.transition({}, `swarm_report_fault:${kind}:${message}`);
|
|
60
|
-
}
|
|
61
|
-
catch { /* ledger unavailable */ }
|
|
62
|
-
}
|
|
63
|
-
/** Send the frame for an already-persisted watermark. One attempt per point:
|
|
64
|
-
* the op_id names (task, seq), and the next beat carries a fresh watermark, so
|
|
65
|
-
* a dropped frame costs one step of liveness evidence. */
|
|
66
|
-
async function sendSwarmReportFrame(kind, seq) {
|
|
67
|
-
const session = state.swarmSession;
|
|
68
|
-
const ws = state.workspace;
|
|
69
|
-
const taskId = swarmReportTaskId();
|
|
70
|
-
if (!session || !ws || !taskId)
|
|
71
|
-
return false;
|
|
72
|
-
let frame;
|
|
73
|
-
try {
|
|
74
|
-
frame = renderSwarmReport(kind, taskId, session.generation, seq);
|
|
75
|
-
}
|
|
76
|
-
catch (e) {
|
|
77
|
-
reportFault(kind, e);
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
await sendSwarmReport(ws.socketPath, frame, swarmOpId(`report_${kind}`, `${taskId}:${seq}`));
|
|
82
|
-
return true;
|
|
83
|
-
}
|
|
84
|
-
catch (e) {
|
|
85
|
-
reportFault(kind, e);
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
/** Advance the durable watermark and carry it to the daemon. Persisting under
|
|
90
|
-
* the report's own kind names the lifecycle point in the local ledger, and
|
|
91
|
-
* persisting before sending gives every point its own strictly increasing seq
|
|
92
|
-
* within this generation — the ordering the reducer's watermark compares
|
|
93
|
-
* against. */
|
|
94
|
-
async function emitSwarmReport(kind) {
|
|
95
|
-
const session = state.swarmSession;
|
|
96
|
-
if (!session)
|
|
97
|
-
return false;
|
|
98
|
-
try {
|
|
99
|
-
return await sendSwarmReportFrame(kind, session.transition({}, `swarm_report_${kind}`).seq);
|
|
100
|
-
}
|
|
101
|
-
catch (e) {
|
|
102
|
-
reportFault(kind, e);
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
function swarmHeartbeat() {
|
|
107
|
-
const session = state.swarmSession;
|
|
108
|
-
if (!session)
|
|
109
|
-
return;
|
|
110
|
-
let seq;
|
|
111
|
-
try {
|
|
112
|
-
seq = session.heartbeat().seq;
|
|
113
|
-
}
|
|
114
|
-
catch (e) {
|
|
115
|
-
session.transition({ recovery: "idle_fault" }, `swarm_heartbeat_fault:${e instanceof Error ? e.message : String(e)}`);
|
|
116
|
-
session.stopHeartbeat();
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
awaitIntent(sendSwarmReportFrame("heartbeat", seq));
|
|
120
|
-
}
|
|
121
|
-
function swarmSessionTransition(patch, kind) { state.swarmSession?.transition(patch, kind); }
|
|
122
|
-
async function sendSwarmIntent(kind, payload, send, maxAttempts = 3, opId = swarmOpId(kind)) {
|
|
123
|
-
const session = state.swarmSession;
|
|
124
|
-
if (!session)
|
|
125
|
-
return send(opId);
|
|
126
|
-
session.recordIntent(kind, payload, opId);
|
|
127
|
-
const intent = await session.retry(opId, async () => {
|
|
128
|
-
const receipt = await send(opId);
|
|
129
|
-
if (receipt && typeof receipt === "object" && receipt.ok === false)
|
|
130
|
-
throw new Error(JSON.stringify(receipt.error ?? receipt));
|
|
131
|
-
return receipt;
|
|
132
|
-
}, maxAttempts);
|
|
133
|
-
if (intent.status !== "accepted") {
|
|
134
|
-
session.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `${kind}_fault`);
|
|
135
|
-
throw new Error(`${kind} intent exhausted${intent.last_error ? `: ${intent.last_error}` : ""}`);
|
|
136
|
-
}
|
|
137
|
-
return intent.receipt;
|
|
138
|
-
}
|
|
139
|
-
function needsReply(inbound = state.currentInbound) { return !!inbound && !inbound.replied && !inbound.noReply && !!state.workspace; }
|
|
140
|
-
function scheduleReminder(pi, delayMs = 30_000) {
|
|
141
|
-
clearReminder();
|
|
142
|
-
const inbound = state.currentInbound;
|
|
143
|
-
if (!inbound || !needsReply(inbound))
|
|
144
|
-
return;
|
|
145
|
-
state.reminderTimer = setTimeout(() => {
|
|
146
|
-
state.reminderTimer = undefined;
|
|
147
|
-
if (!needsReply(inbound) || state.currentInbound !== inbound)
|
|
148
|
-
return;
|
|
149
|
-
const cfg = currentConfig();
|
|
150
|
-
if (cfg.outbound.defaultReplyMode === "explicit-only")
|
|
151
|
-
return;
|
|
152
|
-
if (cfg.outbound.defaultReplyMode === "guarded-explicit" && inbound.reminders < cfg.outbound.guardedExplicit.reminders) {
|
|
153
|
-
if (inbound.reminders === 0)
|
|
154
|
-
inbound.fallbackText = state.lastValidOutput;
|
|
155
|
-
inbound.reminders++;
|
|
156
|
-
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" });
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
void reply(inbound.fallbackText || state.lastValidOutput || cfg.outbound.guardedExplicit.noOutputFallbackText).catch(() => { });
|
|
160
|
-
}, delayMs);
|
|
161
|
-
}
|
|
162
|
-
function scheduleReconnect(pi) {
|
|
163
|
-
if (state.reconnectTimer || !state.watching || !state.workspace)
|
|
164
|
-
return;
|
|
165
|
-
state.reconnectTimer = setTimeout(async () => {
|
|
166
|
-
state.reconnectTimer = undefined;
|
|
167
|
-
if (!state.watching)
|
|
168
|
-
return;
|
|
169
|
-
try {
|
|
170
|
-
await startWatch(pi, state.swarmContext);
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
scheduleReconnect(pi);
|
|
174
|
-
}
|
|
175
|
-
}, 1000);
|
|
176
|
-
}
|
|
177
|
-
/** Whether a history task already has an out (done) in this workspace. */
|
|
178
|
-
async function historyTaskDone(taskId) {
|
|
179
|
-
try {
|
|
180
|
-
const { request } = await import("./onlyne.js");
|
|
181
|
-
if (!state.workspace)
|
|
182
|
-
return false;
|
|
183
|
-
const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
|
|
184
|
-
const items = res?.data ?? res ?? [];
|
|
185
|
-
if (!Array.isArray(items))
|
|
186
|
-
return false;
|
|
187
|
-
const outs = new Set();
|
|
188
|
-
for (const m of items) {
|
|
189
|
-
const text = m?.text ?? "";
|
|
190
|
-
if (typeof text !== "string" || !text.startsWith("---swarm"))
|
|
191
|
-
continue;
|
|
192
|
-
if (text.startsWith("---swarm-ctl"))
|
|
193
|
-
continue;
|
|
194
|
-
if (m?.direction === "outbound") {
|
|
195
|
-
const p = parseSwarmHeader(text);
|
|
196
|
-
if (p)
|
|
197
|
-
outs.add(p.header.task_id);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (outs.has(taskId))
|
|
201
|
-
return true;
|
|
202
|
-
// Stale-claim guard: an inbound older than the newest outbound batch
|
|
203
|
-
// belongs to a previous hop generation. Only the scheduler-assigned
|
|
204
|
-
// env task bypasses this check.
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
/** Replay the newest unclaimed swarm task from loopback history. Idempotent.
|
|
212
|
-
* Prefers the scheduler-assigned env task (ONLYNE_SWARM_TASK); never claims
|
|
213
|
-
* an already-done task unless it is the env task (env wins, scheduler owns
|
|
214
|
-
* the truth about what this terminal must run). */
|
|
215
|
-
async function catchUpSwarmHistory(pi, ctx) {
|
|
216
|
-
if (swarmSlot.task().taskId)
|
|
217
|
-
return;
|
|
218
|
-
const { request } = await import("./onlyne.js");
|
|
219
|
-
if (!state.workspace)
|
|
220
|
-
return;
|
|
221
|
-
const env = envTaskId();
|
|
222
|
-
const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
|
|
223
|
-
const items = res?.data ?? res ?? [];
|
|
224
|
-
if (!Array.isArray(items))
|
|
225
|
-
return;
|
|
226
|
-
const inbounds = [];
|
|
227
|
-
const outs = new Set();
|
|
228
|
-
for (const m of items) {
|
|
229
|
-
const text = m?.text ?? m?.content ?? "";
|
|
230
|
-
if (typeof text !== "string" || !text.startsWith("---swarm"))
|
|
231
|
-
continue;
|
|
232
|
-
if (text.startsWith("---swarm-ctl"))
|
|
233
|
-
continue;
|
|
234
|
-
// Own lifecycle evidence shares this history. A report frame carries no
|
|
235
|
-
// task header, so it must pass before the malformed-header check.
|
|
236
|
-
if (isSwarmReportFrame(text))
|
|
237
|
-
continue;
|
|
238
|
-
const p = parseSwarmHeader(text);
|
|
239
|
-
if (!p) {
|
|
240
|
-
if (text.startsWith("---swarm"))
|
|
241
|
-
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "swarm_protocol_fault");
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
if (m?.direction === "outbound")
|
|
245
|
-
outs.add(p.header.task_id);
|
|
246
|
-
else
|
|
247
|
-
inbounds.push(text);
|
|
248
|
-
}
|
|
249
|
-
// A scheduler-created terminal has an env task id. In that case this
|
|
250
|
-
// selector returns only the matching wire or undefined: it must never
|
|
251
|
-
// fall back to an old history task while the new FIFO write races the
|
|
252
|
-
// initial catchup. Manual ready-pool sessions have no env and retain
|
|
253
|
-
// newest-unclosed fallback selection.
|
|
254
|
-
const selected = selectHistoryDelivery(env || undefined, inbounds, outs);
|
|
255
|
-
if (selected)
|
|
256
|
-
handleSwarmInbound(pi, selected, undefined, ctx);
|
|
257
|
-
}
|
|
258
|
-
/** Pin the tab title for a claimed hop. Pi rewrites the create-time title on
|
|
259
|
-
* boot and on idle transitions, and the scheduler-side rename races those
|
|
260
|
-
* writes. Last writer wins, so the session re-asserts its own title on claim
|
|
261
|
-
* and again on agent_end (idle), which is pi's other rewrite point. */
|
|
262
|
-
function pinSwarmTitle(ctx, taskId) {
|
|
263
|
-
try {
|
|
264
|
-
ctx?.ui?.setTitle?.(`swarm:${swarmTreePath()}:${taskId.slice(0, 8)}`);
|
|
265
|
-
}
|
|
266
|
-
catch { /* title is cosmetic */ }
|
|
267
|
-
}
|
|
268
|
-
/** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
|
|
269
|
-
function handleSwarmInbound(pi, text, eventSeq, ctx) {
|
|
270
|
-
// A report frame is this session's own evidence relayed back on the event
|
|
271
|
-
// stream. It is neither a task delivery nor a protocol fault.
|
|
272
|
-
if (isSwarmReportFrame(text))
|
|
273
|
-
return false;
|
|
274
|
-
let parsed;
|
|
275
|
-
try {
|
|
276
|
-
parsed = parseSwarmHeader(text);
|
|
277
|
-
}
|
|
278
|
-
catch (e) {
|
|
279
|
-
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `swarm_protocol_fault:${e instanceof Error ? e.message : String(e)}`);
|
|
280
|
-
return false;
|
|
281
|
-
}
|
|
282
|
-
if (!parsed) {
|
|
283
|
-
if (text.startsWith("---swarm"))
|
|
284
|
-
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "swarm_protocol_fault");
|
|
285
|
-
return false;
|
|
286
|
-
}
|
|
287
|
-
if (state.socket && eventSeq !== undefined)
|
|
288
|
-
awaitIntent(consumeEvent(state.socket, eventSeq).catch(() => { }));
|
|
289
|
-
// Slot transitions live in SwarmSlot (unit-tested); here we only mirror
|
|
290
|
-
// the claimed task into session state. A claimed session never accepts
|
|
291
|
-
// another task; downstream work spawns new tasks via swarm_send.
|
|
292
|
-
const outcome = (() => { try {
|
|
293
|
-
return swarmSlot.handle(pi, text, envTaskId() || undefined, state.workspace?.root);
|
|
294
|
-
}
|
|
295
|
-
catch (e) {
|
|
296
|
-
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `swarm_slot_fault:${e instanceof Error ? e.message : String(e)}`);
|
|
297
|
-
return "fault";
|
|
298
|
-
} })();
|
|
299
|
-
if (outcome === "claimed" || outcome === "yielded") {
|
|
300
|
-
const cur = swarmSlot.task();
|
|
301
|
-
state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
|
|
302
|
-
state.swarmTaskKey = cur.taskId;
|
|
303
|
-
const sessionCtx = state.swarmContext;
|
|
304
|
-
if (sessionCtx?.appendEntry) {
|
|
305
|
-
const restored = restoreSwarmState(sessionCtx, cur.taskId);
|
|
306
|
-
state.swarmSession?.stopHeartbeat();
|
|
307
|
-
state.swarmSession = new SwarmSessionState(sessionCtx, cur.taskId, restored);
|
|
308
|
-
state.swarmSession.startSession();
|
|
309
|
-
state.swarmSession.transition({ agent: "ready", resource: "attached", lifecycle: "working", recovery: "idle_waiting" }, "swarm_task_claimed");
|
|
310
|
-
state.swarmSession.startHeartbeat(10_000, swarmHeartbeat);
|
|
311
|
-
}
|
|
312
|
-
if (ctx)
|
|
313
|
-
void pinSwarmTitle(ctx, cur.taskId);
|
|
314
|
-
}
|
|
315
|
-
return outcome === "claimed" || outcome === "yielded";
|
|
316
|
-
}
|
|
317
|
-
async function startWatch(pi, ctx) {
|
|
318
|
-
state.workspace = findWorkspace(state.cwd);
|
|
319
|
-
if (!state.workspace)
|
|
320
|
-
throw new Error("current workspace has no .onlyne configuration");
|
|
321
|
-
state.swarmContext = ctx ?? state.swarmContext ?? cachedCtx;
|
|
322
|
-
refreshSwarmFlag();
|
|
323
|
-
if (state.reconnectTimer)
|
|
324
|
-
clearTimeout(state.reconnectTimer);
|
|
325
|
-
state.reconnectTimer = undefined;
|
|
326
|
-
state.socket?.destroy();
|
|
327
|
-
state.socket = undefined;
|
|
328
|
-
const conn = await connectDaemon(state.workspace);
|
|
329
|
-
state.owner = conn.owner;
|
|
330
|
-
state.child = conn.process;
|
|
331
|
-
if (state.swarm) {
|
|
332
|
-
// Swarm mode: the scheduler owns in/out. Subscribe without auto-handling generic
|
|
333
|
-
// traffic; only swarm headers enter the session, via the followUp task queue.
|
|
334
|
-
// Report readiness so the scheduler can match a pending task (fork+exec: any
|
|
335
|
-
// clean ready session on this workspace path may take it).
|
|
336
|
-
const ws = state.workspace;
|
|
337
|
-
// Priority 1 (above default 0): the scheduler consumes at MAX, then we
|
|
338
|
-
// take the event at tier 1 so it also reaches the session. A plain
|
|
339
|
-
// tier-0 subscription would starve whenever the scheduler consumes.
|
|
340
|
-
const socket = subscribe(ws.socketPath, (line) => {
|
|
341
|
-
if (!line?.event)
|
|
342
|
-
return;
|
|
343
|
-
if (line.type !== "inbound_message")
|
|
344
|
-
return;
|
|
345
|
-
const inbound = inboundText(line);
|
|
346
|
-
if (!inbound || inbound.channelId !== "loopback")
|
|
347
|
-
return;
|
|
348
|
-
// Catch up on history first: a fresh watch may subscribe after the
|
|
349
|
-
// task was already delivered (scheduler writes in before the
|
|
350
|
-
// session finishes starting). History replay is idempotent via
|
|
351
|
-
// the slot guard + scheduler task_id dedup.
|
|
352
|
-
if (parseSwarmCtl(inbound.text)) {
|
|
353
|
-
const ctl = parseSwarmCtl(inbound.text);
|
|
354
|
-
awaitIntent(handleSwarmRecycle(ctl.task_id, ctl.reason));
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
void Promise.resolve(catchUpSwarmHistory(pi)).catch((e) => state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `history_catchup_fault:${e instanceof Error ? e.message : String(e)}`));
|
|
358
|
-
handleSwarmInbound(pi, inbound.text, line.event_seq);
|
|
359
|
-
}, () => { if (state.socket === socket)
|
|
360
|
-
scheduleReconnect(pi); }, { priority: 1 });
|
|
361
|
-
state.socket = socket;
|
|
362
|
-
state.watching = true;
|
|
363
|
-
try {
|
|
364
|
-
await awaitSubscriptionAck(socket);
|
|
365
|
-
}
|
|
366
|
-
catch (e) {
|
|
367
|
-
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "subscribe_events_fault");
|
|
368
|
-
throw e;
|
|
369
|
-
}
|
|
370
|
-
const task = envTaskId();
|
|
371
|
-
const sessionTaskId = task || state.swarmTaskKey || `session:${terminalHandle() || randomUUID()}`;
|
|
372
|
-
state.swarmTaskKey = sessionTaskId;
|
|
373
|
-
const sessionCtx = state.swarmContext;
|
|
374
|
-
if (sessionCtx?.appendEntry) {
|
|
375
|
-
const restored = restoreSwarmState(sessionCtx, sessionTaskId);
|
|
376
|
-
state.swarmSession?.stopHeartbeat();
|
|
377
|
-
state.swarmSession = new SwarmSessionState(sessionCtx, sessionTaskId, restored);
|
|
378
|
-
state.swarmSession.startSession();
|
|
379
|
-
}
|
|
380
|
-
const readyPayload = { workspace: ws.root, terminal_handle: terminalHandle() };
|
|
381
|
-
await sendSwarmIntent("swarm_ready", readyPayload, (opId) => swarmReady(ws.socketPath, ws.root, terminalHandle(), state.swarmSession?.snapshot(), opId), currentConfig().outbound.retry.attempts);
|
|
382
|
-
if (state.swarmSession) {
|
|
383
|
-
state.swarmSession.transition({ agent: "ready", resource: "attached", lifecycle: "idle", recovery: "idle_waiting" }, "swarm_ready");
|
|
384
|
-
// Awaits the daemon receipt: the scheduler holds this session's first
|
|
385
|
-
// watermark before it reads anything else from the workspace.
|
|
386
|
-
await emitSwarmReport("ready");
|
|
387
|
-
state.swarmSession.startHeartbeat(10_000, swarmHeartbeat);
|
|
388
|
-
}
|
|
389
|
-
// The task may already sit in history (scheduler wrote in before this
|
|
390
|
-
// session finished starting). Claim it now instead of waiting for a
|
|
391
|
-
// live event that already fired.
|
|
392
|
-
await catchUpSwarmHistory(pi);
|
|
393
|
-
return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
|
|
394
|
-
}
|
|
395
|
-
const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
|
|
396
|
-
return; const inbound = inboundText(line); if (!inbound)
|
|
397
|
-
return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
|
|
398
|
-
return; if (inbound.channelId === "loopback") {
|
|
399
|
-
if (mode === "auto-handle")
|
|
400
|
-
pi.sendUserMessage(`Onlyne loopback activation${inbound.conversationId ? ` (${inbound.conversationId})` : ""}:\n\n${inbound.text}`, { deliverAs: "followUp" });
|
|
401
|
-
consumeIfNotified(inbound);
|
|
402
|
-
return;
|
|
403
|
-
} if (inbound.text.trim() === "/handshake") {
|
|
404
|
-
consumeIfNotified(inbound);
|
|
405
|
-
return;
|
|
406
|
-
} clearReminder(); state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle") {
|
|
407
|
-
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" });
|
|
408
|
-
consumeIfNotified(inbound);
|
|
409
|
-
} }, () => { if (state.socket === socket)
|
|
410
|
-
scheduleReconnect(pi); });
|
|
411
|
-
state.socket = socket;
|
|
412
|
-
state.watching = true;
|
|
413
|
-
return `watching ${state.workspace.root} (${state.owner})`;
|
|
414
|
-
}
|
|
415
|
-
function stopWatch(clearSession = true) { if (state.reconnectTimer)
|
|
416
|
-
clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; if (clearSession) {
|
|
417
|
-
state.swarmSession?.shutdown();
|
|
418
|
-
state.swarmSession = undefined;
|
|
419
|
-
}
|
|
420
|
-
else
|
|
421
|
-
state.swarmSession?.stopHeartbeat(); state.watching = false; state.owner = "stopped"; state.swarmTask = undefined; swarmSlot.clear(); return "watch stopped"; }
|
|
422
|
-
async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
423
|
-
throw new Error("current workspace has no .onlyne configuration"); refreshSwarmFlag(); const conn = await connectDaemon(state.workspace, true); state.owner = conn.owner; state.child = conn.process; return `daemon ${state.owner === "extension" ? "started" : "already running"} for ${state.workspace.root}`; }
|
|
424
|
-
async function stopDaemon() { if (!state.workspace)
|
|
425
|
-
state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
426
|
-
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}`; }
|
|
427
|
-
async function restartDaemon() { await stopDaemon().catch(() => { }); return startDaemon(); }
|
|
428
|
-
async function reply(text) { if (!state.workspace)
|
|
429
|
-
throw new Error("onlyne workspace not found"); const inbound = state.currentInbound; if (!inbound)
|
|
430
|
-
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) {
|
|
431
|
-
inbound.replied = true;
|
|
432
|
-
clearReminder();
|
|
433
|
-
} return res; }
|
|
434
|
-
/** Resolve the current workspace tree path: segment after /.ws/, root is ".". */
|
|
435
|
-
function swarmTreePath() {
|
|
436
|
-
if (!state.workspace)
|
|
437
|
-
throw new Error("onlyne workspace not found");
|
|
438
|
-
return treePathForWorkspace(state.workspace.root);
|
|
439
|
-
}
|
|
440
|
-
async function swarmComplete(text) {
|
|
441
|
-
if (!state.workspace)
|
|
442
|
-
throw new Error("onlyne workspace not found");
|
|
443
|
-
const task = state.swarmTask;
|
|
444
|
-
if (!task)
|
|
445
|
-
throw new Error("no active swarm task");
|
|
446
|
-
const { renderSwarmHeader } = await import("./swarm.js");
|
|
447
|
-
const wire = renderSwarmHeader({ task_id: task.taskId, from: swarmTreePath(), transfer_send_to: task.transferSendTo, attempt: task.attempt }, "", text);
|
|
448
|
-
const payload = { task_id: task.taskId, text: wire };
|
|
449
|
-
const result = await sendSwarmIntent("swarm_complete", payload, (opId) => sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, 1, false, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_complete", task.taskId));
|
|
450
|
-
state.swarmSession?.transition({ agent: "ready", resource: "closing", lifecycle: "exited", recovery: "draining" }, "swarm_complete");
|
|
451
|
-
// Awaits the daemon receipt on the terminal evidence for this generation.
|
|
452
|
-
await emitSwarmReport("complete");
|
|
453
|
-
state.swarmTask = undefined;
|
|
454
|
-
swarmSlot.clear();
|
|
455
|
-
clearReminder();
|
|
456
|
-
try {
|
|
457
|
-
stopWatch(false);
|
|
458
|
-
}
|
|
459
|
-
catch { /* lifecycle cleanup */ }
|
|
460
|
-
setTimeout(() => process.exit(0), 300);
|
|
461
|
-
return { ...(result && typeof result === "object" ? result : { ok: true }), taskId: task.taskId };
|
|
462
|
-
}
|
|
463
|
-
async function swarmQuit(reason) {
|
|
464
|
-
const task = state.swarmTask;
|
|
465
|
-
const taskId = task?.taskId ?? "";
|
|
466
|
-
const why = `quit:${reason ?? ""}`;
|
|
467
|
-
if (state.workspace && taskId) {
|
|
468
|
-
try {
|
|
469
|
-
await sendSwarmIntent("swarm_recycled", { task_id: taskId, reason: why }, (opId) => swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_recycled", `quit:${taskId}`));
|
|
470
|
-
}
|
|
471
|
-
catch { /* fault persists; quit still exits */ }
|
|
472
|
-
}
|
|
473
|
-
// A quit writes no out, so Failed is the accurate projection of this hop.
|
|
474
|
-
// The report carries this session's own watermark next to the swarm_recycled
|
|
475
|
-
// ack above, and its receipt is collected before the exit path runs.
|
|
476
|
-
await emitSwarmReport("fault");
|
|
477
|
-
clearReminder();
|
|
478
|
-
try {
|
|
479
|
-
stopWatch(false);
|
|
480
|
-
}
|
|
481
|
-
catch { /* lifecycle cleanup */ }
|
|
482
|
-
state.swarmTask = undefined;
|
|
483
|
-
swarmSlot.clear();
|
|
484
|
-
setTimeout(() => process.exit(0), 300);
|
|
485
|
-
return { quit: true, taskId, reason: reason ?? "" };
|
|
486
|
-
}
|
|
487
|
-
async function handleSwarmRecycle(ctlTaskId, reason) {
|
|
488
|
-
const claimed = swarmSlot.task().taskId;
|
|
489
|
-
const taskId = ctlTaskId === "*" ? (claimed ?? "") : ctlTaskId;
|
|
490
|
-
if (claimed && ctlTaskId !== "*" && claimed !== ctlTaskId)
|
|
491
|
-
return false;
|
|
492
|
-
const why = `recycle:${reason}`;
|
|
493
|
-
// The scheduler initiated this recycle, so its own ledger already holds the
|
|
494
|
-
// truth about the hop. A report from here would restate a transition that
|
|
495
|
-
// side drove, hence no lifecycle report on this path.
|
|
496
|
-
if (state.workspace && taskId) {
|
|
497
|
-
try {
|
|
498
|
-
await sendSwarmIntent("swarm_recycled", { task_id: taskId, reason: why }, (opId) => swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_recycled", `recycle:${taskId}`));
|
|
499
|
-
}
|
|
500
|
-
catch { /* fault persists; recycle still exits */ }
|
|
501
|
-
}
|
|
502
|
-
clearReminder();
|
|
503
|
-
try {
|
|
504
|
-
stopWatch(false);
|
|
505
|
-
}
|
|
506
|
-
catch { /* lifecycle cleanup */ }
|
|
507
|
-
state.swarmTask = undefined;
|
|
508
|
-
swarmSlot.clear();
|
|
509
|
-
setTimeout(() => process.exit(0), 300);
|
|
510
|
-
return true;
|
|
511
|
-
}
|
|
512
|
-
/** Resolve a scheduler target symlink to that workspace's daemon socket. */
|
|
513
|
-
function swarmTargetSocket(linkPath, workspaceRoot) {
|
|
514
|
-
const targetIn = realpathSync(linkPath);
|
|
515
|
-
const root = resolve(workspaceRoot);
|
|
516
|
-
if (!targetIn.startsWith(root + "/") || !targetIn.endsWith("/.onlyne/channels/loopback/in"))
|
|
517
|
-
throw new Error(`invalid swarm_send target: ${linkPath}`);
|
|
518
|
-
const targetOnlyne = dirname(dirname(dirname(targetIn)));
|
|
519
|
-
return join(targetOnlyne, "run", "s");
|
|
520
|
-
}
|
|
521
|
-
/** swarm_send: spawn downstream through the target daemon's loopback RPC. */
|
|
522
|
-
async function swarmSend(to, text) {
|
|
523
|
-
if (!state.workspace)
|
|
524
|
-
throw new Error("onlyne workspace not found");
|
|
525
|
-
const task = state.swarmTask;
|
|
526
|
-
if (!task)
|
|
527
|
-
throw new Error("no active swarm task");
|
|
528
|
-
const { renderSwarmHeader } = await import("./swarm.js");
|
|
529
|
-
const wsRoot = state.workspace.root;
|
|
530
|
-
const linkPath = to === "." || to === "_root" ? join(wsRoot, "onlyne_in", "_root") : join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
|
|
531
|
-
const childId = randomUUID();
|
|
532
|
-
const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
|
|
533
|
-
const targetSocket = swarmTargetSocket(linkPath, wsRoot);
|
|
534
|
-
const payload = { child_id: childId, to, task_id: task.taskId, text: wire };
|
|
535
|
-
await sendSwarmIntent("swarm_send", payload, (opId) => loopback(targetSocket, wire, true, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_send", childId));
|
|
536
|
-
swarmSlot.noteSpawned(childId);
|
|
537
|
-
return { childId, to };
|
|
538
|
-
}
|
|
539
|
-
/** Swarm mode: apply the workspace snapshot model triple to this session. */
|
|
540
|
-
async function applySwarmModel(pi, ctx) {
|
|
541
|
-
if (!state.swarm || !state.workspace)
|
|
542
|
-
return;
|
|
543
|
-
const m = readSwarmModel(state.workspace.onlyneDir);
|
|
544
|
-
if (!m)
|
|
545
|
-
return;
|
|
546
|
-
if (ctx?.model && m.provider && ctx.model.provider === m.provider && m.model && ctx.model.id === m.model && (!m.effort || m.effort === ctx.thinkingLevel))
|
|
547
|
-
return;
|
|
548
|
-
try {
|
|
549
|
-
if (m.provider && m.model) {
|
|
550
|
-
const found = ctx.modelRegistry?.find?.(m.provider, m.model);
|
|
551
|
-
if (found) {
|
|
552
|
-
const ok = await pi.setModel(found);
|
|
553
|
-
if (!ok) {
|
|
554
|
-
ctx.ui?.notify(`swarm model ${m.provider}/${m.model}: no auth`, "warning");
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
else
|
|
559
|
-
ctx.ui?.notify(`swarm model ${m.provider}/${m.model} not in registry; keeping default`, "warning");
|
|
560
|
-
}
|
|
561
|
-
const eff = m.effort;
|
|
562
|
-
if (eff && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(eff))
|
|
563
|
-
pi.setThinkingLevel(eff);
|
|
564
|
-
ctx.ui?.setStatus?.("onlyne-model", `model:${m.provider ?? ""}/${m.model ?? ""}${m.effort ? ":" + m.effort : ""}`);
|
|
565
|
-
}
|
|
566
|
-
catch (e) {
|
|
567
|
-
ctx.ui?.notify(`swarm model apply failed: ${e}`, "warning");
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
export default function onlyne(pi) {
|
|
571
|
-
pi.on("session_start", async (_event, ctx) => { cachedCtx = ctx; const resumeWatch = state.watching; if (state.owner === "extension")
|
|
572
|
-
await stopDaemon().catch(() => { });
|
|
573
|
-
else
|
|
574
|
-
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) {
|
|
575
|
-
try {
|
|
576
|
-
ctx.ui.notify(await startWatch(pi), "info");
|
|
577
|
-
}
|
|
578
|
-
catch (e) {
|
|
579
|
-
ctx.ui.notify(String(e), "warning");
|
|
580
|
-
}
|
|
581
|
-
} });
|
|
582
|
-
pi.on("session_shutdown", async () => { if (state.owner === "extension")
|
|
583
|
-
await stopDaemon().catch(() => { });
|
|
584
|
-
else
|
|
585
|
-
stopWatch(); });
|
|
586
|
-
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
587
|
-
process.once(sig, () => stopWatch());
|
|
588
|
-
pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
|
|
589
|
-
state.lastValidOutput = text; });
|
|
590
|
-
pi.on("agent_start", async () => { clearReminder(); swarmSessionTransition({ agent: "running", lifecycle: "working", recovery: "idle_waiting" }, "turn_start"); awaitIntent(emitSwarmReport("turn_started")); reportHopBusy().catch(() => { }); });
|
|
591
|
-
pi.on("agent_end", async () => { if (state.swarm) {
|
|
592
|
-
const cur = swarmSlot.task();
|
|
593
|
-
if (cur.taskId && cachedCtx)
|
|
594
|
-
void pinSwarmTitle(cachedCtx, cur.taskId);
|
|
595
|
-
swarmSessionTransition({ agent: "idle", lifecycle: "idle", recovery: "idle_waiting" }, "turn_idle");
|
|
596
|
-
scheduleSwarmExitReminder(pi);
|
|
597
|
-
}
|
|
598
|
-
else
|
|
599
|
-
scheduleReminder(pi); reportHopIdle().catch(() => { }); });
|
|
600
|
-
pi.registerCommand("onlyne", {
|
|
601
|
-
description: "Onlyne watch/status/config commands",
|
|
602
|
-
getArgumentCompletions: (prefix) => {
|
|
603
|
-
const commands = ["status", "watch on", "watch off", "daemon start", "daemon stop", "daemon restart", "config auto-start", "swarm on", "swarm off", "swarm status"];
|
|
604
|
-
const p = prefix.trimStart();
|
|
605
|
-
const filtered = commands.filter((c) => c.startsWith(p));
|
|
606
|
-
return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
|
|
607
|
-
},
|
|
608
|
-
handler: async (argLine, ctx) => {
|
|
609
|
-
const [cmd, sub] = argLine.trim().split(/\s+/);
|
|
610
|
-
try {
|
|
611
|
-
if (cmd === "watch" && sub === "on")
|
|
612
|
-
ctx.ui.notify(await startWatch(pi), "info");
|
|
613
|
-
else if (cmd === "watch" && sub === "off")
|
|
614
|
-
ctx.ui.notify(stopWatch(), "info");
|
|
615
|
-
else if (cmd === "daemon" && sub === "start")
|
|
616
|
-
ctx.ui.notify(await startDaemon(), "info");
|
|
617
|
-
else if (cmd === "daemon" && sub === "stop")
|
|
618
|
-
ctx.ui.notify(await stopDaemon(), "info");
|
|
619
|
-
else if (cmd === "daemon" && sub === "restart")
|
|
620
|
-
ctx.ui.notify(await restartDaemon(), "info");
|
|
621
|
-
else if (cmd === "status")
|
|
622
|
-
ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; swarm=${state.swarm ? "on" : "off"}; workspace=${state.workspace?.root ?? "none"}`, "info");
|
|
623
|
-
else if (cmd === "swarm" && sub === "status") {
|
|
624
|
-
const cur = swarmSlot.task();
|
|
625
|
-
ctx.ui.notify(`swarm=${state.swarm ? "on" : "off"}; task=${cur.taskId ?? "none"}; spawned=${cur.sentChildIds.length}`, "info");
|
|
626
|
-
}
|
|
627
|
-
else if (cmd === "swarm" && (sub === "on" || sub === "off")) {
|
|
628
|
-
ctx.ui.notify(await setSwarm(pi, sub === "on"), "info");
|
|
629
|
-
}
|
|
630
|
-
else if (cmd === "config" && sub === "auto-start") {
|
|
631
|
-
const cfg = currentConfig();
|
|
632
|
-
cfg.watch.autoStart = !cfg.watch.autoStart;
|
|
633
|
-
saveConfig(state.cwd, cfg);
|
|
634
|
-
ctx.ui.notify(`autoStart=${cfg.watch.autoStart}`, "info");
|
|
635
|
-
}
|
|
636
|
-
else
|
|
637
|
-
ctx.ui.notify("usage: /onlyne status | watch on|off | daemon start|stop|restart | swarm on|off|status | config auto-start", "info");
|
|
638
|
-
}
|
|
639
|
-
catch (e) {
|
|
640
|
-
ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
|
|
641
|
-
}
|
|
642
|
-
},
|
|
643
|
-
});
|
|
644
|
-
registerNormalTools(pi);
|
|
645
|
-
registerSwarmTools(pi);
|
|
646
|
-
applyToolSurface(pi);
|
|
647
|
-
}
|
|
648
|
-
const NORMAL_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "onlyne_reply", "onlyne_send", "onlyne_broadcast", "onlyne_loopback", "onlyne_mark_no_reply"];
|
|
649
|
-
const SWARM_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "swarm_complete", "swarm_quit", "swarm_send", "swarm_status"];
|
|
650
|
-
/** Tool surface follows [swarm] at session start: one mode, one toolset. */
|
|
651
|
-
function applyToolSurface(pi) {
|
|
652
|
-
try {
|
|
653
|
-
const active = new Set(typeof pi.getActiveTools === "function" ? pi.getActiveTools() : []);
|
|
654
|
-
const all = typeof pi.getAllTools === "function" ? pi.getAllTools().map((t) => t.name) : [];
|
|
655
|
-
const keep = state.swarm ? SWARM_TOOLS : NORMAL_TOOLS;
|
|
656
|
-
const next = [...active].filter((n) => all.includes(n) && !(NORMAL_TOOLS.includes(n) || SWARM_TOOLS.includes(n)));
|
|
657
|
-
for (const n of keep)
|
|
658
|
-
if (all.includes(n) && !next.includes(n))
|
|
659
|
-
next.push(n);
|
|
660
|
-
if (typeof pi.setActiveTools === "function")
|
|
661
|
-
pi.setActiveTools(next);
|
|
662
|
-
}
|
|
663
|
-
catch { /* older pi without tool-surface API: both sets stay registered */ }
|
|
664
|
-
}
|
|
665
|
-
function registerNormalTools(pi) {
|
|
666
|
-
pi.registerTool(defineTool({ name: "onlyne_daemon_start", label: "Onlyne daemon start", description: "Start or connect to the current workspace-local Onlyne daemon managed by pi-onlyne.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await startDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
667
|
-
pi.registerTool(defineTool({ name: "onlyne_daemon_stop", label: "Onlyne daemon stop", description: "Stop the current workspace-local Onlyne daemon through pi-onlyne lifecycle management. The tool also sends shutdown to a daemon already running in this workspace.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await stopDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
668
|
-
pi.registerTool(defineTool({ name: "onlyne_daemon_restart", label: "Onlyne daemon restart", description: "Restart the current workspace-local Onlyne daemon through pi-onlyne lifecycle management.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await restartDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
669
|
-
pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with Markdown to the current Onlyne inbound message. Use Markdown by default and preserve the message as one document.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await reply(params.text))); } }));
|
|
670
|
-
pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to the channel's configured Onlyne conversation. Set rawText=true only for literal plain text.", parameters: Type.Object({ channelId: Type.String(), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
671
|
-
throw new Error("onlyne workspace not found"); const res = await sendWithRetry(state.workspace.socketPath, params, params.text, currentConfig().outbound.retry.attempts, params.rawText ?? false); return textResult(JSON.stringify(res), res); } }));
|
|
672
|
-
pi.registerTool(defineTool({ name: "onlyne_broadcast", label: "Onlyne broadcast", description: "Send Markdown to many configured Onlyne channels concurrently. Set rawText=true only for literal plain text.", parameters: Type.Object({ targets: Type.Array(Type.Object({ channelId: Type.String() })), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
673
|
-
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); } }));
|
|
674
|
-
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)
|
|
675
|
-
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); } }));
|
|
676
|
-
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) {
|
|
677
|
-
state.currentInbound.noReply = true;
|
|
678
|
-
clearReminder();
|
|
679
|
-
} return textResult("marked no reply", params); } }));
|
|
680
|
-
}
|
|
681
|
-
function registerSwarmTools(pi) {
|
|
682
|
-
pi.registerTool(defineTool({ name: "swarm_complete", label: "Swarm complete", description: "Hand over this swarm hop: write the out message carrying this task header (done signal). The session becomes recyclable. Use once per swarm task.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmComplete(params.text))); } }));
|
|
683
|
-
pi.registerTool(defineTool({ name: "swarm_quit", label: "Swarm quit", description: "Quit this swarm hop silently: no out is written and the scheduler records failed. Use when the task premise does not hold or there is nothing to do.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmQuit(params.reason))); } }));
|
|
684
|
-
pi.registerTool(defineTool({ name: "swarm_send", label: "Swarm send", description: "Spawn a downstream swarm task through the target workspace daemon loopback RPC with transfer_send_to set to the current task. Stable op_id makes retries idempotent; returns the child id without waiting. Errors on missing targets.", parameters: Type.Object({ to: Type.String(), text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmSend(params.to, params.text))); } }));
|
|
685
|
-
pi.registerTool(defineTool({ name: "swarm_status", label: "Swarm status", description: "Read-only swarm state: current task id, from, workspace path, and spawned child ids.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const cur = swarmSlot.task(); return textResult(JSON.stringify({ taskId: cur.taskId ?? null, from: cur.from, transferSendTo: cur.transferSendTo, workspace: state.workspace?.root ?? null, spawned: cur.sentChildIds })); } }));
|
|
686
|
-
}
|
|
687
|
-
/** R4 hop activity reporters: turn-granularity busy/idle over the existing
|
|
688
|
-
* swarm event channel. Fire-and-forget (failures silent): the scheduler
|
|
689
|
-
* treats missing rows as race residue, and its TTLs are the backstop.
|
|
690
|
-
* busy fires only with a live claimed task; idle fires only when the turn
|
|
691
|
-
* ended without an out (swarmTask still set). The decision (guard +
|
|
692
|
-
* pending_exit) is the shared, unit-tested hopActivityBody. */
|
|
693
|
-
async function reportHopBusy() {
|
|
694
|
-
const body = hopActivityBody("busy", { swarm: state.swarm, workspaceRoot: state.workspace?.root, terminal: terminalHandle(), taskId: state.swarmTask?.taskId });
|
|
695
|
-
if (!body || !state.workspace)
|
|
696
|
-
return;
|
|
697
|
-
await sendSwarmIntent("swarm_busy", body, (opId) => swarmBusy(state.workspace.socketPath, body, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_busy", `${state.swarmTask?.taskId ?? ""}:${state.swarmSession?.seq ?? 0}`));
|
|
698
|
-
}
|
|
699
|
-
async function reportHopIdle() {
|
|
700
|
-
const body = hopActivityBody("idle", { swarm: state.swarm, workspaceRoot: state.workspace?.root, terminal: terminalHandle(), taskId: state.swarmTask?.taskId });
|
|
701
|
-
if (!body || !state.workspace)
|
|
702
|
-
return;
|
|
703
|
-
await sendSwarmIntent("swarm_idle", body, (opId) => swarmIdle(state.workspace.socketPath, body, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_idle", `${state.swarmTask?.taskId ?? ""}:${state.swarmSession?.seq ?? 0}`));
|
|
704
|
-
}
|
|
705
|
-
/** Swarm exit guard: at agent end, an unfinished hop gets one reminder, then auto-quit. */
|
|
706
|
-
function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
707
|
-
clearReminder();
|
|
708
|
-
if (!state.swarmTask)
|
|
709
|
-
return;
|
|
710
|
-
state.reminderTimer = setTimeout(() => {
|
|
711
|
-
state.reminderTimer = undefined;
|
|
712
|
-
if (!state.swarmTask)
|
|
713
|
-
return;
|
|
714
|
-
if (state.swarmTask) {
|
|
715
|
-
{
|
|
716
|
-
const p = pi;
|
|
717
|
-
if (typeof p.sendMessage === "function")
|
|
718
|
-
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" });
|
|
719
|
-
else
|
|
720
|
-
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" });
|
|
721
|
-
}
|
|
722
|
-
state.reminderTimer = setTimeout(() => {
|
|
723
|
-
state.reminderTimer = undefined;
|
|
724
|
-
if (state.swarmTask)
|
|
725
|
-
void swarmQuit("auto-quit: no explicit exit").catch(() => { });
|
|
726
|
-
}, delayMs);
|
|
727
|
-
}
|
|
728
|
-
}, delayMs);
|
|
729
|
-
}
|
|
730
|
-
/** Toggle swarm mode: persists to .onlyne/config.toml [swarm] enabled, restarts watch. */
|
|
731
|
-
async function setSwarm(pi, enabled) {
|
|
732
|
-
if (!state.workspace)
|
|
733
|
-
throw new Error("onlyne workspace not found");
|
|
734
|
-
const cfgPath = join(state.workspace.onlyneDir, "config.toml");
|
|
735
|
-
let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
736
|
-
if (text.includes("[swarm]")) {
|
|
737
|
-
const lines = text.split("\n");
|
|
738
|
-
let inSwarm = false;
|
|
739
|
-
text = lines.map((line) => {
|
|
740
|
-
const t = line.trim();
|
|
741
|
-
if (t.startsWith("[")) {
|
|
742
|
-
inSwarm = t === "[swarm]";
|
|
743
|
-
return line;
|
|
744
|
-
}
|
|
745
|
-
if (inSwarm && t.startsWith("enabled"))
|
|
746
|
-
return `enabled = ${enabled}`;
|
|
747
|
-
return line;
|
|
748
|
-
}).join("\n");
|
|
749
|
-
}
|
|
750
|
-
else {
|
|
751
|
-
if (text && !text.endsWith("\n"))
|
|
752
|
-
text += "\n";
|
|
753
|
-
text += `\n[swarm]\nenabled = ${enabled}\n`;
|
|
754
|
-
}
|
|
755
|
-
writeFileSync(cfgPath, text);
|
|
756
|
-
refreshSwarmFlag();
|
|
757
|
-
if (state.watching) {
|
|
758
|
-
stopWatch();
|
|
759
|
-
return `${enabled ? "swarm on; " : "swarm off; "}${await startWatch(pi)}`;
|
|
760
|
-
}
|
|
761
|
-
return `swarm ${enabled ? "on" : "off"} (watch not running)`;
|
|
762
|
-
}
|
|
763
|
-
/** Test seam: drive the lifecycle-report wiring against a stub daemon socket.
|
|
764
|
-
* No live pi session and no real daemon are involved; the seam exposes the
|
|
765
|
-
* same closures the hooks call. Mirrors `__swarmSlotForTest`. */
|
|
766
|
-
export function __swarmReportForTest() {
|
|
767
|
-
return {
|
|
768
|
-
setWorkspace: (ws) => { state.workspace = ws; },
|
|
769
|
-
beginSession: (appendEntry, taskId) => {
|
|
770
|
-
state.swarmSession?.shutdown();
|
|
771
|
-
state.swarmSession = new SwarmSessionState({ appendEntry }, taskId);
|
|
772
|
-
state.swarmSession.startSession();
|
|
773
|
-
return { generation: state.swarmSession.generation, seq: state.swarmSession.seq };
|
|
774
|
-
},
|
|
775
|
-
setTask: (task) => { state.swarmTask = task; },
|
|
776
|
-
reportTaskId: swarmReportTaskId,
|
|
777
|
-
emit: emitSwarmReport,
|
|
778
|
-
beat: swarmHeartbeat,
|
|
779
|
-
};
|
|
780
|
-
}
|