pi-onlyne 0.4.0 → 0.6.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 CHANGED
@@ -1,72 +1,107 @@
1
1
  # pi-onlyne
2
2
 
3
- **Give pi agents a real IM inbox/outbox through [Onlyne](https://github.com/dbydd/onlyne).**
3
+ `pi-onlyne` gives [Pi](https://github.com/badlogic/pi-mono) agents a local IM inbox and outbox through [Onlyne](https://github.com/dbydd/onlyne). The extension connects Pi to an Onlyne workspace, exposes message tools, and delivers subscribed events as Pi follow-ups.
4
4
 
5
- `pi-onlyne` is the Pi extension for Onlyne. It adds tools and commands to pi so an agent can receive messages from IM channels and send replies without pretending that a chat platform is a terminal, a browser tab, or a custom workflow engine.
5
+ ## Runtime requirements
6
6
 
7
- ## What is Onlyne?
7
+ - Node.js 20 or newer
8
+ - Pi 0.84 or newer with the `pi` command available in `PATH`
9
+ - `onlyne` 0.4.x installed with `cargo install onlyne`, or a compatible local build
10
+ - An initialized Onlyne workspace with `.onlyne/config.toml`
11
+ - A configured model/provider for Pi agent replies
12
+ - Unix domain socket support on the host
8
13
 
9
- [Onlyne](https://github.com/dbydd/onlyne) is a small workspace-local IM channel daemon. It runs in your project directory, keeps its config/state under `.onlyne/`, and brokers local agent calls to real messaging adapters such as Telegram, Feishu/Lark, QQ Bot, and WeChat.
14
+ The extension supports macOS and Linux. Each workspace keeps daemon state, channel credentials, history, sockets, and logs under its own `.onlyne/` directory.
10
15
 
11
- Onlyne is deliberately narrow:
16
+ ## Install
17
+
18
+ Install the published Pi package:
12
19
 
13
- - local workspace daemon, not a global cloud service
14
- - channel broker, not an agent runtime
15
- - Unix socket / stdio friendly, not a web dashboard
16
- - local history and event stream, not a heavy message platform
20
+ ```bash
21
+ pi install npm:pi-onlyne
22
+ ```
17
23
 
18
- ## What does this extension do?
24
+ Run it for one Pi process:
19
25
 
20
- `pi-onlyne` connects pi to an existing Onlyne workspace and exposes Onlyne as native pi tools.
26
+ ```bash
27
+ pi -e npm:pi-onlyne
28
+ ```
21
29
 
22
- Onlyne channels are singleton-routed: each enabled channel has one `bind_conversation_id` set in config or by sending `/handshake` from the desired conversation, so pi tools take `channelId` only.
30
+ Install the package from a local checkout during development:
23
31
 
24
- With this extension, a pi agent can:
32
+ ```bash
33
+ cd path/to/pi-onlyne
34
+ npm install
35
+ npm run check
36
+ pi install .
37
+ ```
25
38
 
26
- - watch an Onlyne workspace for inbound IM messages
27
- - surface inbound messages into the current pi session
28
- - reply to the current inbound message
29
- - send a message to a channel's configured conversation
30
- - broadcast the same message to multiple conversations
31
- - inject a local loopback activation so background scripts can wake the session
32
- - share Onlyne's FIFO consume cursor so `.onlyne/channels/<channel>/out` does not re-read messages already surfaced to pi
33
- - mark an inbound message as intentionally not replied
39
+ The package publishes `dist/`, `README.md`, `SPEC.md`, and `LICENSE`. `prepublishOnly` runs the build and test suite.
34
40
 
35
- Messages are Markdown by default, matching normal agent output. Use `rawText: true` only when the message must be sent literally. Onlyne can also expose FIFO IO under `.onlyne/channels/<channel>/in|out`; pi-onlyne stays on the socket/event API and advances the shared consume cursor after delivering inbound follow-ups.
41
+ ## Prepare an Onlyne workspace
36
42
 
37
- ## Install
43
+ Run these commands from the project that should receive the messages:
38
44
 
39
45
  ```bash
40
- pi install npm:pi-onlyne
46
+ cargo install onlyne
47
+ onlyne init
48
+ onlyne export-skill
41
49
  ```
42
50
 
43
- For a one-off run without installing:
51
+ Configure a channel in `.onlyne/config.toml` and place secrets in `.onlyne/.env`. Examples:
44
52
 
45
- ```bash
46
- pi -e npm:pi-onlyne
53
+ ```toml
54
+ [adapters.telegram]
55
+ enabled = true
56
+
57
+ [adapters.feishu]
58
+ enabled = true
59
+
60
+ [adapters.qqbot]
61
+ enabled = true
62
+
63
+ [adapters.wechat]
64
+ enabled = true
47
65
  ```
48
66
 
49
- You also need an initialized Onlyne workspace:
67
+ Use the matching `onlyne auth` command for Feishu, QQ Bot, or WeChat. Telegram uses `TELEGRAM_BOT_TOKEN` in `.onlyne/.env`. Bind a target conversation with `bind_conversation_id`, or send `/handshake` from the desired conversation after the adapter starts.
68
+
69
+ Start the daemon from the project root:
50
70
 
51
71
  ```bash
52
- onlyne init
53
- # Optional: refresh the workspace-local agent skill
54
- onlyne export-skill
72
+ onlyne run
55
73
  ```
56
74
 
57
- `pi-onlyne` can manage a workspace-local daemon for the current Pi session. Prefer `/onlyne daemon start|stop|restart` or `/onlyne watch on` over shelling out `onlyne run` manually. Do not combine plugin-managed daemons with ad-hoc `nohup onlyne run`, `pkill -f 'onlyne run'`, or global launchd/systemd jobs for the same workspace.
58
-
59
- ## Typical workflow
75
+ A Pi session can start or connect to the daemon through `/onlyne daemon start`.
76
+
77
+ ## Configure Pi behavior
78
+
79
+ The extension reads `.pi/onlyne.json` from the current Pi project. The default configuration is:
80
+
81
+ ```json
82
+ {
83
+ "watch": { "autoStart": false },
84
+ "inbound": { "defaultMode": "auto-handle", "rules": [] },
85
+ "outbound": {
86
+ "defaultReplyMode": "guarded-explicit",
87
+ "guardedExplicit": {
88
+ "reminders": 2,
89
+ "noOutputFallbackText": "Onlyne/Pi error: no valid reply was produced."
90
+ },
91
+ "retry": { "attempts": 2, "concurrency": 8 }
92
+ }
93
+ }
94
+ ```
60
95
 
61
- 1. Initialize/configure Onlyne in your project.
62
- 2. Install this Pi extension.
63
- 3. Start the workspace daemon and watch from pi:
96
+ Enable automatic subscription when Pi starts:
64
97
 
65
- ```text
66
- /onlyne watch on
98
+ ```json
99
+ {
100
+ "watch": { "autoStart": true }
101
+ }
67
102
  ```
68
103
 
69
- When a normal user message arrives through Onlyne, pi receives it as a follow-up message. Onlyne control messages such as `/handshake` are consumed silently. The agent can then call `onlyne_reply`, or deliberately call `onlyne_mark_no_reply`.
104
+ The extension merges partial JSON with the defaults. `inbound.rules` accepts channel and optional conversation selectors with `auto-handle`, `queue-only`, or `muted` modes. `outbound.defaultReplyMode` accepts `guarded-explicit`, `explicit-only`, or `implicit-final`.
70
105
 
71
106
  ## Commands
72
107
 
@@ -78,9 +113,12 @@ When a normal user message arrives through Onlyne, pi receives it as a follow-up
78
113
  /onlyne watch on
79
114
  /onlyne watch off
80
115
  /onlyne config auto-start
116
+ /onlyne swarm on
117
+ /onlyne swarm off
118
+ /onlyne swarm status
81
119
  ```
82
120
 
83
- `/onlyne` supports argument completions for `status`, `daemon start|stop|restart`, `watch on`, `watch off`, and `config auto-start`.
121
+ `watch on` subscribes to the current workspace event stream. Incoming channel messages become Pi follow-ups. A normal inbound message receives `onlyne_reply`, and an intentional omission receives `onlyne_mark_no_reply`.
84
122
 
85
123
  ## Agent tools
86
124
 
@@ -93,8 +131,11 @@ onlyne_send({ channelId, text, rawText? })
93
131
  onlyne_broadcast({ targets, text, rawText? })
94
132
  onlyne_loopback({ text, rawText? })
95
133
  onlyne_mark_no_reply({ reason? })
134
+ onlyne_swarm_reply({ text, rawText? })
96
135
  ```
97
136
 
137
+ Messages use Markdown by default. `rawText: true` preserves literal text for scripts and protocol payloads.
138
+
98
139
  ### Send one message
99
140
 
100
141
  ```ts
@@ -104,57 +145,76 @@ onlyne_send({
104
145
  })
105
146
  ```
106
147
 
107
- ### Send literal text
108
-
109
- ```ts
110
- onlyne_send({
111
- channelId: "telegram",
112
- text: "# not a heading",
113
- rawText: true
114
- })
115
- ```
116
-
117
148
  ### Broadcast
118
149
 
119
150
  ```ts
120
151
  onlyne_broadcast({
121
- targets: [
122
- { channelId: "telegram" },
123
- { channelId: "feishu" }
124
- ],
125
- text: "# Release shipped\n\nVersion 0.3.4 is live."
152
+ targets: [{ channelId: "telegram" }, { channelId: "feishu" }],
153
+ text: "# Release shipped\n\nVersion 0.6.0 is live."
126
154
  })
127
155
  ```
128
156
 
129
157
  ### Loopback wake-up
130
158
 
131
- From any local script, inject an inbound message into the current Onlyne daemon:
159
+ A local script can wake the current Pi session through the daemon socket:
132
160
 
133
161
  ```bash
134
162
  onlyne client '{"id":"wake","op":"loopback","text":"background job finished","raw_text":true}'
135
- # or, with FIFO IO enabled by the daemon:
136
- printf 'background job finished\n' > .onlyne/channels/loopback/in
137
163
  ```
138
164
 
139
- Pi treats channel `loopback` as wake-up-only: it sends a follow-up to the session, but does not expect `onlyne_reply`.
165
+ The extension also supports `.onlyne/channels/loopback/in` when FIFO IO is enabled.
140
166
 
141
- ## Local state
167
+ ## Swarm mode
142
168
 
143
- This extension stores its own pi-side config at:
169
+ Swarm mode lets `onlyne-swarm` own task routing for a generated agent workspace. Enable it in `.onlyne/config.toml`:
144
170
 
145
- ```text
146
- .pi/onlyne.json
171
+ ```toml
172
+ [swarm]
173
+ enabled = true
147
174
  ```
148
175
 
149
- Onlyne itself stores workspace state under:
176
+ A swarm Pi session subscribes to loopback events, reports `swarm_ready`, accepts one task atomically, receives child callbacks through `followUp`, and completes with `onlyne_swarm_reply`. `onlyne_mark_no_reply` closes a task without an outbound result.
150
177
 
151
- ```text
152
- .onlyne/
178
+ For automatic startup in a generated workspace, add `.pi/onlyne.json`:
179
+
180
+ ```json
181
+ {
182
+ "watch": { "autoStart": true },
183
+ "outbound": {
184
+ "defaultReplyMode": "explicit-only",
185
+ "retry": { "attempts": 4, "concurrency": 8 }
186
+ }
187
+ }
188
+ ```
189
+
190
+ The swarm scheduler starts Pi with normal extension discovery. The configured retry extension remains available in swarm sessions. See the [onlyne-swarm README](https://github.com/dbydd/onlyne-swarm) for the graph template, scheduler commands, Orca requirements, and test runner.
191
+
192
+ ## Local state and security
193
+
194
+ Pi-side settings live at `.pi/onlyne.json`. Onlyne stores credentials, history, sockets, logs, and adapter state under `.onlyne/`. Keep `.onlyne/.env` private. Review package source before installing third-party extensions because Pi extensions run with the permissions of the Pi process.
195
+
196
+ ## Release notes
197
+
198
+ This checkout is version 0.6.0 with swarm support already merged into the `dev` branch. npm currently publishes 0.4.0 as the latest tag. Run `npm run check` before any release so the build and tests regenerate `dist/`. Publish with `npm publish` after reviewing the generated tarball.
199
+
200
+ ## Development
201
+
202
+ ```bash
203
+ npm install
204
+ npm run check
205
+ npm pack --dry-run
153
206
  ```
154
207
 
155
- That keeps each project isolated: different workspaces can run different Onlyne daemons, channels, histories, and policies.
208
+ `npm run check` compiles TypeScript and runs the Node test suite. The tests cover configuration, workspace discovery, daemon connection, swarm header parsing, and the atomic swarm task slot.
156
209
 
157
210
  ## Links
158
211
 
159
- - Onlyne main repository: https://github.com/dbydd/onlyne
160
- - pi-onlyne package: https://www.npmjs.com/package/pi-onlyne
212
+ - Onlyne: https://github.com/dbydd/onlyne
213
+ - Onlyne documentation: https://github.com/dbydd/onlyne/tree/dev/docs
214
+ - npm package: https://www.npmjs.com/package/pi-onlyne
215
+ - pi-onlyne source: https://github.com/dbydd/pi-onlyne
216
+ - onlyne-swarm: https://github.com/dbydd/onlyne-swarm
217
+
218
+ ## License
219
+
220
+ MIT
package/SPEC.md CHANGED
@@ -50,3 +50,25 @@ Stored in project `.pi/onlyne.json`:
50
50
  - Auth QR/secret editing TUI.
51
51
  - Schedules.
52
52
  - Target groups.
53
+
54
+ ## Swarm mode (v2)
55
+
56
+ - Switch: `/onlyne swarm on|off|status`. On/off persists to the workspace
57
+ `.onlyne/config.toml` `[swarm] enabled` flag and restarts watch. Status line
58
+ and `session_start` banner report `swarm` vs `ready`.
59
+ - When swarm is on, generic in/out auto-handling is disabled: the scheduler owns
60
+ input/output. Only loopback messages carrying a `---swarm` body header enter
61
+ the session, via the `followUp` task queue.
62
+ - Session model: one session carries exactly one task (atomic slot). A new
63
+ header claims the slot; headers with matching `reply_to`/`task_id` arrive as
64
+ followUp callbacks for the suspended parent; headers for other tasks while
65
+ busy are ignored with an `[onlyne-internal]` notice.
66
+ - Startup handshake: swarm watch sends the `swarm_ready` op
67
+ (`{workspace, terminal_handle}`) so the scheduler can match a pending task.
68
+ `ONLYNE_SWARM_TASK` env and `ORCA_TERMINAL_HANDLE`/`ONLYNE_TERMINAL_HANDLE`
69
+ provide fallback correlation.
70
+ - Tools: `onlyne_swarm_reply({text, rawText?})` writes the out message carrying
71
+ the task header (the scheduler's success signal) and ends the task slot.
72
+ `onlyne_mark_no_reply` additionally clears the swarm slot.
73
+ - Body protocol lives in `src/swarm.ts` (`parseSwarmHeader`,
74
+ `renderSwarmHeader`, `readSwarmEnabled`); covered by `test/swarm.test.mjs`.
package/dist/index.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { broadcast, connectDaemon, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe } from "./onlyne.js";
3
+ import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady } from "./onlyne.js";
4
4
  import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
5
5
  import { findWorkspace } from "./workspace.js";
6
- const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped" };
6
+ import { envTaskId, parseSwarmHeader, readSwarmEnabled, terminalHandle } from "./swarm.js";
7
+ import { SwarmSlot } from "./swarm-slot.js";
8
+ const swarmSlot = new SwarmSlot();
9
+ const slotTaskId = () => swarmSlot.task().taskId;
10
+ const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false, swarmPending: 0 };
7
11
  const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
8
12
  const currentConfig = () => loadConfig(state.cwd);
13
+ function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
9
14
  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; }
10
15
  function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
11
16
  void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
@@ -49,10 +54,33 @@ function scheduleReconnect(pi) {
49
54
  }
50
55
  }, 1000);
51
56
  }
57
+ /** Swarm-mode inbound path: single atomic task slot + followUp callbacks. */
58
+ function handleSwarmInbound(pi, text, eventSeq) {
59
+ const parsed = parseSwarmHeader(text);
60
+ if (!parsed)
61
+ return false;
62
+ if (state.socket && eventSeq !== undefined)
63
+ void consumeEvent(state.socket, eventSeq).catch(() => { });
64
+ // Slot transitions live in SwarmSlot (unit-tested); here we only mirror
65
+ // the outcome into session state (pending counter + task record).
66
+ const before = slotTaskId();
67
+ const outcome = swarmSlot.handle(pi, text);
68
+ const after = slotTaskId();
69
+ if (outcome === "claimed") {
70
+ const cur = swarmSlot.task();
71
+ state.swarmTask = { taskId: cur.taskId, from: cur.from, replyTo: cur.replyTo, attempt: cur.attempt, pendingReplies: 0 };
72
+ state.swarmPending = 0;
73
+ }
74
+ else if (outcome === "callback" && before !== undefined && before === after) {
75
+ state.swarmPending = Math.max(0, state.swarmPending - 1);
76
+ }
77
+ return true;
78
+ }
52
79
  async function startWatch(pi) {
53
80
  state.workspace = findWorkspace(state.cwd);
54
81
  if (!state.workspace)
55
82
  throw new Error("current workspace has no .onlyne configuration");
83
+ refreshSwarmFlag();
56
84
  if (state.reconnectTimer)
57
85
  clearTimeout(state.reconnectTimer);
58
86
  state.reconnectTimer = undefined;
@@ -61,6 +89,32 @@ async function startWatch(pi) {
61
89
  const conn = await connectDaemon(state.workspace);
62
90
  state.owner = conn.owner;
63
91
  state.child = conn.process;
92
+ if (state.swarm) {
93
+ // Swarm mode: the scheduler owns in/out. Subscribe without auto-handling generic
94
+ // traffic; only swarm headers enter the session, via the followUp task queue.
95
+ // Report readiness so the scheduler can match a pending task (fork+exec: any
96
+ // clean ready session on this workspace path may take it).
97
+ const ws = state.workspace;
98
+ const socket = subscribe(ws.socketPath, (line) => {
99
+ if (!line?.event)
100
+ return;
101
+ if (line.type !== "inbound_message")
102
+ return;
103
+ const inbound = inboundText(line);
104
+ if (!inbound || inbound.channelId !== "loopback")
105
+ return;
106
+ handleSwarmInbound(pi, inbound.text, line.event_seq);
107
+ }, () => { if (state.socket === socket)
108
+ scheduleReconnect(pi); });
109
+ state.socket = socket;
110
+ state.watching = true;
111
+ const task = envTaskId();
112
+ try {
113
+ await swarmReady(ws.socketPath, ws.root, terminalHandle());
114
+ }
115
+ catch { /* scheduler may read env fallback */ }
116
+ return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
117
+ }
64
118
  const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
65
119
  return; const inbound = inboundText(line); if (!inbound)
66
120
  return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
@@ -82,9 +136,9 @@ async function startWatch(pi) {
82
136
  return `watching ${state.workspace.root} (${state.owner})`;
83
137
  }
84
138
  function stopWatch() { if (state.reconnectTimer)
85
- 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"; return "watch stopped"; }
139
+ 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; state.swarmPending = 0; swarmSlot.clear(); return "watch stopped"; }
86
140
  async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
87
- throw new Error("current workspace has no .onlyne configuration"); 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}`; }
141
+ 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}`; }
88
142
  async function stopDaemon() { if (!state.workspace)
89
143
  state.workspace = findWorkspace(state.cwd); if (!state.workspace)
90
144
  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}`; }
@@ -95,11 +149,28 @@ async function reply(text) { if (!state.workspace)
95
149
  inbound.replied = true;
96
150
  clearReminder();
97
151
  } return res; }
152
+ /** Swarm reply: writes the out message carrying the task header (success signal), ends the task slot. */
153
+ async function swarmReply(text, rawText = false) {
154
+ if (!state.workspace)
155
+ throw new Error("onlyne workspace not found");
156
+ const task = state.swarmTask;
157
+ if (!task)
158
+ throw new Error("no active swarm task");
159
+ const { renderSwarmHeader } = await import("./swarm.js");
160
+ const wire = renderSwarmHeader({ task_id: task.taskId, from: ".", reply_to: task.replyTo, attempt: task.attempt }, "", text);
161
+ const res = await sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, currentConfig().outbound.retry.attempts, rawText);
162
+ if (res.ok) {
163
+ state.swarmTask = undefined;
164
+ state.swarmPending = 0;
165
+ swarmSlot.clear();
166
+ }
167
+ return { ...res, taskId: task.taskId };
168
+ }
98
169
  export default function onlyne(pi) {
99
170
  pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
100
171
  await stopDaemon().catch(() => { });
101
172
  else
102
- stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; ctx.ui.setStatus("onlyne", state.workspace ? "onlyne: ready" : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
173
+ stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; state.swarmTask = undefined; state.swarmPending = 0; swarmSlot.clear(); refreshSwarmFlag(); ctx.ui.setStatus("onlyne", state.workspace ? (state.swarm ? "onlyne: swarm" : "onlyne: ready") : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
103
174
  try {
104
175
  ctx.ui.notify(await startWatch(pi), "info");
105
176
  }
@@ -120,7 +191,7 @@ export default function onlyne(pi) {
120
191
  pi.registerCommand("onlyne", {
121
192
  description: "Onlyne watch/status/config commands",
122
193
  getArgumentCompletions: (prefix) => {
123
- const commands = ["status", "watch on", "watch off", "daemon start", "daemon stop", "daemon restart", "config auto-start"];
194
+ const commands = ["status", "watch on", "watch off", "daemon start", "daemon stop", "daemon restart", "config auto-start", "swarm on", "swarm off", "swarm status"];
124
195
  const p = prefix.trimStart();
125
196
  const filtered = commands.filter((c) => c.startsWith(p));
126
197
  return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
@@ -139,7 +210,12 @@ export default function onlyne(pi) {
139
210
  else if (cmd === "daemon" && sub === "restart")
140
211
  ctx.ui.notify(await restartDaemon(), "info");
141
212
  else if (cmd === "status")
142
- ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; workspace=${state.workspace?.root ?? "none"}`, "info");
213
+ ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; swarm=${state.swarm ? "on" : "off"}; workspace=${state.workspace?.root ?? "none"}`, "info");
214
+ else if (cmd === "swarm" && sub === "status")
215
+ ctx.ui.notify(`swarm=${state.swarm ? "on" : "off"}; task=${state.swarmTask?.taskId ?? "none"}; pending=${state.swarmPending}`, "info");
216
+ else if (cmd === "swarm" && (sub === "on" || sub === "off")) {
217
+ ctx.ui.notify(await setSwarm(pi, sub === "on"), "info");
218
+ }
143
219
  else if (cmd === "config" && sub === "auto-start") {
144
220
  const cfg = currentConfig();
145
221
  cfg.watch.autoStart = !cfg.watch.autoStart;
@@ -147,7 +223,7 @@ export default function onlyne(pi) {
147
223
  ctx.ui.notify(`autoStart=${cfg.watch.autoStart}`, "info");
148
224
  }
149
225
  else
150
- ctx.ui.notify("usage: /onlyne status | watch on|off | daemon start|stop|restart | config auto-start", "info");
226
+ ctx.ui.notify("usage: /onlyne status | watch on|off | daemon start|stop|restart | swarm on|off|status | config auto-start", "info");
151
227
  }
152
228
  catch (e) {
153
229
  ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
@@ -158,6 +234,7 @@ export default function onlyne(pi) {
158
234
  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 }); } }));
159
235
  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 }); } }));
160
236
  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))); } }));
237
+ pi.registerTool(defineTool({ name: "onlyne_swarm_reply", label: "Onlyne swarm reply", description: "Swarm mode: write the task out message carrying the task header (the success signal) and end the task slot. Use for the active swarm task only.", parameters: Type.Object({ text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmReply(params.text, params.rawText ?? false))); } }));
161
238
  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)
162
239
  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); } }));
163
240
  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)
@@ -167,5 +244,44 @@ export default function onlyne(pi) {
167
244
  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) {
168
245
  state.currentInbound.noReply = true;
169
246
  clearReminder();
247
+ } if (state.swarmTask) {
248
+ state.swarmTask = undefined;
249
+ state.swarmPending = 0;
250
+ swarmSlot.clear();
170
251
  } return textResult("marked no reply", params); } }));
171
252
  }
253
+ /** Toggle swarm mode: persists to .onlyne/config.toml [swarm] enabled, restarts watch. */
254
+ async function setSwarm(pi, enabled) {
255
+ if (!state.workspace)
256
+ throw new Error("onlyne workspace not found");
257
+ const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
258
+ const { join } = await import("node:path");
259
+ const cfgPath = join(state.workspace.onlyneDir, "config.toml");
260
+ let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
261
+ if (text.includes("[swarm]")) {
262
+ const lines = text.split("\n");
263
+ let inSwarm = false;
264
+ text = lines.map((line) => {
265
+ const t = line.trim();
266
+ if (t.startsWith("[")) {
267
+ inSwarm = t === "[swarm]";
268
+ return line;
269
+ }
270
+ if (inSwarm && t.startsWith("enabled"))
271
+ return `enabled = ${enabled}`;
272
+ return line;
273
+ }).join("\n");
274
+ }
275
+ else {
276
+ if (text && !text.endsWith("\n"))
277
+ text += "\n";
278
+ text += `\n[swarm]\nenabled = ${enabled}\n`;
279
+ }
280
+ writeFileSync(cfgPath, text);
281
+ refreshSwarmFlag();
282
+ if (state.watching) {
283
+ stopWatch();
284
+ return `${enabled ? "swarm on; " : "swarm off; "}${await startWatch(pi)}`;
285
+ }
286
+ return `swarm ${enabled ? "on" : "off"} (watch not running)`;
287
+ }
package/dist/onlyne.d.ts CHANGED
@@ -10,6 +10,9 @@ export interface OnlyneRequest {
10
10
  format?: "plain" | "markdown";
11
11
  raw_text?: boolean;
12
12
  limit?: number;
13
+ priority?: number;
14
+ consume_timeout_ms?: number;
15
+ event_seq?: number;
13
16
  }
14
17
  export interface SendTarget {
15
18
  channelId: string;
@@ -19,7 +22,10 @@ export interface SendResult extends SendTarget {
19
22
  error?: string;
20
23
  }
21
24
  export declare function request(socketPath: string, req: OnlyneRequest): Promise<any>;
22
- export declare function subscribe(socketPath: string, onLine: (line: any) => void, onDisconnect?: () => void): Socket;
25
+ export declare function subscribe(socketPath: string, onLine: (line: any) => void, onDisconnect?: () => void, opts?: {
26
+ priority?: number;
27
+ consumeTimeoutMs?: number;
28
+ }): Socket;
23
29
  export declare function waitForSocket(socketPath: string, timeoutMs?: number): Promise<void>;
24
30
  export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean): Promise<{
25
31
  owner: "external" | "extension";
@@ -27,6 +33,8 @@ export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean):
27
33
  }>;
28
34
  export declare function shutdownDaemon(ws: Workspace, child?: ChildProcess): Promise<void>;
29
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 consumeEvent(socket: Socket, eventSeq: number): Promise<void>;
30
38
  export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
31
39
  export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
32
40
  export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean): Promise<SendResult>;
package/dist/onlyne.js CHANGED
@@ -18,7 +18,7 @@ export function request(socketPath, req) {
18
18
  } });
19
19
  });
20
20
  }
21
- export function subscribe(socketPath, onLine, onDisconnect) {
21
+ export function subscribe(socketPath, onLine, onDisconnect, opts) {
22
22
  const socket = createConnection(socketPath);
23
23
  let buf = "";
24
24
  let closed = false;
@@ -28,7 +28,7 @@ export function subscribe(socketPath, onLine, onDisconnect) {
28
28
  socket.on("error", disconnect);
29
29
  socket.on("close", disconnect);
30
30
  socket.on("connect", () => { if (!socket.destroyed)
31
- socket.write('{"id":"sub","op":"subscribe_events"}\n', () => { }); });
31
+ socket.write(`${JSON.stringify({ id: "sub", op: "subscribe_events", ...(opts?.priority !== undefined ? { priority: opts.priority } : {}), ...(opts?.consumeTimeoutMs !== undefined ? { consume_timeout_ms: opts.consumeTimeoutMs } : {}) })}\n`, () => { }); });
32
32
  socket.on("data", (chunk) => { buf += chunk; for (;;) {
33
33
  const idx = buf.indexOf("\n");
34
34
  if (idx < 0)
@@ -109,6 +109,17 @@ export function stopProcess(child) { if (!child || child.killed)
109
109
  child.kill("SIGTERM");
110
110
  }
111
111
  catch { /* ignore */ } }
112
+ export async function swarmReady(socketPath, workspace, terminalHandle) {
113
+ return request(socketPath, { id: `swarm-ready-${Date.now()}`, op: "swarm_ready", text: JSON.stringify({ workspace, terminal_handle: terminalHandle }) });
114
+ }
115
+ export async function consumeEvent(socket, eventSeq) {
116
+ return new Promise((resolve) => { try {
117
+ socket.write(`${JSON.stringify({ id: `consume-${Date.now()}`, op: "consume", event_seq: eventSeq })}\n`, () => resolve());
118
+ }
119
+ catch {
120
+ resolve();
121
+ } });
122
+ }
112
123
  export async function loopback(socketPath, text, rawText = true) {
113
124
  return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", text, raw_text: rawText });
114
125
  }
@@ -0,0 +1,36 @@
1
+ export type SwarmHandleResult = "claimed" | "callback" | "busy-ignored" | "not-swarm";
2
+ export interface SwarmSlotMessage {
3
+ text: string;
4
+ deliverAs: "followUp";
5
+ }
6
+ export interface SwarmPi {
7
+ sendUserMessage: (text: string, opts: {
8
+ deliverAs: "followUp";
9
+ }) => void;
10
+ }
11
+ /**
12
+ * Single atomic task slot for swarm mode, extracted for unit testing.
13
+ * index.ts delegates its state transitions here; the only coupling is the
14
+ * followUp injection callback.
15
+ */
16
+ export declare class SwarmSlot {
17
+ private taskId?;
18
+ private from;
19
+ private replyTo;
20
+ private attempt;
21
+ handle(pi: SwarmPi, text: string): SwarmHandleResult;
22
+ task(): {
23
+ taskId?: string;
24
+ from: string;
25
+ replyTo: string;
26
+ attempt: number;
27
+ };
28
+ taskIdOf(): string | undefined;
29
+ clear(): void;
30
+ }
31
+ /** Test seam: fresh slot without touching module-global pi-onlyne state. */
32
+ export declare function __swarmSlotForTest(): {
33
+ handle: (pi: SwarmPi, text: string) => SwarmHandleResult;
34
+ taskId: () => string | undefined;
35
+ clear: () => void;
36
+ };
@@ -0,0 +1,53 @@
1
+ import { parseSwarmHeader } from "./swarm.js";
2
+ /**
3
+ * Single atomic task slot for swarm mode, extracted for unit testing.
4
+ * index.ts delegates its state transitions here; the only coupling is the
5
+ * followUp injection callback.
6
+ */
7
+ export class SwarmSlot {
8
+ taskId;
9
+ from = ".";
10
+ replyTo = "";
11
+ attempt = 1;
12
+ handle(pi, text) {
13
+ const parsed = parseSwarmHeader(text);
14
+ if (!parsed)
15
+ return "not-swarm";
16
+ const { header, payload } = parsed;
17
+ if (!this.taskId) {
18
+ this.taskId = header.task_id;
19
+ this.from = header.from;
20
+ this.replyTo = header.reply_to;
21
+ this.attempt = header.attempt;
22
+ pi.sendUserMessage(`Onlyne swarm task ${header.task_id} (from ${header.from}):\n\n${payload}\n\nWork this task atomically. Child task callbacks arrive as followUp messages. Finish with onlyne_swarm_reply carrying the reply Markdown, or onlyne_mark_no_reply to end without output.`, { deliverAs: "followUp" });
23
+ return "claimed";
24
+ }
25
+ if (header.reply_to === this.taskId || header.task_id === this.taskId) {
26
+ pi.sendUserMessage(`Onlyne swarm callback for ${this.taskId} (from ${header.from}):\n\n${payload}`, { deliverAs: "followUp" });
27
+ return "callback";
28
+ }
29
+ pi.sendUserMessage(`[onlyne-internal] swarm task ${header.task_id} ignored: session busy with ${this.taskId}.`, { deliverAs: "followUp" });
30
+ return "busy-ignored";
31
+ }
32
+ task() {
33
+ return { taskId: this.taskId, from: this.from, replyTo: this.replyTo, attempt: this.attempt };
34
+ }
35
+ taskIdOf() {
36
+ return this.taskId;
37
+ }
38
+ clear() {
39
+ this.taskId = undefined;
40
+ this.from = ".";
41
+ this.replyTo = "";
42
+ this.attempt = 1;
43
+ }
44
+ }
45
+ /** Test seam: fresh slot without touching module-global pi-onlyne state. */
46
+ export function __swarmSlotForTest() {
47
+ const slot = new SwarmSlot();
48
+ return {
49
+ handle: (pi, text) => slot.handle(pi, text),
50
+ taskId: () => slot.taskIdOf(),
51
+ clear: () => slot.clear(),
52
+ };
53
+ }
@@ -0,0 +1,21 @@
1
+ /** Swarm header carried in the message body (upper-layer protocol, see PROTOCOL.md). */
2
+ export interface SwarmHeader {
3
+ task_id: string;
4
+ from: string;
5
+ reply_to: string;
6
+ attempt: number;
7
+ }
8
+ export interface SwarmMessage {
9
+ header: SwarmHeader;
10
+ payload: string;
11
+ }
12
+ /** Parse a `---swarm` body header. Returns null for non-swarm messages. */
13
+ export declare function parseSwarmHeader(text: string): SwarmMessage | null;
14
+ /** Render a swarm body header in front of a Markdown payload. */
15
+ export declare function renderSwarmHeader(header: SwarmHeader, role: string, payloadMarkdown: string): string;
16
+ /** Read the `[swarm] enabled` flag from the workspace .onlyne/config.toml. */
17
+ export declare function readSwarmEnabled(onlyneDir: string): boolean;
18
+ /** Terminal handle for swarm_ready correlation (injected by the scheduler). */
19
+ export declare function terminalHandle(): string;
20
+ /** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
21
+ export declare function envTaskId(): string;
package/dist/swarm.js ADDED
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ /** Parse a `---swarm` body header. Returns null for non-swarm messages. */
5
+ export function parseSwarmHeader(text) {
6
+ const rest = text.startsWith("---swarm\n") ? text.slice("---swarm\n".length) : null;
7
+ if (rest === null)
8
+ return null;
9
+ let end = rest.indexOf("\n---\n");
10
+ let payload;
11
+ let headRaw;
12
+ if (end >= 0) {
13
+ headRaw = rest.slice(0, end);
14
+ payload = rest.slice(end + "\n---\n".length);
15
+ }
16
+ else if (rest.endsWith("\n---")) {
17
+ headRaw = rest.slice(0, rest.length - "\n---".length);
18
+ payload = "";
19
+ }
20
+ else {
21
+ const alt = rest.indexOf("\n---");
22
+ if (alt < 0)
23
+ return null;
24
+ headRaw = rest.slice(0, alt);
25
+ payload = rest.slice(alt + "\n---".length).replace(/^\n/, "");
26
+ }
27
+ let task_id;
28
+ let from = ".";
29
+ let reply_to = "";
30
+ let attempt = 1;
31
+ for (const line of headRaw.split("\n")) {
32
+ const t = line.trim();
33
+ if (!t || t.startsWith("#"))
34
+ continue;
35
+ const i = t.indexOf(":");
36
+ if (i < 0)
37
+ continue;
38
+ const k = t.slice(0, i).trim();
39
+ const v = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
40
+ if (k === "task_id")
41
+ task_id = v;
42
+ else if (k === "from")
43
+ from = v || ".";
44
+ else if (k === "reply_to")
45
+ reply_to = v;
46
+ else if (k === "attempt")
47
+ attempt = Number.parseInt(v, 10) || 1;
48
+ }
49
+ if (!task_id || !UUID_RE.test(task_id.trim()))
50
+ return null;
51
+ return { header: { task_id: task_id.trim(), from, reply_to, attempt }, payload };
52
+ }
53
+ /** Render a swarm body header in front of a Markdown payload. */
54
+ export function renderSwarmHeader(header, role, payloadMarkdown) {
55
+ let s = `---swarm\ntask_id: ${header.task_id}\nfrom: ${header.from}\nreply_to: ${header.reply_to}\nattempt: ${header.attempt}\n---\n`;
56
+ if (role)
57
+ s += `## role: ${role}\n\n${role}\n`;
58
+ s += payloadMarkdown;
59
+ if (!s.endsWith("\n"))
60
+ s += "\n";
61
+ return s;
62
+ }
63
+ /** Read the `[swarm] enabled` flag from the workspace .onlyne/config.toml. */
64
+ export function readSwarmEnabled(onlyneDir) {
65
+ const path = join(onlyneDir, "config.toml");
66
+ if (!existsSync(path))
67
+ return false;
68
+ try {
69
+ const text = readFileSync(path, "utf8");
70
+ let inSwarm = false;
71
+ for (const line of text.split("\n")) {
72
+ const t = line.trim();
73
+ if (t.startsWith("[")) {
74
+ inSwarm = t === "[swarm]";
75
+ continue;
76
+ }
77
+ if (inSwarm && t.startsWith("enabled")) {
78
+ return /=?\s*true\b/.test(t);
79
+ }
80
+ }
81
+ }
82
+ catch { /* unreadable -> disabled */ }
83
+ return false;
84
+ }
85
+ /** Terminal handle for swarm_ready correlation (injected by the scheduler). */
86
+ export function terminalHandle() {
87
+ return process.env.ORCA_TERMINAL_HANDLE || process.env.ONLYNE_TERMINAL_HANDLE || "";
88
+ }
89
+ /** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
90
+ export function envTaskId() {
91
+ return process.env.ONLYNE_SWARM_TASK || "";
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pi extension tools for sending messages through Onlyne.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",