pi-onlyne 0.4.0 → 0.7.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 +146 -69
- package/SPEC.md +47 -1
- package/dist/index.js +215 -9
- package/dist/onlyne.d.ts +9 -1
- package/dist/onlyne.js +13 -2
- package/dist/swarm-slot.d.ts +47 -0
- package/dist/swarm-slot.js +56 -0
- package/dist/swarm.d.ts +21 -0
- package/dist/swarm.js +99 -0
- package/dist/workspace.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,72 +1,107 @@
|
|
|
1
1
|
# pi-onlyne
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
## Runtime requirements
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
+
## Install
|
|
12
17
|
|
|
13
|
-
|
|
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
|
|
18
|
+
Install the published Pi package:
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:pi-onlyne
|
|
22
|
+
```
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
Run it for one Pi process:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pi -e npm:pi-onlyne
|
|
28
|
+
```
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
Install the package from a local checkout during development:
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
```bash
|
|
33
|
+
cd path/to/pi-onlyne
|
|
34
|
+
npm install
|
|
35
|
+
npm run check
|
|
36
|
+
pi install .
|
|
37
|
+
```
|
|
25
38
|
|
|
26
|
-
|
|
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
|
-
|
|
41
|
+
## Prepare an Onlyne workspace
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
Run these commands from the project that should receive the messages:
|
|
38
44
|
|
|
39
45
|
```bash
|
|
40
|
-
|
|
46
|
+
cargo install onlyne
|
|
47
|
+
onlyne init
|
|
48
|
+
onlyne export-skill
|
|
41
49
|
```
|
|
42
50
|
|
|
43
|
-
|
|
51
|
+
Configure a channel in `.onlyne/config.toml` and place secrets in `.onlyne/.env`. Examples:
|
|
44
52
|
|
|
45
|
-
```
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
# Optional: refresh the workspace-local agent skill
|
|
54
|
-
onlyne export-skill
|
|
72
|
+
onlyne run
|
|
55
73
|
```
|
|
56
74
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
##
|
|
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
|
-
|
|
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
|
-
```
|
|
66
|
-
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"watch": { "autoStart": true }
|
|
101
|
+
}
|
|
67
102
|
```
|
|
68
103
|
|
|
69
|
-
|
|
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,12 +113,17 @@ 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
|
-
|
|
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
|
|
|
125
|
+
Normal mode:
|
|
126
|
+
|
|
87
127
|
```text
|
|
88
128
|
onlyne_daemon_start()
|
|
89
129
|
onlyne_daemon_stop()
|
|
@@ -95,22 +135,30 @@ onlyne_loopback({ text, rawText? })
|
|
|
95
135
|
onlyne_mark_no_reply({ reason? })
|
|
96
136
|
```
|
|
97
137
|
|
|
98
|
-
|
|
138
|
+
Swarm mode (`[swarm] enabled`):
|
|
99
139
|
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
})
|
|
140
|
+
```text
|
|
141
|
+
onlyne_daemon_start()
|
|
142
|
+
onlyne_daemon_stop()
|
|
143
|
+
onlyne_daemon_restart()
|
|
144
|
+
swarm_complete({ text })
|
|
145
|
+
swarm_quit({ reason? })
|
|
146
|
+
swarm_send({ to, text })
|
|
147
|
+
swarm_status()
|
|
105
148
|
```
|
|
106
149
|
|
|
107
|
-
|
|
150
|
+
One session sees one toolset, chosen at session start. Generic send/reply
|
|
151
|
+
tools stay out of the swarm surface so unheaded writes cannot pollute the
|
|
152
|
+
protocol.
|
|
153
|
+
|
|
154
|
+
Messages use Markdown by default. `rawText: true` preserves literal text for scripts and protocol payloads.
|
|
155
|
+
|
|
156
|
+
### Send one message
|
|
108
157
|
|
|
109
158
|
```ts
|
|
110
159
|
onlyne_send({
|
|
111
160
|
channelId: "telegram",
|
|
112
|
-
text: "#
|
|
113
|
-
rawText: true
|
|
161
|
+
text: "# Build report\n\nAll checks passed."
|
|
114
162
|
})
|
|
115
163
|
```
|
|
116
164
|
|
|
@@ -118,43 +166,72 @@ onlyne_send({
|
|
|
118
166
|
|
|
119
167
|
```ts
|
|
120
168
|
onlyne_broadcast({
|
|
121
|
-
targets: [
|
|
122
|
-
|
|
123
|
-
{ channelId: "feishu" }
|
|
124
|
-
],
|
|
125
|
-
text: "# Release shipped\n\nVersion 0.3.4 is live."
|
|
169
|
+
targets: [{ channelId: "telegram" }, { channelId: "feishu" }],
|
|
170
|
+
text: "# Release shipped\n\nVersion 0.6.0 is live."
|
|
126
171
|
})
|
|
127
172
|
```
|
|
128
173
|
|
|
129
174
|
### Loopback wake-up
|
|
130
175
|
|
|
131
|
-
|
|
176
|
+
A local script can wake the current Pi session through the daemon socket:
|
|
132
177
|
|
|
133
178
|
```bash
|
|
134
179
|
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
180
|
```
|
|
138
181
|
|
|
139
|
-
|
|
182
|
+
The extension also supports `.onlyne/channels/loopback/in` when FIFO IO is enabled.
|
|
140
183
|
|
|
141
|
-
##
|
|
184
|
+
## Swarm mode
|
|
142
185
|
|
|
143
|
-
|
|
186
|
+
Swarm mode lets `onlyne-swarm` own task routing for a generated agent workspace. Enable it in `.onlyne/config.toml`:
|
|
144
187
|
|
|
145
|
-
```
|
|
146
|
-
|
|
188
|
+
```toml
|
|
189
|
+
[swarm]
|
|
190
|
+
enabled = true
|
|
147
191
|
```
|
|
148
192
|
|
|
149
|
-
|
|
193
|
+
A swarm Pi session subscribes to loopback events, reports `swarm_ready`, accepts one hop atomically, spawns continuations with `swarm_send` (fire-and-forget), and exits with `swarm_complete` (done signal) or `swarm_quit` (silent, scheduler records failed). Downstream results travel through files and the ledger; nothing waits.
|
|
150
194
|
|
|
151
|
-
|
|
152
|
-
|
|
195
|
+
For automatic startup in a generated workspace, add `.pi/onlyne.json`:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"watch": { "autoStart": true },
|
|
200
|
+
"outbound": {
|
|
201
|
+
"defaultReplyMode": "explicit-only",
|
|
202
|
+
"retry": { "attempts": 4, "concurrency": 8 }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
153
205
|
```
|
|
154
206
|
|
|
155
|
-
|
|
207
|
+
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.
|
|
208
|
+
|
|
209
|
+
## Local state and security
|
|
210
|
+
|
|
211
|
+
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.
|
|
212
|
+
|
|
213
|
+
## Release notes
|
|
214
|
+
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
## Development
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
npm install
|
|
221
|
+
npm run check
|
|
222
|
+
npm pack --dry-run
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
`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
226
|
|
|
157
227
|
## Links
|
|
158
228
|
|
|
159
|
-
- Onlyne
|
|
160
|
-
-
|
|
229
|
+
- Onlyne: https://github.com/dbydd/onlyne
|
|
230
|
+
- Onlyne documentation: https://github.com/dbydd/onlyne/tree/dev/docs
|
|
231
|
+
- npm package: https://www.npmjs.com/package/pi-onlyne
|
|
232
|
+
- pi-onlyne source: https://github.com/dbydd/pi-onlyne
|
|
233
|
+
- onlyne-swarm: https://github.com/dbydd/onlyne-swarm
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT
|
package/SPEC.md
CHANGED
|
@@ -8,7 +8,7 @@ Pi extension for Onlyne. Onlyne remains a workspace-local IM broker; this extens
|
|
|
8
8
|
|
|
9
9
|
- Watch is configurable; default manual.
|
|
10
10
|
- `/onlyne` provides argument completions for its supported subcommands, including daemon lifecycle commands.
|
|
11
|
-
- `watch on` connects to the workspace-local `.onlyne/run/
|
|
11
|
+
- `watch on` connects to the workspace-local `.onlyne/run/s`; if unavailable, it starts a Pi-owned workspace daemon.
|
|
12
12
|
- `/onlyne daemon start|stop|restart` is the preferred lifecycle surface. Agents must not use ad-hoc `nohup onlyne run`, `pkill -f 'onlyne run'`, or global launchd/systemd jobs when pi-onlyne owns the daemon.
|
|
13
13
|
- Inbound events come from Onlyne `subscribe_events`; no polling.
|
|
14
14
|
- Inbound mode is rule-based: `auto-handle`, `queue-only`, or `muted`.
|
|
@@ -38,11 +38,25 @@ Stored in project `.pi/onlyne.json`:
|
|
|
38
38
|
|
|
39
39
|
## Tools
|
|
40
40
|
|
|
41
|
+
Normal mode (default):
|
|
42
|
+
|
|
41
43
|
- `onlyne_reply({ text })`
|
|
42
44
|
- `onlyne_send({ channelId, text, rawText? })`
|
|
43
45
|
- `onlyne_broadcast({ targets, text, rawText? })`
|
|
44
46
|
- `onlyne_loopback({ text, rawText? })`
|
|
45
47
|
- `onlyne_mark_no_reply({ reason? })`
|
|
48
|
+
- `onlyne_daemon_start/stop/restart`
|
|
49
|
+
|
|
50
|
+
Swarm mode (`[swarm] enabled`, see below):
|
|
51
|
+
|
|
52
|
+
- `swarm_complete({ text })`
|
|
53
|
+
- `swarm_quit({ reason? })`
|
|
54
|
+
- `swarm_send({ to, text })`
|
|
55
|
+
- `swarm_status()`
|
|
56
|
+
- `onlyne_daemon_start/stop/restart`
|
|
57
|
+
|
|
58
|
+
One session sees one toolset. The surface is chosen at `session_start` from
|
|
59
|
+
`[swarm]` and applied with `setActiveTools` when the Pi API exists.
|
|
46
60
|
|
|
47
61
|
## Deferred
|
|
48
62
|
|
|
@@ -50,3 +64,35 @@ Stored in project `.pi/onlyne.json`:
|
|
|
50
64
|
- Auth QR/secret editing TUI.
|
|
51
65
|
- Schedules.
|
|
52
66
|
- Target groups.
|
|
67
|
+
|
|
68
|
+
## Swarm mode (v2, amendment-1)
|
|
69
|
+
|
|
70
|
+
- Switch: `/onlyne swarm on|off|status`. On/off persists to the workspace
|
|
71
|
+
`.onlyne/config.toml` `[swarm] enabled` flag and restarts watch. Status line
|
|
72
|
+
and `session_start` banner report `swarm` vs `ready`.
|
|
73
|
+
- When swarm is on, generic in/out auto-handling is disabled: the scheduler owns
|
|
74
|
+
input/output. Only loopback messages carrying a `---swarm` body header enter
|
|
75
|
+
the session, via the `followUp` task queue.
|
|
76
|
+
- Session model: one session carries exactly one hop, no exceptions. A new
|
|
77
|
+
header claims the slot; any further header while claimed is ignored
|
|
78
|
+
(the scheduler always opens a new session, so this guard never fires
|
|
79
|
+
on the normal path). No waiting, no callbacks, no parent bookkeeping.
|
|
80
|
+
- Startup handshake: swarm watch sends the `swarm_ready` op
|
|
81
|
+
(`{workspace, terminal_handle}`) so the scheduler can match a pending task.
|
|
82
|
+
`ONLYNE_SWARM_TASK` env and `ORCA_TERMINAL_HANDLE`/`ONLYNE_TERMINAL_HANDLE`
|
|
83
|
+
provide fallback correlation.
|
|
84
|
+
- Tools: `swarm_complete({text})` writes the out message carrying this hop's
|
|
85
|
+
header (the scheduler's done signal). `swarm_quit({reason?})` exits silently
|
|
86
|
+
(scheduler records failed). `swarm_send({to, text})` spawns a downstream
|
|
87
|
+
task with `transfer_send_to` set to the current task and returns the child
|
|
88
|
+
id without waiting. `swarm_status()` reports the current task and spawned
|
|
89
|
+
ids. Daemon lifecycle tools stay available in both modes.
|
|
90
|
+
- Generic send/reply tools are not registered in the swarm surface: unheaded
|
|
91
|
+
or misheaded writes would pollute the protocol. All swarm IO goes through
|
|
92
|
+
the `swarm_*` tools, whose headers are constructed inside the plugin
|
|
93
|
+
(`renderSwarmHeader`).
|
|
94
|
+
- Exit guard: at `agent_end` with an unfinished hop, one followUp reminder
|
|
95
|
+
fires; a second quiet window auto-runs `swarm_quit` (failed ledger row).
|
|
96
|
+
- Body protocol lives in `src/swarm.ts` (`parseSwarmHeader`,
|
|
97
|
+
`renderSwarmHeader`, `readSwarmEnabled`); covered by `test/swarm.test.mjs`.
|
|
98
|
+
Old `reply_to` headers parse as ordinary (non-swarm) messages.
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
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
|
-
|
|
6
|
+
import { envTaskId, parseSwarmHeader, readSwarmEnabled, terminalHandle } from "./swarm.js";
|
|
7
|
+
import { SwarmSlot } from "./swarm-slot.js";
|
|
8
|
+
const swarmSlot = new SwarmSlot();
|
|
9
|
+
const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
|
|
7
10
|
const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
|
|
8
11
|
const currentConfig = () => loadConfig(state.cwd);
|
|
12
|
+
function refreshSwarmFlag() { state.swarm = state.workspace ? readSwarmEnabled(state.workspace.onlyneDir) : false; }
|
|
9
13
|
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
14
|
function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
|
|
11
15
|
void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
|
|
@@ -49,10 +53,28 @@ function scheduleReconnect(pi) {
|
|
|
49
53
|
}
|
|
50
54
|
}, 1000);
|
|
51
55
|
}
|
|
56
|
+
/** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
|
|
57
|
+
function handleSwarmInbound(pi, text, eventSeq) {
|
|
58
|
+
const parsed = parseSwarmHeader(text);
|
|
59
|
+
if (!parsed)
|
|
60
|
+
return false;
|
|
61
|
+
if (state.socket && eventSeq !== undefined)
|
|
62
|
+
void consumeEvent(state.socket, eventSeq).catch(() => { });
|
|
63
|
+
// Slot transitions live in SwarmSlot (unit-tested); here we only mirror
|
|
64
|
+
// the claimed task into session state. A claimed session never accepts
|
|
65
|
+
// another task; downstream work spawns new tasks via swarm_send.
|
|
66
|
+
const outcome = swarmSlot.handle(pi, text);
|
|
67
|
+
if (outcome === "claimed") {
|
|
68
|
+
const cur = swarmSlot.task();
|
|
69
|
+
state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
|
|
70
|
+
}
|
|
71
|
+
return outcome === "claimed";
|
|
72
|
+
}
|
|
52
73
|
async function startWatch(pi) {
|
|
53
74
|
state.workspace = findWorkspace(state.cwd);
|
|
54
75
|
if (!state.workspace)
|
|
55
76
|
throw new Error("current workspace has no .onlyne configuration");
|
|
77
|
+
refreshSwarmFlag();
|
|
56
78
|
if (state.reconnectTimer)
|
|
57
79
|
clearTimeout(state.reconnectTimer);
|
|
58
80
|
state.reconnectTimer = undefined;
|
|
@@ -61,6 +83,32 @@ async function startWatch(pi) {
|
|
|
61
83
|
const conn = await connectDaemon(state.workspace);
|
|
62
84
|
state.owner = conn.owner;
|
|
63
85
|
state.child = conn.process;
|
|
86
|
+
if (state.swarm) {
|
|
87
|
+
// Swarm mode: the scheduler owns in/out. Subscribe without auto-handling generic
|
|
88
|
+
// traffic; only swarm headers enter the session, via the followUp task queue.
|
|
89
|
+
// Report readiness so the scheduler can match a pending task (fork+exec: any
|
|
90
|
+
// clean ready session on this workspace path may take it).
|
|
91
|
+
const ws = state.workspace;
|
|
92
|
+
const socket = subscribe(ws.socketPath, (line) => {
|
|
93
|
+
if (!line?.event)
|
|
94
|
+
return;
|
|
95
|
+
if (line.type !== "inbound_message")
|
|
96
|
+
return;
|
|
97
|
+
const inbound = inboundText(line);
|
|
98
|
+
if (!inbound || inbound.channelId !== "loopback")
|
|
99
|
+
return;
|
|
100
|
+
handleSwarmInbound(pi, inbound.text, line.event_seq);
|
|
101
|
+
}, () => { if (state.socket === socket)
|
|
102
|
+
scheduleReconnect(pi); });
|
|
103
|
+
state.socket = socket;
|
|
104
|
+
state.watching = true;
|
|
105
|
+
const task = envTaskId();
|
|
106
|
+
try {
|
|
107
|
+
await swarmReady(ws.socketPath, ws.root, terminalHandle());
|
|
108
|
+
}
|
|
109
|
+
catch { /* scheduler may read env fallback */ }
|
|
110
|
+
return `swarm watching ${ws.root} (${state.owner})${task ? ` task=${task}` : ""}`;
|
|
111
|
+
}
|
|
64
112
|
const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
|
|
65
113
|
return; const inbound = inboundText(line); if (!inbound)
|
|
66
114
|
return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
|
|
@@ -82,9 +130,9 @@ async function startWatch(pi) {
|
|
|
82
130
|
return `watching ${state.workspace.root} (${state.owner})`;
|
|
83
131
|
}
|
|
84
132
|
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"; }
|
|
133
|
+
clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; state.swarmTask = undefined; swarmSlot.clear(); return "watch stopped"; }
|
|
86
134
|
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}`; }
|
|
135
|
+
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
136
|
async function stopDaemon() { if (!state.workspace)
|
|
89
137
|
state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
90
138
|
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 +143,77 @@ async function reply(text) { if (!state.workspace)
|
|
|
95
143
|
inbound.replied = true;
|
|
96
144
|
clearReminder();
|
|
97
145
|
} return res; }
|
|
146
|
+
/** Resolve the current workspace tree path from the workspace root dir name. Root itself is ".". */
|
|
147
|
+
function swarmTreePath() {
|
|
148
|
+
if (!state.workspace)
|
|
149
|
+
throw new Error("onlyne workspace not found");
|
|
150
|
+
const { basename } = require("node:path");
|
|
151
|
+
return state.workspace.root === process.cwd() ? "." : basename(state.workspace.root);
|
|
152
|
+
}
|
|
153
|
+
/** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
|
|
154
|
+
async function swarmComplete(text) {
|
|
155
|
+
if (!state.workspace)
|
|
156
|
+
throw new Error("onlyne workspace not found");
|
|
157
|
+
const task = state.swarmTask;
|
|
158
|
+
if (!task)
|
|
159
|
+
throw new Error("no active swarm task");
|
|
160
|
+
const { renderSwarmHeader } = await import("./swarm.js");
|
|
161
|
+
const wire = renderSwarmHeader({ task_id: task.taskId, from: swarmTreePath(), transfer_send_to: task.transferSendTo, attempt: task.attempt }, "", text);
|
|
162
|
+
const res = await sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, currentConfig().outbound.retry.attempts);
|
|
163
|
+
if (res.ok) {
|
|
164
|
+
state.swarmTask = undefined;
|
|
165
|
+
swarmSlot.clear();
|
|
166
|
+
}
|
|
167
|
+
return { ...res, taskId: task.taskId };
|
|
168
|
+
}
|
|
169
|
+
/** swarm_quit: silent exit. No out written; the scheduler records failed. */
|
|
170
|
+
async function swarmQuit(reason) {
|
|
171
|
+
const task = state.swarmTask;
|
|
172
|
+
state.swarmTask = undefined;
|
|
173
|
+
swarmSlot.clear();
|
|
174
|
+
return { quit: true, taskId: task?.taskId, reason: reason ?? "" };
|
|
175
|
+
}
|
|
176
|
+
/** swarm_send: spawn downstream. New UUID, transfer_send_to = current task, fire-and-forget. */
|
|
177
|
+
async function swarmSend(to, text) {
|
|
178
|
+
if (!state.workspace)
|
|
179
|
+
throw new Error("onlyne workspace not found");
|
|
180
|
+
const task = state.swarmTask;
|
|
181
|
+
if (!task)
|
|
182
|
+
throw new Error("no active swarm task");
|
|
183
|
+
const { randomUUID } = await import("node:crypto");
|
|
184
|
+
const { renderSwarmHeader } = await import("./swarm.js");
|
|
185
|
+
const { existsSync } = await import("node:fs");
|
|
186
|
+
const { join, resolve } = await import("node:path");
|
|
187
|
+
// Resolve the target workspace dir: root "." or a tree path under the swarm tree.
|
|
188
|
+
// The scheduler owns the tree; here we only verify the send-side symlink exists
|
|
189
|
+
// (missing target = dangling link = error, never a blind FIFO write).
|
|
190
|
+
const wsRoot = state.workspace.root;
|
|
191
|
+
const linkPath = to === "." || to === "_root"
|
|
192
|
+
? join(wsRoot, "onlyne_in", "_root")
|
|
193
|
+
: join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
|
|
194
|
+
const resolved = resolve(wsRoot);
|
|
195
|
+
const linkDir = resolve(linkPath);
|
|
196
|
+
if (!linkDir.startsWith(resolved))
|
|
197
|
+
throw new Error(`invalid swarm_send target: ${to}`);
|
|
198
|
+
if (!existsSync(linkPath))
|
|
199
|
+
throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
|
|
200
|
+
const childId = randomUUID();
|
|
201
|
+
const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
|
|
202
|
+
const { writeFileSync } = await import("node:fs");
|
|
203
|
+
try {
|
|
204
|
+
writeFileSync(linkPath, wire);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
throw new Error(`swarm_send failed for ${to}: ${e instanceof Error ? e.message : String(e)}`);
|
|
208
|
+
}
|
|
209
|
+
swarmSlot.noteSpawned(childId);
|
|
210
|
+
return { childId, to };
|
|
211
|
+
}
|
|
98
212
|
export default function onlyne(pi) {
|
|
99
213
|
pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
|
|
100
214
|
await stopDaemon().catch(() => { });
|
|
101
215
|
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) {
|
|
216
|
+
stopWatch(); state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); state.currentInbound = undefined; state.lastValidOutput = undefined; state.swarmTask = undefined; swarmSlot.clear(); refreshSwarmFlag(); applyToolSurface(pi); ctx.ui.setStatus("onlyne", state.workspace ? (state.swarm ? "onlyne: swarm" : "onlyne: ready") : "onlyne: no .onlyne"); if ((currentConfig().watch.autoStart || resumeWatch) && state.workspace) {
|
|
103
217
|
try {
|
|
104
218
|
ctx.ui.notify(await startWatch(pi), "info");
|
|
105
219
|
}
|
|
@@ -116,11 +230,14 @@ export default function onlyne(pi) {
|
|
|
116
230
|
pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
|
|
117
231
|
state.lastValidOutput = text; });
|
|
118
232
|
pi.on("agent_start", async () => clearReminder());
|
|
119
|
-
pi.on("agent_end", async () =>
|
|
233
|
+
pi.on("agent_end", async () => { if (state.swarm)
|
|
234
|
+
scheduleSwarmExitReminder(pi);
|
|
235
|
+
else
|
|
236
|
+
scheduleReminder(pi); });
|
|
120
237
|
pi.registerCommand("onlyne", {
|
|
121
238
|
description: "Onlyne watch/status/config commands",
|
|
122
239
|
getArgumentCompletions: (prefix) => {
|
|
123
|
-
const commands = ["status", "watch on", "watch off", "daemon start", "daemon stop", "daemon restart", "config auto-start"];
|
|
240
|
+
const commands = ["status", "watch on", "watch off", "daemon start", "daemon stop", "daemon restart", "config auto-start", "swarm on", "swarm off", "swarm status"];
|
|
124
241
|
const p = prefix.trimStart();
|
|
125
242
|
const filtered = commands.filter((c) => c.startsWith(p));
|
|
126
243
|
return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
|
|
@@ -139,7 +256,14 @@ export default function onlyne(pi) {
|
|
|
139
256
|
else if (cmd === "daemon" && sub === "restart")
|
|
140
257
|
ctx.ui.notify(await restartDaemon(), "info");
|
|
141
258
|
else if (cmd === "status")
|
|
142
|
-
ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; workspace=${state.workspace?.root ?? "none"}`, "info");
|
|
259
|
+
ctx.ui.notify(`onlyne ${state.watching ? "watching" : "stopped"}; owner=${state.owner}; swarm=${state.swarm ? "on" : "off"}; workspace=${state.workspace?.root ?? "none"}`, "info");
|
|
260
|
+
else if (cmd === "swarm" && sub === "status") {
|
|
261
|
+
const cur = swarmSlot.task();
|
|
262
|
+
ctx.ui.notify(`swarm=${state.swarm ? "on" : "off"}; task=${cur.taskId ?? "none"}; spawned=${cur.sentChildIds.length}`, "info");
|
|
263
|
+
}
|
|
264
|
+
else if (cmd === "swarm" && (sub === "on" || sub === "off")) {
|
|
265
|
+
ctx.ui.notify(await setSwarm(pi, sub === "on"), "info");
|
|
266
|
+
}
|
|
143
267
|
else if (cmd === "config" && sub === "auto-start") {
|
|
144
268
|
const cfg = currentConfig();
|
|
145
269
|
cfg.watch.autoStart = !cfg.watch.autoStart;
|
|
@@ -147,13 +271,35 @@ export default function onlyne(pi) {
|
|
|
147
271
|
ctx.ui.notify(`autoStart=${cfg.watch.autoStart}`, "info");
|
|
148
272
|
}
|
|
149
273
|
else
|
|
150
|
-
ctx.ui.notify("usage: /onlyne status | watch on|off | daemon start|stop|restart | config auto-start", "info");
|
|
274
|
+
ctx.ui.notify("usage: /onlyne status | watch on|off | daemon start|stop|restart | swarm on|off|status | config auto-start", "info");
|
|
151
275
|
}
|
|
152
276
|
catch (e) {
|
|
153
277
|
ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
|
|
154
278
|
}
|
|
155
279
|
},
|
|
156
280
|
});
|
|
281
|
+
registerNormalTools(pi);
|
|
282
|
+
registerSwarmTools(pi);
|
|
283
|
+
applyToolSurface(pi);
|
|
284
|
+
}
|
|
285
|
+
const NORMAL_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "onlyne_reply", "onlyne_send", "onlyne_broadcast", "onlyne_loopback", "onlyne_mark_no_reply"];
|
|
286
|
+
const SWARM_TOOLS = ["onlyne_daemon_start", "onlyne_daemon_stop", "onlyne_daemon_restart", "swarm_complete", "swarm_quit", "swarm_send", "swarm_status"];
|
|
287
|
+
/** Tool surface follows [swarm] at session start: one mode, one toolset. */
|
|
288
|
+
function applyToolSurface(pi) {
|
|
289
|
+
try {
|
|
290
|
+
const active = new Set(typeof pi.getActiveTools === "function" ? pi.getActiveTools() : []);
|
|
291
|
+
const all = typeof pi.getAllTools === "function" ? pi.getAllTools().map((t) => t.name) : [];
|
|
292
|
+
const keep = state.swarm ? SWARM_TOOLS : NORMAL_TOOLS;
|
|
293
|
+
const next = [...active].filter((n) => all.includes(n) && !(NORMAL_TOOLS.includes(n) || SWARM_TOOLS.includes(n)));
|
|
294
|
+
for (const n of keep)
|
|
295
|
+
if (all.includes(n) && !next.includes(n))
|
|
296
|
+
next.push(n);
|
|
297
|
+
if (typeof pi.setActiveTools === "function")
|
|
298
|
+
pi.setActiveTools(next);
|
|
299
|
+
}
|
|
300
|
+
catch { /* older pi without tool-surface API: both sets stay registered */ }
|
|
301
|
+
}
|
|
302
|
+
function registerNormalTools(pi) {
|
|
157
303
|
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 }); } }));
|
|
158
304
|
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
305
|
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 }); } }));
|
|
@@ -169,3 +315,63 @@ export default function onlyne(pi) {
|
|
|
169
315
|
clearReminder();
|
|
170
316
|
} return textResult("marked no reply", params); } }));
|
|
171
317
|
}
|
|
318
|
+
function registerSwarmTools(pi) {
|
|
319
|
+
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))); } }));
|
|
320
|
+
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))); } }));
|
|
321
|
+
pi.registerTool(defineTool({ name: "swarm_send", label: "Swarm send", description: "Spawn a downstream swarm task: write a new task to onlyne_in/<to>/ with transfer_send_to set to the current task. Fire-and-forget; returns the child id without waiting. Errors on missing targets.", parameters: Type.Object({ to: Type.String(), text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmSend(params.to, params.text))); } }));
|
|
322
|
+
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 })); } }));
|
|
323
|
+
}
|
|
324
|
+
/** Swarm exit guard: at agent end, an unfinished hop gets one reminder, then auto-quit. */
|
|
325
|
+
function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
326
|
+
clearReminder();
|
|
327
|
+
if (!state.swarmTask)
|
|
328
|
+
return;
|
|
329
|
+
state.reminderTimer = setTimeout(() => {
|
|
330
|
+
state.reminderTimer = undefined;
|
|
331
|
+
if (!state.swarmTask)
|
|
332
|
+
return;
|
|
333
|
+
if (state.swarmTask) {
|
|
334
|
+
pi.sendUserMessage(`Swarm hop ${state.swarmTask.taskId} has no exit yet. Call swarm_complete with the handover summary, or swarm_quit when there is nothing to do.`, { deliverAs: "followUp" });
|
|
335
|
+
state.reminderTimer = setTimeout(() => {
|
|
336
|
+
state.reminderTimer = undefined;
|
|
337
|
+
if (state.swarmTask)
|
|
338
|
+
void swarmQuit("auto-quit: no explicit exit").catch(() => { });
|
|
339
|
+
}, delayMs);
|
|
340
|
+
}
|
|
341
|
+
}, delayMs);
|
|
342
|
+
}
|
|
343
|
+
/** Toggle swarm mode: persists to .onlyne/config.toml [swarm] enabled, restarts watch. */
|
|
344
|
+
async function setSwarm(pi, enabled) {
|
|
345
|
+
if (!state.workspace)
|
|
346
|
+
throw new Error("onlyne workspace not found");
|
|
347
|
+
const { readFileSync, writeFileSync, existsSync } = await import("node:fs");
|
|
348
|
+
const { join } = await import("node:path");
|
|
349
|
+
const cfgPath = join(state.workspace.onlyneDir, "config.toml");
|
|
350
|
+
let text = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
351
|
+
if (text.includes("[swarm]")) {
|
|
352
|
+
const lines = text.split("\n");
|
|
353
|
+
let inSwarm = false;
|
|
354
|
+
text = lines.map((line) => {
|
|
355
|
+
const t = line.trim();
|
|
356
|
+
if (t.startsWith("[")) {
|
|
357
|
+
inSwarm = t === "[swarm]";
|
|
358
|
+
return line;
|
|
359
|
+
}
|
|
360
|
+
if (inSwarm && t.startsWith("enabled"))
|
|
361
|
+
return `enabled = ${enabled}`;
|
|
362
|
+
return line;
|
|
363
|
+
}).join("\n");
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
if (text && !text.endsWith("\n"))
|
|
367
|
+
text += "\n";
|
|
368
|
+
text += `\n[swarm]\nenabled = ${enabled}\n`;
|
|
369
|
+
}
|
|
370
|
+
writeFileSync(cfgPath, text);
|
|
371
|
+
refreshSwarmFlag();
|
|
372
|
+
if (state.watching) {
|
|
373
|
+
stopWatch();
|
|
374
|
+
return `${enabled ? "swarm on; " : "swarm off; "}${await startWatch(pi)}`;
|
|
375
|
+
}
|
|
376
|
+
return `swarm ${enabled ? "on" : "off"} (watch not running)`;
|
|
377
|
+
}
|
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
|
|
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(
|
|
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,47 @@
|
|
|
1
|
+
export type SwarmHandleResult = "claimed" | "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 transfer;
|
|
20
|
+
private attempt;
|
|
21
|
+
private sentChildIds;
|
|
22
|
+
handle(pi: SwarmPi, text: string): SwarmHandleResult;
|
|
23
|
+
task(): {
|
|
24
|
+
taskId?: string;
|
|
25
|
+
from: string;
|
|
26
|
+
transferSendTo: string;
|
|
27
|
+
attempt: number;
|
|
28
|
+
sentChildIds: string[];
|
|
29
|
+
};
|
|
30
|
+
noteSpawned(childId: string): void;
|
|
31
|
+
taskIdOf(): string | undefined;
|
|
32
|
+
clear(): void;
|
|
33
|
+
}
|
|
34
|
+
/** Test seam: fresh slot without touching module-global pi-onlyne state. */
|
|
35
|
+
export declare function __swarmSlotForTest(): {
|
|
36
|
+
handle: (pi: SwarmPi, text: string) => SwarmHandleResult;
|
|
37
|
+
task: () => {
|
|
38
|
+
taskId?: string;
|
|
39
|
+
from: string;
|
|
40
|
+
transferSendTo: string;
|
|
41
|
+
attempt: number;
|
|
42
|
+
sentChildIds: string[];
|
|
43
|
+
};
|
|
44
|
+
taskId: () => string | undefined;
|
|
45
|
+
noteSpawned: (childId: string) => void;
|
|
46
|
+
clear: () => void;
|
|
47
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
transfer = "";
|
|
11
|
+
attempt = 1;
|
|
12
|
+
sentChildIds = [];
|
|
13
|
+
handle(pi, text) {
|
|
14
|
+
const parsed = parseSwarmHeader(text);
|
|
15
|
+
if (!parsed)
|
|
16
|
+
return "not-swarm";
|
|
17
|
+
const { header, payload } = parsed;
|
|
18
|
+
if (!this.taskId) {
|
|
19
|
+
this.taskId = header.task_id;
|
|
20
|
+
this.from = header.from;
|
|
21
|
+
this.transfer = header.transfer_send_to;
|
|
22
|
+
this.attempt = header.attempt;
|
|
23
|
+
this.sentChildIds = [];
|
|
24
|
+
pi.sendUserMessage(`Onlyne swarm task ${header.task_id} (from ${header.from}):\n\n${payload}\n\nThis session carries this one task only. Restore context from files, work, spawn continuations with swarm_send when another unit must continue, then exit with swarm_complete. Downstream results travel through files and the ledger; nothing waits here.`, { deliverAs: "followUp" });
|
|
25
|
+
return "claimed";
|
|
26
|
+
}
|
|
27
|
+
return "not-swarm";
|
|
28
|
+
}
|
|
29
|
+
task() {
|
|
30
|
+
return { taskId: this.taskId, from: this.from, transferSendTo: this.transfer, attempt: this.attempt, sentChildIds: [...this.sentChildIds] };
|
|
31
|
+
}
|
|
32
|
+
noteSpawned(childId) {
|
|
33
|
+
this.sentChildIds.push(childId);
|
|
34
|
+
}
|
|
35
|
+
taskIdOf() {
|
|
36
|
+
return this.taskId;
|
|
37
|
+
}
|
|
38
|
+
clear() {
|
|
39
|
+
this.taskId = undefined;
|
|
40
|
+
this.from = ".";
|
|
41
|
+
this.transfer = "";
|
|
42
|
+
this.attempt = 1;
|
|
43
|
+
this.sentChildIds = [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Test seam: fresh slot without touching module-global pi-onlyne state. */
|
|
47
|
+
export function __swarmSlotForTest() {
|
|
48
|
+
const slot = new SwarmSlot();
|
|
49
|
+
return {
|
|
50
|
+
handle: (pi, text) => slot.handle(pi, text),
|
|
51
|
+
task: () => slot.task(),
|
|
52
|
+
taskId: () => slot.taskIdOf(),
|
|
53
|
+
noteSpawned: (childId) => slot.noteSpawned(childId),
|
|
54
|
+
clear: () => slot.clear(),
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/swarm.d.ts
ADDED
|
@@ -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
|
+
transfer_send_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,99 @@
|
|
|
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 transfer_send_to = "";
|
|
30
|
+
let attempt = 1;
|
|
31
|
+
let sawTransfer = false;
|
|
32
|
+
for (const line of headRaw.split("\n")) {
|
|
33
|
+
const t = line.trim();
|
|
34
|
+
if (!t || t.startsWith("#"))
|
|
35
|
+
continue;
|
|
36
|
+
const i = t.indexOf(":");
|
|
37
|
+
if (i < 0)
|
|
38
|
+
continue;
|
|
39
|
+
const k = t.slice(0, i).trim();
|
|
40
|
+
const v = t.slice(i + 1).trim().replace(/^["']|["']$/g, "");
|
|
41
|
+
if (k === "task_id")
|
|
42
|
+
task_id = v;
|
|
43
|
+
else if (k === "from")
|
|
44
|
+
from = v || ".";
|
|
45
|
+
else if (k === "transfer_send_to") {
|
|
46
|
+
transfer_send_to = v;
|
|
47
|
+
sawTransfer = true;
|
|
48
|
+
}
|
|
49
|
+
else if (k === "reply_to")
|
|
50
|
+
return null;
|
|
51
|
+
else if (k === "attempt")
|
|
52
|
+
attempt = Number.parseInt(v, 10) || 1;
|
|
53
|
+
}
|
|
54
|
+
if (!sawTransfer)
|
|
55
|
+
return null;
|
|
56
|
+
if (!task_id || !UUID_RE.test(task_id.trim()))
|
|
57
|
+
return null;
|
|
58
|
+
return { header: { task_id: task_id.trim(), from, transfer_send_to, attempt }, payload };
|
|
59
|
+
}
|
|
60
|
+
/** Render a swarm body header in front of a Markdown payload. */
|
|
61
|
+
export function renderSwarmHeader(header, role, payloadMarkdown) {
|
|
62
|
+
let s = `---swarm\ntask_id: ${header.task_id}\nfrom: ${header.from}\ntransfer_send_to: ${header.transfer_send_to}\nattempt: ${header.attempt}\n---\n`;
|
|
63
|
+
if (role)
|
|
64
|
+
s += `## role: ${role}\n\n${role}\n`;
|
|
65
|
+
s += payloadMarkdown;
|
|
66
|
+
if (!s.endsWith("\n"))
|
|
67
|
+
s += "\n";
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
/** Read the `[swarm] enabled` flag from the workspace .onlyne/config.toml. */
|
|
71
|
+
export function readSwarmEnabled(onlyneDir) {
|
|
72
|
+
const path = join(onlyneDir, "config.toml");
|
|
73
|
+
if (!existsSync(path))
|
|
74
|
+
return false;
|
|
75
|
+
try {
|
|
76
|
+
const text = readFileSync(path, "utf8");
|
|
77
|
+
let inSwarm = false;
|
|
78
|
+
for (const line of text.split("\n")) {
|
|
79
|
+
const t = line.trim();
|
|
80
|
+
if (t.startsWith("[")) {
|
|
81
|
+
inSwarm = t === "[swarm]";
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (inSwarm && t.startsWith("enabled")) {
|
|
85
|
+
return /=?\s*true\b/.test(t);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch { /* unreadable -> disabled */ }
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/** Terminal handle for swarm_ready correlation (injected by the scheduler). */
|
|
93
|
+
export function terminalHandle() {
|
|
94
|
+
return process.env.ORCA_TERMINAL_HANDLE || process.env.ONLYNE_TERMINAL_HANDLE || "";
|
|
95
|
+
}
|
|
96
|
+
/** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
|
|
97
|
+
export function envTaskId() {
|
|
98
|
+
return process.env.ONLYNE_SWARM_TASK || "";
|
|
99
|
+
}
|
package/dist/workspace.js
CHANGED
|
@@ -5,7 +5,7 @@ export function findWorkspace(start) {
|
|
|
5
5
|
for (;;) {
|
|
6
6
|
const onlyneDir = join(dir, ".onlyne");
|
|
7
7
|
if (existsSync(onlyneDir))
|
|
8
|
-
return { root: dir, onlyneDir, socketPath: join(onlyneDir, "run", "
|
|
8
|
+
return { root: dir, onlyneDir, socketPath: join(onlyneDir, "run", "s") };
|
|
9
9
|
const parent = dirname(dir);
|
|
10
10
|
if (parent === dir)
|
|
11
11
|
return null;
|