pi-onlyne 0.3.4 → 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 +135 -71
- package/SPEC.md +25 -3
- package/dist/index.js +228 -44
- package/dist/onlyne.d.ts +14 -5
- package/dist/onlyne.js +63 -5
- package/dist/swarm-slot.d.ts +36 -0
- package/dist/swarm-slot.js +53 -0
- package/dist/swarm.d.ts +21 -0
- package/dist/swarm.js +92 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,96 +1,141 @@
|
|
|
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
|
|
17
|
+
|
|
18
|
+
Install the published Pi package:
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- local history and event stream, not a heavy message platform
|
|
20
|
+
```bash
|
|
21
|
+
pi install npm:pi-onlyne
|
|
22
|
+
```
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
Run it for one Pi process:
|
|
19
25
|
|
|
20
|
-
|
|
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 init
|
|
53
72
|
onlyne run
|
|
54
|
-
# Optional, in another shell: refresh the workspace-local agent skill
|
|
55
|
-
onlyne export-skill
|
|
56
73
|
```
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
##
|
|
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
|
+
```
|
|
61
95
|
|
|
62
|
-
|
|
63
|
-
2. Start that workspace's daemon with `onlyne run`.
|
|
64
|
-
3. Install this Pi extension.
|
|
65
|
-
4. Start watching from pi:
|
|
96
|
+
Enable automatic subscription when Pi starts:
|
|
66
97
|
|
|
67
|
-
```
|
|
68
|
-
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"watch": { "autoStart": true }
|
|
101
|
+
}
|
|
69
102
|
```
|
|
70
103
|
|
|
71
|
-
|
|
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`.
|
|
72
105
|
|
|
73
106
|
## Commands
|
|
74
107
|
|
|
75
108
|
```text
|
|
76
109
|
/onlyne status
|
|
110
|
+
/onlyne daemon start
|
|
111
|
+
/onlyne daemon stop
|
|
112
|
+
/onlyne daemon restart
|
|
77
113
|
/onlyne watch on
|
|
78
114
|
/onlyne watch off
|
|
79
115
|
/onlyne config auto-start
|
|
116
|
+
/onlyne swarm on
|
|
117
|
+
/onlyne swarm off
|
|
118
|
+
/onlyne swarm status
|
|
80
119
|
```
|
|
81
120
|
|
|
82
|
-
|
|
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`.
|
|
83
122
|
|
|
84
123
|
## Agent tools
|
|
85
124
|
|
|
86
125
|
```text
|
|
126
|
+
onlyne_daemon_start()
|
|
127
|
+
onlyne_daemon_stop()
|
|
128
|
+
onlyne_daemon_restart()
|
|
87
129
|
onlyne_reply({ text })
|
|
88
130
|
onlyne_send({ channelId, text, rawText? })
|
|
89
131
|
onlyne_broadcast({ targets, text, rawText? })
|
|
90
132
|
onlyne_loopback({ text, rawText? })
|
|
91
133
|
onlyne_mark_no_reply({ reason? })
|
|
134
|
+
onlyne_swarm_reply({ text, rawText? })
|
|
92
135
|
```
|
|
93
136
|
|
|
137
|
+
Messages use Markdown by default. `rawText: true` preserves literal text for scripts and protocol payloads.
|
|
138
|
+
|
|
94
139
|
### Send one message
|
|
95
140
|
|
|
96
141
|
```ts
|
|
@@ -100,57 +145,76 @@ onlyne_send({
|
|
|
100
145
|
})
|
|
101
146
|
```
|
|
102
147
|
|
|
103
|
-
### Send literal text
|
|
104
|
-
|
|
105
|
-
```ts
|
|
106
|
-
onlyne_send({
|
|
107
|
-
channelId: "telegram",
|
|
108
|
-
text: "# not a heading",
|
|
109
|
-
rawText: true
|
|
110
|
-
})
|
|
111
|
-
```
|
|
112
|
-
|
|
113
148
|
### Broadcast
|
|
114
149
|
|
|
115
150
|
```ts
|
|
116
151
|
onlyne_broadcast({
|
|
117
|
-
targets: [
|
|
118
|
-
|
|
119
|
-
{ channelId: "feishu" }
|
|
120
|
-
],
|
|
121
|
-
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."
|
|
122
154
|
})
|
|
123
155
|
```
|
|
124
156
|
|
|
125
157
|
### Loopback wake-up
|
|
126
158
|
|
|
127
|
-
|
|
159
|
+
A local script can wake the current Pi session through the daemon socket:
|
|
128
160
|
|
|
129
161
|
```bash
|
|
130
162
|
onlyne client '{"id":"wake","op":"loopback","text":"background job finished","raw_text":true}'
|
|
131
|
-
# or, with FIFO IO enabled by the daemon:
|
|
132
|
-
printf 'background job finished\n' > .onlyne/channels/loopback/in
|
|
133
163
|
```
|
|
134
164
|
|
|
135
|
-
|
|
165
|
+
The extension also supports `.onlyne/channels/loopback/in` when FIFO IO is enabled.
|
|
136
166
|
|
|
137
|
-
##
|
|
167
|
+
## Swarm mode
|
|
138
168
|
|
|
139
|
-
|
|
169
|
+
Swarm mode lets `onlyne-swarm` own task routing for a generated agent workspace. Enable it in `.onlyne/config.toml`:
|
|
140
170
|
|
|
141
|
-
```
|
|
142
|
-
|
|
171
|
+
```toml
|
|
172
|
+
[swarm]
|
|
173
|
+
enabled = true
|
|
143
174
|
```
|
|
144
175
|
|
|
145
|
-
|
|
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.
|
|
146
177
|
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
149
206
|
```
|
|
150
207
|
|
|
151
|
-
|
|
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.
|
|
152
209
|
|
|
153
210
|
## Links
|
|
154
211
|
|
|
155
|
-
- Onlyne
|
|
156
|
-
-
|
|
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
|
@@ -7,9 +7,9 @@ Pi extension for Onlyne. Onlyne remains a workspace-local IM broker; this extens
|
|
|
7
7
|
## v1 Decisions
|
|
8
8
|
|
|
9
9
|
- Watch is configurable; default manual.
|
|
10
|
-
- `/onlyne` provides argument completions for its supported subcommands.
|
|
11
|
-
- `watch on` connects
|
|
12
|
-
-
|
|
10
|
+
- `/onlyne` provides argument completions for its supported subcommands, including daemon lifecycle commands.
|
|
11
|
+
- `watch on` connects to the workspace-local `.onlyne/run/onlyne.sock`; if unavailable, it starts a Pi-owned workspace daemon.
|
|
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`.
|
|
15
15
|
- Outbound defaults to `guarded-explicit`: prefer tool reply, fallback to final text, else send configured error text.
|
|
@@ -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,37 +1,176 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { broadcast, connectDaemon, loopback, markConsumed, sendWithRetry, 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 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(() => { }); }
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
function clearReminder() { if (state.reminderTimer)
|
|
18
|
+
clearTimeout(state.reminderTimer); state.reminderTimer = undefined; }
|
|
19
|
+
function needsReply(inbound = state.currentInbound) { return !!inbound && !inbound.replied && !inbound.noReply && !!state.workspace; }
|
|
20
|
+
function scheduleReminder(pi, delayMs = 30_000) {
|
|
21
|
+
clearReminder();
|
|
22
|
+
const inbound = state.currentInbound;
|
|
23
|
+
if (!inbound || !needsReply(inbound))
|
|
24
|
+
return;
|
|
25
|
+
state.reminderTimer = setTimeout(() => {
|
|
26
|
+
state.reminderTimer = undefined;
|
|
27
|
+
if (!needsReply(inbound) || state.currentInbound !== inbound)
|
|
28
|
+
return;
|
|
29
|
+
const cfg = currentConfig();
|
|
30
|
+
if (cfg.outbound.defaultReplyMode === "explicit-only")
|
|
31
|
+
return;
|
|
32
|
+
if (cfg.outbound.defaultReplyMode === "guarded-explicit" && inbound.reminders < cfg.outbound.guardedExplicit.reminders) {
|
|
33
|
+
if (inbound.reminders === 0)
|
|
34
|
+
inbound.fallbackText = state.lastValidOutput;
|
|
35
|
+
inbound.reminders++;
|
|
36
|
+
pi.sendUserMessage(`Onlyne reminder ${inbound.reminders}/${cfg.outbound.guardedExplicit.reminders}: reply to ${inbound.channelId}/${inbound.conversationId} with onlyne_reply, or call onlyne_mark_no_reply.`, { deliverAs: "followUp" });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
void reply(inbound.fallbackText || state.lastValidOutput || cfg.outbound.guardedExplicit.noOutputFallbackText).catch(() => { });
|
|
40
|
+
}, delayMs);
|
|
41
|
+
}
|
|
42
|
+
function scheduleReconnect(pi) {
|
|
43
|
+
if (state.reconnectTimer || !state.watching || !state.workspace)
|
|
44
|
+
return;
|
|
45
|
+
state.reconnectTimer = setTimeout(async () => {
|
|
46
|
+
state.reconnectTimer = undefined;
|
|
47
|
+
if (!state.watching)
|
|
48
|
+
return;
|
|
49
|
+
try {
|
|
50
|
+
await startWatch(pi);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
scheduleReconnect(pi);
|
|
54
|
+
}
|
|
55
|
+
}, 1000);
|
|
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
|
+
}
|
|
79
|
+
async function startWatch(pi) {
|
|
80
|
+
state.workspace = findWorkspace(state.cwd);
|
|
81
|
+
if (!state.workspace)
|
|
82
|
+
throw new Error("current workspace has no .onlyne configuration");
|
|
83
|
+
refreshSwarmFlag();
|
|
84
|
+
if (state.reconnectTimer)
|
|
85
|
+
clearTimeout(state.reconnectTimer);
|
|
86
|
+
state.reconnectTimer = undefined;
|
|
87
|
+
state.socket?.destroy();
|
|
88
|
+
state.socket = undefined;
|
|
89
|
+
const conn = await connectDaemon(state.workspace);
|
|
90
|
+
state.owner = conn.owner;
|
|
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
|
+
}
|
|
118
|
+
const socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
|
|
119
|
+
return; const inbound = inboundText(line); if (!inbound)
|
|
120
|
+
return; const mode = inboundModeFor(currentConfig(), inbound.channelId, inbound.conversationId); if (mode === "muted")
|
|
121
|
+
return; if (inbound.channelId === "loopback") {
|
|
122
|
+
if (mode === "auto-handle")
|
|
123
|
+
pi.sendUserMessage(`Onlyne loopback activation${inbound.conversationId ? ` (${inbound.conversationId})` : ""}:\n\n${inbound.text}`, { deliverAs: "followUp" });
|
|
124
|
+
consumeIfNotified(inbound);
|
|
125
|
+
return;
|
|
126
|
+
} if (inbound.text.trim() === "/handshake") {
|
|
127
|
+
consumeIfNotified(inbound);
|
|
128
|
+
return;
|
|
129
|
+
} clearReminder(); state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle") {
|
|
130
|
+
pi.sendUserMessage(`Onlyne inbound message from ${inbound.channelId}/${inbound.conversationId}:\n\n${inbound.text}\n\nReply with onlyne_reply, or call onlyne_mark_no_reply if no reply is needed.`, { deliverAs: "followUp" });
|
|
131
|
+
consumeIfNotified(inbound);
|
|
132
|
+
} }, () => { if (state.socket === socket)
|
|
133
|
+
scheduleReconnect(pi); });
|
|
134
|
+
state.socket = socket;
|
|
135
|
+
state.watching = true;
|
|
136
|
+
return `watching ${state.workspace.root} (${state.owner})`;
|
|
137
|
+
}
|
|
138
|
+
function stopWatch() { if (state.reconnectTimer)
|
|
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"; }
|
|
140
|
+
async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
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}`; }
|
|
142
|
+
async function stopDaemon() { if (!state.workspace)
|
|
143
|
+
state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
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}`; }
|
|
145
|
+
async function restartDaemon() { await stopDaemon().catch(() => { }); return startDaemon(); }
|
|
29
146
|
async function reply(text) { if (!state.workspace)
|
|
30
147
|
throw new Error("onlyne workspace not found"); const inbound = state.currentInbound; if (!inbound)
|
|
31
|
-
throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId }, text, currentConfig().outbound.retry.attempts); if (res.ok)
|
|
32
|
-
inbound.replied = true;
|
|
148
|
+
throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId }, text, currentConfig().outbound.retry.attempts); if (res.ok) {
|
|
149
|
+
inbound.replied = true;
|
|
150
|
+
clearReminder();
|
|
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
|
+
}
|
|
33
169
|
export default function onlyne(pi) {
|
|
34
|
-
pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching;
|
|
170
|
+
pi.on("session_start", async (_event, ctx) => { const resumeWatch = state.watching; if (state.owner === "extension")
|
|
171
|
+
await stopDaemon().catch(() => { });
|
|
172
|
+
else
|
|
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) {
|
|
35
174
|
try {
|
|
36
175
|
ctx.ui.notify(await startWatch(pi), "info");
|
|
37
176
|
}
|
|
@@ -39,31 +178,20 @@ export default function onlyne(pi) {
|
|
|
39
178
|
ctx.ui.notify(String(e), "warning");
|
|
40
179
|
}
|
|
41
180
|
} });
|
|
42
|
-
pi.on("session_shutdown", async () => {
|
|
181
|
+
pi.on("session_shutdown", async () => { if (state.owner === "extension")
|
|
182
|
+
await stopDaemon().catch(() => { });
|
|
183
|
+
else
|
|
184
|
+
stopWatch(); });
|
|
43
185
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
44
186
|
process.once(sig, () => stopWatch());
|
|
45
187
|
pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
|
|
46
188
|
state.lastValidOutput = text; });
|
|
47
|
-
pi.on("
|
|
48
|
-
|
|
49
|
-
if (!inbound || inbound.replied || inbound.noReply || !state.workspace)
|
|
50
|
-
return;
|
|
51
|
-
const cfg = currentConfig();
|
|
52
|
-
if (cfg.outbound.defaultReplyMode === "explicit-only")
|
|
53
|
-
return;
|
|
54
|
-
if (cfg.outbound.defaultReplyMode === "guarded-explicit" && inbound.reminders < cfg.outbound.guardedExplicit.reminders) {
|
|
55
|
-
if (inbound.reminders === 0)
|
|
56
|
-
inbound.fallbackText = state.lastValidOutput;
|
|
57
|
-
inbound.reminders++;
|
|
58
|
-
pi.sendUserMessage(`Onlyne reminder ${inbound.reminders}/${cfg.outbound.guardedExplicit.reminders}: reply to ${inbound.channelId}/${inbound.conversationId} with onlyne_reply, or call onlyne_mark_no_reply.`, { deliverAs: "followUp" });
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
await reply(inbound.fallbackText || state.lastValidOutput || cfg.outbound.guardedExplicit.noOutputFallbackText);
|
|
62
|
-
});
|
|
189
|
+
pi.on("agent_start", async () => clearReminder());
|
|
190
|
+
pi.on("agent_end", async () => scheduleReminder(pi));
|
|
63
191
|
pi.registerCommand("onlyne", {
|
|
64
192
|
description: "Onlyne watch/status/config commands",
|
|
65
193
|
getArgumentCompletions: (prefix) => {
|
|
66
|
-
const commands = ["status", "watch on", "watch off", "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"];
|
|
67
195
|
const p = prefix.trimStart();
|
|
68
196
|
const filtered = commands.filter((c) => c.startsWith(p));
|
|
69
197
|
return filtered.length ? filtered.map((value) => ({ value, label: value })) : null;
|
|
@@ -75,8 +203,19 @@ export default function onlyne(pi) {
|
|
|
75
203
|
ctx.ui.notify(await startWatch(pi), "info");
|
|
76
204
|
else if (cmd === "watch" && sub === "off")
|
|
77
205
|
ctx.ui.notify(stopWatch(), "info");
|
|
206
|
+
else if (cmd === "daemon" && sub === "start")
|
|
207
|
+
ctx.ui.notify(await startDaemon(), "info");
|
|
208
|
+
else if (cmd === "daemon" && sub === "stop")
|
|
209
|
+
ctx.ui.notify(await stopDaemon(), "info");
|
|
210
|
+
else if (cmd === "daemon" && sub === "restart")
|
|
211
|
+
ctx.ui.notify(await restartDaemon(), "info");
|
|
78
212
|
else if (cmd === "status")
|
|
79
|
-
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
|
+
}
|
|
80
219
|
else if (cmd === "config" && sub === "auto-start") {
|
|
81
220
|
const cfg = currentConfig();
|
|
82
221
|
cfg.watch.autoStart = !cfg.watch.autoStart;
|
|
@@ -84,20 +223,65 @@ export default function onlyne(pi) {
|
|
|
84
223
|
ctx.ui.notify(`autoStart=${cfg.watch.autoStart}`, "info");
|
|
85
224
|
}
|
|
86
225
|
else
|
|
87
|
-
ctx.ui.notify("usage: /onlyne status | watch on|off | 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");
|
|
88
227
|
}
|
|
89
228
|
catch (e) {
|
|
90
229
|
ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
|
|
91
230
|
}
|
|
92
231
|
},
|
|
93
232
|
});
|
|
233
|
+
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 }); } }));
|
|
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 }); } }));
|
|
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 }); } }));
|
|
94
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))); } }));
|
|
95
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)
|
|
96
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); } }));
|
|
97
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)
|
|
98
241
|
throw new Error("onlyne workspace not found"); const cfg = currentConfig(); const results = await broadcast(state.workspace.socketPath, params.targets, params.text, cfg.outbound.retry.attempts, cfg.outbound.retry.concurrency, params.rawText ?? false); return textResult(JSON.stringify({ ok: results.every((r) => r.ok), results }), results); } }));
|
|
99
242
|
pi.registerTool(defineTool({ name: "onlyne_loopback", label: "Onlyne loopback", description: "Inject a local loopback activation message so scripts can wake the current Pi session. Set rawText=false for Markdown. FIFO alternative: write to .onlyne/channels/loopback/in.", parameters: Type.Object({ text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
100
243
|
throw new Error("onlyne workspace not found"); const res = await loopback(state.workspace.socketPath, params.text, params.rawText ?? true); return textResult(JSON.stringify(res), res); } }));
|
|
101
|
-
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)
|
|
102
|
-
state.currentInbound.noReply = true;
|
|
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) {
|
|
245
|
+
state.currentInbound.noReply = true;
|
|
246
|
+
clearReminder();
|
|
247
|
+
} if (state.swarmTask) {
|
|
248
|
+
state.swarmTask = undefined;
|
|
249
|
+
state.swarmPending = 0;
|
|
250
|
+
swarmSlot.clear();
|
|
251
|
+
} return textResult("marked no reply", params); } }));
|
|
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)`;
|
|
103
287
|
}
|
package/dist/onlyne.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
2
|
import { type Socket } from "node:net";
|
|
3
3
|
import type { Workspace } from "./workspace.js";
|
|
4
4
|
export interface OnlyneRequest {
|
|
@@ -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,13 +22,19 @@ 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)
|
|
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
|
-
export declare function connectDaemon(ws: Workspace): Promise<{
|
|
25
|
-
owner: "external";
|
|
30
|
+
export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean): Promise<{
|
|
31
|
+
owner: "external" | "extension";
|
|
26
32
|
process?: ChildProcess;
|
|
27
33
|
}>;
|
|
28
|
-
export declare function
|
|
34
|
+
export declare function shutdownDaemon(ws: Workspace, child?: ChildProcess): Promise<void>;
|
|
35
|
+
export declare function stopProcess(child?: ChildProcess): void;
|
|
36
|
+
export declare function swarmReady(socketPath: string, workspace: string, terminalHandle: string): Promise<any>;
|
|
37
|
+
export declare function consumeEvent(socket: Socket, eventSeq: number): Promise<void>;
|
|
29
38
|
export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
|
|
30
39
|
export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
|
|
31
40
|
export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean): Promise<SendResult>;
|
package/dist/onlyne.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { createConnection } from "node:net";
|
|
2
3
|
export function request(socketPath, req) {
|
|
3
4
|
return new Promise((resolve, reject) => {
|
|
@@ -17,11 +18,17 @@ export function request(socketPath, req) {
|
|
|
17
18
|
} });
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
|
-
export function subscribe(socketPath, onLine) {
|
|
21
|
+
export function subscribe(socketPath, onLine, onDisconnect, opts) {
|
|
21
22
|
const socket = createConnection(socketPath);
|
|
22
23
|
let buf = "";
|
|
24
|
+
let closed = false;
|
|
23
25
|
socket.setEncoding("utf8");
|
|
24
|
-
|
|
26
|
+
const disconnect = () => { if (closed)
|
|
27
|
+
return; closed = true; onDisconnect?.(); };
|
|
28
|
+
socket.on("error", disconnect);
|
|
29
|
+
socket.on("close", disconnect);
|
|
30
|
+
socket.on("connect", () => { if (!socket.destroyed)
|
|
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`, () => { }); });
|
|
25
32
|
socket.on("data", (chunk) => { buf += chunk; for (;;) {
|
|
26
33
|
const idx = buf.indexOf("\n");
|
|
27
34
|
if (idx < 0)
|
|
@@ -52,16 +59,67 @@ export async function waitForSocket(socketPath, timeoutMs = 5000) {
|
|
|
52
59
|
}
|
|
53
60
|
throw last instanceof Error ? last : new Error("onlyne socket not ready");
|
|
54
61
|
}
|
|
55
|
-
|
|
62
|
+
function spawnManagedDaemon(ws) {
|
|
63
|
+
const bin = process.env.ONLYNE_BIN || "onlyne";
|
|
64
|
+
const script = `
|
|
65
|
+
parent="$1"; shift
|
|
66
|
+
"$@" &
|
|
67
|
+
child=$!
|
|
68
|
+
trap 'kill "$child" 2>/dev/null; wait "$child" 2>/dev/null' INT TERM HUP EXIT
|
|
69
|
+
while kill -0 "$parent" 2>/dev/null; do
|
|
70
|
+
kill -0 "$child" 2>/dev/null || { wait "$child"; exit $?; }
|
|
71
|
+
sleep 1
|
|
72
|
+
done
|
|
73
|
+
kill "$child" 2>/dev/null
|
|
74
|
+
wait "$child" 2>/dev/null
|
|
75
|
+
`;
|
|
76
|
+
return spawn("sh", ["-c", script, "onlyne-supervisor", String(process.pid), bin, "--workspace", ws.root, "run"], { stdio: "ignore" });
|
|
77
|
+
}
|
|
78
|
+
export async function connectDaemon(ws, startIfMissing = true) {
|
|
56
79
|
try {
|
|
57
80
|
await request(ws.socketPath, { id: "ping", op: "ping" });
|
|
58
81
|
return { owner: "external" };
|
|
59
82
|
}
|
|
60
83
|
catch (e) {
|
|
61
|
-
|
|
84
|
+
if (!startIfMissing)
|
|
85
|
+
throw new Error(`onlyne daemon is not running for ${ws.root}; start it with /onlyne daemon start`, { cause: e });
|
|
86
|
+
const child = spawnManagedDaemon(ws);
|
|
87
|
+
try {
|
|
88
|
+
await waitForSocket(ws.socketPath);
|
|
89
|
+
// 自己 spawn 的 daemon 已退出:竞态中输给了其他启动方,socket 归对方所有,降级为 external(只订阅、不 shutdown)。
|
|
90
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
91
|
+
return { owner: "external" };
|
|
92
|
+
return { owner: "extension", process: child };
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
stopProcess(child);
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function shutdownDaemon(ws, child) {
|
|
101
|
+
try {
|
|
102
|
+
await request(ws.socketPath, { id: `shutdown-${Date.now()}`, op: "shutdown" });
|
|
103
|
+
}
|
|
104
|
+
catch { /* may already be down */ }
|
|
105
|
+
stopProcess(child);
|
|
106
|
+
}
|
|
107
|
+
export function stopProcess(child) { if (!child || child.killed)
|
|
108
|
+
return; try {
|
|
109
|
+
child.kill("SIGTERM");
|
|
110
|
+
}
|
|
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());
|
|
62
118
|
}
|
|
119
|
+
catch {
|
|
120
|
+
resolve();
|
|
121
|
+
} });
|
|
63
122
|
}
|
|
64
|
-
export function stopProcess(_child) { }
|
|
65
123
|
export async function loopback(socketPath, text, rawText = true) {
|
|
66
124
|
return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", text, raw_text: rawText });
|
|
67
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
|
+
}
|
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
|
+
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
|
+
}
|