pi-onlyne 1.0.0 → 1.1.1
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 +31 -12
- package/README.zh.md +28 -11
- package/package.json +1 -1
- package/src/activity.mjs +96 -0
- package/src/activity.test.mjs +69 -0
- package/src/agent.mjs +102 -39
- package/src/agent.test.mjs +127 -2
- package/src/index.ts +3 -0
- package/src/pi-surface.mjs +13 -0
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ extension travels with it: nothing is installed globally.
|
|
|
53
53
|
```toml
|
|
54
54
|
# spec.toml
|
|
55
55
|
[server]
|
|
56
|
-
agent_package = "/abs/path/to/
|
|
56
|
+
agent_package = "/abs/path/to/plugins/onlyne-agent-pi" # read once, at generate time
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
```bash
|
|
@@ -63,7 +63,7 @@ onlyne server generate --root <server-root> --out <dir>
|
|
|
63
63
|
The generated `.pi/settings.json` then carries:
|
|
64
64
|
|
|
65
65
|
```json
|
|
66
|
-
{ "packages": ["../.onlyne/agent/pi
|
|
66
|
+
{ "packages": ["../.onlyne/agent/onlyne-agent-pi"] }
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
`pi list` shows the entry under "Project packages". To verify the load itself, make the
|
|
@@ -72,14 +72,26 @@ copied `index.ts` throw and watch for the failure.
|
|
|
72
72
|
### Manual (no generator)
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
cp -R
|
|
76
|
-
printf '{"packages":["../.onlyne/agent/pi
|
|
75
|
+
cp -R plugins/onlyne-agent-pi <ws>/.onlyne/agent/onlyne-agent-pi
|
|
76
|
+
printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings.json
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
### From npm
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pi install npm:pi-onlyne # user-level: every pi process on this box loads it
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The published package is `pi-onlyne` on npm; `pi install npm:pi-onlyne@<version>` pins
|
|
86
|
+
one. This route reaches ordinary interactive sessions too, and there the extension
|
|
87
|
+
stays inert (no `ONLYNE_ROLE`, so no adapter). A role workspace needs no global
|
|
88
|
+
install to get a panel: the file-level copy above, or `onlyne server generate`,
|
|
89
|
+
scopes the plugin to the workspace that serves the role.
|
|
90
|
+
|
|
79
91
|
### One-off / testing
|
|
80
92
|
|
|
81
93
|
```bash
|
|
82
|
-
pi --session-id <id> -e /abs/path/to/
|
|
94
|
+
pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
|
|
83
95
|
```
|
|
84
96
|
|
|
85
97
|
### The switch file
|
|
@@ -121,8 +133,13 @@ What happens when a pi API is missing, and what the host does then:
|
|
|
121
133
|
| no `sendMessage` | probed | the role prose from `welcome` is not injected as context; the task itself still arrives |
|
|
122
134
|
| no `appendEntry` | probed | no `onlyne-assign` / `onlyne-complete` session entries are recorded |
|
|
123
135
|
| no `ui.setStatus` | guarded | the footer status line is skipped |
|
|
136
|
+
| no `ui.setWidget` | guarded | routine notices continue through the footer status line and the `[pi-onlyne]` stderr line |
|
|
124
137
|
| no `ctx.shutdown` | guarded | `recycle` and a completion still settle the task; the process stays up for the operator to close |
|
|
125
138
|
|
|
139
|
+
### Activity panel
|
|
140
|
+
|
|
141
|
+
When the host reports a UI (`ctx.hasUI`, true in the TUI and RPC modes, false in print and JSON modes) and `ctx.ui.setWidget` is available, routine onlyne notices draw in the panel above the editor with widget key `onlyne`. The header shows role, connection state, generation, the current task id, and phase. Below it, up to six newest-first events use `<=` for inbound frames, `=>` for outbound frames, `!!` for warnings, `..` for state changes, and `~~` for duplicate deliveries. Repeated identical events fold into one line with `xN`; the panel holds at most eight lines, each capped at 96 cells, and `session_shutdown` clears it.
|
|
142
|
+
|
|
126
143
|
## 3. Tools
|
|
127
144
|
|
|
128
145
|
Registered only inside an onlyne session.
|
|
@@ -193,7 +210,7 @@ judges delivery facts only — whether a role was reached — and never the shap
|
|
|
193
210
|
of the text that was sent.
|
|
194
211
|
|
|
195
212
|
The policy lives next to the plugin's `package.json`, so it travels inside the copy a
|
|
196
|
-
generated workspace loads: `<ws>/.onlyne/agent/pi
|
|
213
|
+
generated workspace loads: `<ws>/.onlyne/agent/onlyne-agent-pi/relay.toml` in a generated
|
|
197
214
|
workspace, `relay.toml` in a manual installation.
|
|
198
215
|
|
|
199
216
|
```toml
|
|
@@ -281,10 +298,12 @@ the shipped client.
|
|
|
281
298
|
written, and a framing fault closes the connection and reconnects. Framing cannot
|
|
282
299
|
resynchronise after a corrupt body, which is the same conclusion
|
|
283
300
|
`crates/onlyne-frame/src/lib.rs` reaches.
|
|
284
|
-
- **
|
|
285
|
-
same
|
|
286
|
-
|
|
287
|
-
|
|
301
|
+
- **Deliveries are idempotent; tasks are not.** The dedup key is the envelope id. The
|
|
302
|
+
same delivery twice gets one injection and an ack with `reason: "duplicate"`, and a
|
|
303
|
+
new envelope for a task that is already running reaches that session as another
|
|
304
|
+
message — the work record keeps its counters and its relay ledger, and only its
|
|
305
|
+
"turns since this instruction" watchdog restarts. The client mints a fresh uuid per
|
|
306
|
+
envelope, so `duplicate` fires on a genuine re-offer and on nothing else.
|
|
288
307
|
|
|
289
308
|
- **Pane binding (Orca tabs).** Inside an Orca pane the plugin reports the pane it runs in on every
|
|
290
309
|
heartbeat, as `observed.host.orca.pane_key` in the report's `Observation`
|
|
@@ -334,7 +353,7 @@ client injected none, §5).
|
|
|
334
353
|
| `ready refused: internal: unknown session for …` | the plugin mounted and reported for a task the client never staged (normal when pi is started by hand outside a task) | start pi under the client, not by hand |
|
|
335
354
|
| `assign` never arrives | the client's `session_command` did not spawn pi, or `inject` was dropped | the client log for the spawn line; `/onlyne status` for the capability set |
|
|
336
355
|
| ledger stays `in_flight` | no completion was reported: no turn ran, or `agent_settled` never fired | the pi session file for `onlyne-assign` / `onlyne-complete` entries |
|
|
337
|
-
| `onlyne_complete` answers `relay guard: missing handoff to: …` | the workspace's spec (or a `relay.toml` standing in for it) names a role this session never sent to | the
|
|
356
|
+
| `onlyne_complete` answers `relay guard: missing handoff to: …` | the workspace's spec (or a `relay.toml` standing in for it) names a role this session never sent to | routine notices appear in the `onlyne` panel; stderr keeps refusals such as `relay guard from …`, socket errors, timeouts and framing faults; `required=…` names the policy; `relay guard: missing handoff …` names the delivered set |
|
|
338
357
|
| `hello … forbidden` / connection closed right after `hello` | the mount role does not match the client's role | `hello.args.mount.role` vs the workspace's role |
|
|
339
358
|
| `frame_too_large` | a body above 8 MiB | only reachable through an oversize outbound image; the ceiling is the core's |
|
|
340
359
|
| tools missing | `pi.registerTool` is absent in that pi version | `/onlyne status`; the capability table above |
|
|
@@ -348,7 +367,7 @@ client injected none, §5).
|
|
|
348
367
|
## 9. Development
|
|
349
368
|
|
|
350
369
|
```bash
|
|
351
|
-
cd
|
|
370
|
+
cd plugins/onlyne-agent-pi
|
|
352
371
|
node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard
|
|
353
372
|
```
|
|
354
373
|
|
package/README.zh.md
CHANGED
|
@@ -47,7 +47,7 @@ pi 0.85.1 只加载这个写法。项目 `packages` 里的路径以 settings 文
|
|
|
47
47
|
```toml
|
|
48
48
|
# spec.toml
|
|
49
49
|
[server]
|
|
50
|
-
agent_package = "/abs/path/to/
|
|
50
|
+
agent_package = "/abs/path/to/plugins/onlyne-agent-pi" # 只在 generate 时读一次
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
```bash
|
|
@@ -57,7 +57,7 @@ onlyne server generate --root <server-root> --out <dir>
|
|
|
57
57
|
生成的 `.pi/settings.json` 形如:
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
|
-
{ "packages": ["../.onlyne/agent/pi
|
|
60
|
+
{ "packages": ["../.onlyne/agent/onlyne-agent-pi"] }
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
`pi list` 会把这条列在 “Project packages” 下。要验证真的加载了,就让复制进来的 `index.ts`
|
|
@@ -66,14 +66,25 @@ onlyne server generate --root <server-root> --out <dir>
|
|
|
66
66
|
### 手工(不经过 generate)
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
cp -R
|
|
70
|
-
printf '{"packages":["../.onlyne/agent/pi
|
|
69
|
+
cp -R plugins/onlyne-agent-pi <ws>/.onlyne/agent/onlyne-agent-pi
|
|
70
|
+
printf '{"packages":["../.onlyne/agent/onlyne-agent-pi"]}\n' > <ws>/.pi/settings.json
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
### 从 npm 装
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pi install npm:pi-onlyne # 用户级:这台机器上每个 pi 进程都会加载
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
发布名是 npm 上的 `pi-onlyne`,`pi install npm:pi-onlyne@<version>` 钉住某一版。这条路径会覆盖
|
|
80
|
+
普通交互会话,那里没有 `ONLYNE_ROLE`,扩展保持静默(见 §1 的身份门)。role workspace 想要面
|
|
81
|
+
板,不必装到全局:上面那份文件级复制、或者 `onlyne server generate`,都把插件限定在服务这个
|
|
82
|
+
role 的 workspace 里。
|
|
83
|
+
|
|
73
84
|
### 一次性 / 测试
|
|
74
85
|
|
|
75
86
|
```bash
|
|
76
|
-
pi --session-id <id> -e /abs/path/to/
|
|
87
|
+
pi --session-id <id> -e /abs/path/to/plugins/onlyne-agent-pi -ns -nc
|
|
77
88
|
```
|
|
78
89
|
|
|
79
90
|
### 开关文件
|
|
@@ -112,8 +123,13 @@ role。client 不读这个文件(计划 §11 已把旧 readiness 门降级为
|
|
|
112
123
|
| `sendMessage` | `session_start` | `welcome` 的 role prose 不再作为上下文注入;任务本身照常到达 |
|
|
113
124
|
| `appendEntry` | `session_start` | 不再写 `onlyne-assign` / `onlyne-complete` 会话条目 |
|
|
114
125
|
| `ui.setStatus` | 调用点保护 | 跳过 footer 状态行 |
|
|
126
|
+
| `ui.setWidget` | 调用点保护 | 日常通知继续走 footer 状态行与 `[pi-onlyne]` stderr 行 |
|
|
115
127
|
| `ctx.shutdown` | 调用点保护 | `recycle` 与 completion 照常结算任务;进程留给操作者自己关闭 |
|
|
116
128
|
|
|
129
|
+
### 活动面板
|
|
130
|
+
|
|
131
|
+
宿主报告有 UI 时(`ctx.hasUI`:TUI 与 RPC 模式为 true,print 与 JSON 模式为 false)且 `ctx.ui.setWidget` 可用,日常 onlyne 通知显示在编辑器上方,widget key 为 `onlyne`。标题行显示 role、连接状态、generation、当前 task id 与阶段。其下最多六条事件,按最新在前排列:`<=` 入站,`=>` 出站,`!!` 警告,`..` 状态,`~~` 重复投递。连续相同事件折成一行并带 `xN`;面板最多八行,每行最多 96 个显示单元,`session_shutdown` 时清除。
|
|
132
|
+
|
|
117
133
|
## 3. 工具面
|
|
118
134
|
|
|
119
135
|
仅在 onlyne session 内注册。
|
|
@@ -170,7 +186,7 @@ ack 之后、进程退出之前。completion 是按 client 手里的元组结算
|
|
|
170
186
|
事实——某个 role 有没有被触达——绝不看发出去的文本长什么样、写得好不好。
|
|
171
187
|
|
|
172
188
|
策略文件放在插件自己的 `package.json` 旁边,因此随 generate 出的工作区一起被带进去:生成的工作
|
|
173
|
-
区里是 `<ws>/.onlyne/agent/pi
|
|
189
|
+
区里是 `<ws>/.onlyne/agent/onlyne-agent-pi/relay.toml`,手工安装则是插件目录下的 `relay.toml`。
|
|
174
190
|
|
|
175
191
|
```toml
|
|
176
192
|
relay_required = ["writer"] # 这些 role 必须收到过接力
|
|
@@ -243,9 +259,10 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
243
259
|
`inject` 插件记录的重载。其他键记日志后忽略,绝不误读。
|
|
244
260
|
- **`frame_too_large` / `bad_frame`**:超限正文在写出任何字节之前就被拒;帧错误关闭连接并重
|
|
245
261
|
连。帧一旦损坏无法重新同步,这与 `crates/onlyne-frame/src/lib.rs` 的结论一致。
|
|
246
|
-
-
|
|
247
|
-
`reason: "duplicate"
|
|
248
|
-
|
|
262
|
+
- **投递按 envelope id 幂等,任务不按 id 一次性使用**:去重键是 envelope id。同一条投递重复
|
|
263
|
+
到达只注入一次,ack 带 `reason: "duplicate"`;正在运行的任务收到新 envelope,会作为新消息
|
|
264
|
+
注入同一个会话,工作记录保留自己的计数与转发账本,只把"自这条指令以来的轮数"看门狗归零。
|
|
265
|
+
client 每条 envelope 都发新 uuid,所以 `duplicate` 只在真正的重投上生效。
|
|
249
266
|
|
|
250
267
|
- **pane 绑定(Orca tab)。** 在 Orca pane 里,插件在每个 heartbeat 上报自己跑在哪:报告
|
|
251
268
|
`Observation` 里的 `observed.host.orca.pane_key`(`crates/onlyne-session/src/host.rs`),环境
|
|
@@ -290,7 +307,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
290
307
|
| `ready refused: internal: unknown session for …` | 插件为 client 从未暂存的任务报了 ready(手工起 pi 时的正常现象) | 让 client 拉起 pi,而不是手工起 |
|
|
291
308
|
| `assign` 一直不来 | client 的 `session_command` 没能拉起 pi,或 `inject` 被降级 | client 日志里的 spawn 行;`/onlyne status` 看能力集 |
|
|
292
309
|
| ledger 停在 `in_flight` | 没有 completion:没跑 turn,或 `agent_settled` 没触发 | pi session 文件里的 `onlyne-assign` / `onlyne-complete` 条目 |
|
|
293
|
-
| `onlyne_complete` 回答 `relay guard: missing handoff to: …` | 工作区的 spec(或顶替它的 `relay.toml`)点名了一个本会话从未触达的 role |
|
|
310
|
+
| `onlyne_complete` 回答 `relay guard: missing handoff to: …` | 工作区的 spec(或顶替它的 `relay.toml`)点名了一个本会话从未触达的 role | 日常通知显示在 `onlyne` 面板;stderr 保留 `relay guard from …` 等拒绝、socket 错误、超时与帧错误;`required=…` 说明策略;`relay guard: missing handoff …` 列出已投递集合 |
|
|
294
311
|
| `hello` 后立刻 `forbidden` / 断连 | mount role 与 client 的 role 不一致 | `hello.args.mount.role` 对该工作区的 role |
|
|
295
312
|
| `frame_too_large` | 正文超过 8 MiB | 只会由超限的出站图片触发;上限来自核心 |
|
|
296
313
|
| 工具缺失 | 该 pi 版本没有 `pi.registerTool` | `/onlyne status`;对照上面的能力表 |
|
|
@@ -304,7 +321,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
304
321
|
## 9. 开发与验证
|
|
305
322
|
|
|
306
323
|
```bash
|
|
307
|
-
cd
|
|
324
|
+
cd plugins/onlyne-agent-pi
|
|
308
325
|
node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫
|
|
309
326
|
```
|
|
310
327
|
|
package/package.json
CHANGED
package/src/activity.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** The widget key shared with pi-surface. */
|
|
2
|
+
export const WIDGET_KEY = "onlyne";
|
|
3
|
+
/** Render budget below pi's own ten-line cap. */
|
|
4
|
+
export const MAX_LINES = 8;
|
|
5
|
+
/** Line budget that keeps the panel inside one terminal row. */
|
|
6
|
+
export const MAX_WIDTH = 96;
|
|
7
|
+
/** Event text budget before the final line-width cap. */
|
|
8
|
+
export const MAX_TEXT = 72;
|
|
9
|
+
/** Retained newest-first activity history. */
|
|
10
|
+
export const KEEP_EVENTS = 64;
|
|
11
|
+
/** Default count of activity rows below the header. */
|
|
12
|
+
export const SHOW_EVENTS = 6;
|
|
13
|
+
|
|
14
|
+
const MARKERS = {
|
|
15
|
+
in: "<=",
|
|
16
|
+
dup: "~~",
|
|
17
|
+
out: "=>",
|
|
18
|
+
warn: "!!",
|
|
19
|
+
state: "..",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const clean = (value) => String(value ?? "").replace(/\s+/g, " ").trim();
|
|
23
|
+
const points = (value) => Array.from(String(value ?? ""));
|
|
24
|
+
|
|
25
|
+
function cut(value, width, mark = true) {
|
|
26
|
+
const chars = points(value);
|
|
27
|
+
if (chars.length <= width) return chars.join("");
|
|
28
|
+
if (width <= 0) return "";
|
|
29
|
+
if (!mark || width === 1) return chars.slice(0, width).join("");
|
|
30
|
+
return `${chars.slice(0, width - 1).join("")}…`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function noteText(value) {
|
|
34
|
+
const text = clean(value);
|
|
35
|
+
const chars = points(text);
|
|
36
|
+
return chars.length > MAX_TEXT ? `${chars.slice(0, MAX_TEXT).join("")}…` : text;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function timeOf(value) {
|
|
40
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
41
|
+
const two = (number) => String(number).padStart(2, "0");
|
|
42
|
+
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function headerLine(state) {
|
|
46
|
+
const role = clean(state.role);
|
|
47
|
+
const connection = clean(state.connection) || "connecting";
|
|
48
|
+
const first = role ? `onlyne ${cut(role, 16, false)}` : "onlyne";
|
|
49
|
+
const parts = [first, connection];
|
|
50
|
+
if (state.generation !== undefined && state.generation !== null) parts.push(`gen ${state.generation}`);
|
|
51
|
+
const taskId = clean(state.taskId);
|
|
52
|
+
if (taskId) {
|
|
53
|
+
const phase = clean(state.phase);
|
|
54
|
+
parts.push(`task ${cut(taskId, 8, false)}${phase ? ` ${phase}` : ""}`);
|
|
55
|
+
}
|
|
56
|
+
return cut(parts.join(" · "), MAX_WIDTH);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function eventLine(event) {
|
|
60
|
+
const marker = MARKERS[event.kind] ?? MARKERS.state;
|
|
61
|
+
const count = event.repeats > 0 ? ` x${event.repeats + 1}` : "";
|
|
62
|
+
return cut(`${timeOf(event.at)} ${marker} ${event.text}${count}`, MAX_WIDTH);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Activity is a pure panel model: header state plus newest-first event history.
|
|
67
|
+
* `lines()` reads stored state only, so identical state gives identical output.
|
|
68
|
+
*/
|
|
69
|
+
export function createActivity({ maxEvents = SHOW_EVENTS, clock = () => new Date() } = {}) {
|
|
70
|
+
const state = { connection: "connecting" };
|
|
71
|
+
const events = [];
|
|
72
|
+
const shown = Math.max(0, Math.min(Number(maxEvents) || 0, MAX_LINES - 1));
|
|
73
|
+
const api = {
|
|
74
|
+
note(kind, text) {
|
|
75
|
+
const next = noteText(text);
|
|
76
|
+
const current = events[0];
|
|
77
|
+
if (current && current.kind === kind && current.text === next) {
|
|
78
|
+
current.repeats += 1;
|
|
79
|
+
current.at = clock();
|
|
80
|
+
return api;
|
|
81
|
+
}
|
|
82
|
+
events.unshift({ at: clock(), kind, text: next, repeats: 0 });
|
|
83
|
+
if (events.length > KEEP_EVENTS) events.length = KEEP_EVENTS;
|
|
84
|
+
return api;
|
|
85
|
+
},
|
|
86
|
+
set(patch = {}) {
|
|
87
|
+
Object.assign(state, patch);
|
|
88
|
+
return api;
|
|
89
|
+
},
|
|
90
|
+
lines() {
|
|
91
|
+
return [headerLine(state), ...events.slice(0, shown).map(eventLine)].slice(0, MAX_LINES);
|
|
92
|
+
},
|
|
93
|
+
events,
|
|
94
|
+
};
|
|
95
|
+
return api;
|
|
96
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { createActivity, MAX_LINES, MAX_TEXT, MAX_WIDTH, SHOW_EVENTS } from "./activity.mjs";
|
|
5
|
+
|
|
6
|
+
const clock = () => new Date("2026-09-13T09:08:07");
|
|
7
|
+
|
|
8
|
+
test("header renders before welcome and after state updates", () => {
|
|
9
|
+
const activity = createActivity({ clock }).set({ role: "planner" });
|
|
10
|
+
assert.deepEqual(activity.lines(), ["onlyne planner · connecting"]);
|
|
11
|
+
|
|
12
|
+
activity.set({ connection: "connected", generation: 3, taskId: "abcdef012345", phase: "running" });
|
|
13
|
+
assert.equal(activity.lines()[0], "onlyne planner · connected · gen 3 · task abcdef01 running");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("task tail is absent with no task", () => {
|
|
17
|
+
const activity = createActivity({ clock }).set({ role: "planner", connection: "connected", generation: 2, phase: "running" });
|
|
18
|
+
assert.equal(activity.lines()[0], "onlyne planner · connected · gen 2");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("matching events merge and new text gets a new row", () => {
|
|
22
|
+
const activity = createActivity({ clock });
|
|
23
|
+
activity.note("warn", "x").note("warn", "x");
|
|
24
|
+
assert.equal(activity.events.length, 1);
|
|
25
|
+
assert.equal(activity.events[0].repeats, 1);
|
|
26
|
+
assert.match(activity.lines()[1], /09:08:07 !! x x2$/);
|
|
27
|
+
|
|
28
|
+
activity.note("warn", "y");
|
|
29
|
+
assert.equal(activity.events.length, 2);
|
|
30
|
+
assert.match(activity.lines()[1], /09:08:07 !! y$/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("long event text is cut at MAX_TEXT", () => {
|
|
34
|
+
const activity = createActivity({ clock });
|
|
35
|
+
activity.note("state", "a".repeat(MAX_TEXT + 10));
|
|
36
|
+
assert.equal(Array.from(activity.events[0].text).length, MAX_TEXT + 1);
|
|
37
|
+
assert.ok(activity.events[0].text.endsWith("…"));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("hostile input stays inside render bounds", () => {
|
|
41
|
+
const activity = createActivity({ maxEvents: 100, clock }).set({
|
|
42
|
+
role: "r".repeat(48),
|
|
43
|
+
connection: "connected",
|
|
44
|
+
generation: 999,
|
|
45
|
+
taskId: "1234567890abcdef",
|
|
46
|
+
phase: "running",
|
|
47
|
+
});
|
|
48
|
+
for (let index = 0; index < 100; index += 1) activity.note("warn", `${index} ${"x".repeat(5_000)}`);
|
|
49
|
+
|
|
50
|
+
const lines = activity.lines();
|
|
51
|
+
assert.ok(lines.length <= MAX_LINES, `line count ${lines.length}`);
|
|
52
|
+
for (const line of lines) assert.ok(Array.from(line).length <= MAX_WIDTH, line);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("identical state gives identical output", () => {
|
|
56
|
+
const activity = createActivity({ clock }).set({ role: "planner", connection: "connected" });
|
|
57
|
+
activity.note("in", "build it");
|
|
58
|
+
assert.deepEqual(activity.lines(), activity.lines());
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("default render shows the newest SHOW_EVENTS events", () => {
|
|
62
|
+
const activity = createActivity({ clock });
|
|
63
|
+
for (let index = 0; index < SHOW_EVENTS + 4; index += 1) activity.note("state", `event ${index}`);
|
|
64
|
+
|
|
65
|
+
const lines = activity.lines();
|
|
66
|
+
assert.equal(lines.length, SHOW_EVENTS + 1);
|
|
67
|
+
assert.match(lines[1], /event 9$/);
|
|
68
|
+
assert.match(lines.at(-1), new RegExp(`event ${SHOW_EVENTS + 4 - SHOW_EVENTS}$`));
|
|
69
|
+
});
|
package/src/agent.mjs
CHANGED
|
@@ -7,15 +7,23 @@
|
|
|
7
7
|
// module decides *what* the protocol says and hands the *effects* to the
|
|
8
8
|
// surface, which is why the whole state machine is testable against a plain
|
|
9
9
|
// Node unix socket.
|
|
10
|
+
//
|
|
11
|
+
// What the human sees travels through one funnel, `notice` below: the pi widget
|
|
12
|
+
// panel when the host has `ctx.ui.setWidget`, the footer status line plus a
|
|
13
|
+
// `[pi-onlyne]` stderr line when it has not. `log` keeps the diagnostics — a
|
|
14
|
+
// refused report, a socket error, a timeout, a framing fault — because those
|
|
15
|
+
// matter to a host with no panel at all.
|
|
10
16
|
|
|
11
17
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
18
|
import { createConnection } from "node:net";
|
|
13
|
-
import { join } from "node:path";
|
|
19
|
+
import { basename, join } from "node:path";
|
|
14
20
|
import { createFrameDecoder, encodeFrame } from "./frame.mjs";
|
|
21
|
+
import { createActivity } from "./activity.mjs";
|
|
15
22
|
import {
|
|
16
23
|
DEFAULT_HEARTBEAT_MS,
|
|
17
24
|
assignAckArgs,
|
|
18
25
|
completeReport,
|
|
26
|
+
describePrincipal,
|
|
19
27
|
detachArgs,
|
|
20
28
|
heartbeatReport,
|
|
21
29
|
helloArgs,
|
|
@@ -77,6 +85,7 @@ export class OnlyneAgent {
|
|
|
77
85
|
* taskId: string,
|
|
78
86
|
* surface: any,
|
|
79
87
|
* log?: (line: string, data?: unknown) => void,
|
|
88
|
+
* activity?: { note: (kind: string, text: string) => any, set: (patch: any) => any, lines: () => string[], events: any[] },
|
|
80
89
|
* relay?: { required?: string[], count?: number | null },
|
|
81
90
|
* capabilities?: string[],
|
|
82
91
|
* heartbeatMs?: number,
|
|
@@ -95,6 +104,9 @@ export class OnlyneAgent {
|
|
|
95
104
|
this.sessionId = options.sessionId;
|
|
96
105
|
this.envTaskId = options.taskId;
|
|
97
106
|
this.surface = options.surface;
|
|
107
|
+
/** The widget panel's model (`activity.mjs`); seeded with the local role. */
|
|
108
|
+
this.activity = options.activity ?? createActivity();
|
|
109
|
+
this.activity.set({ role: options.role });
|
|
98
110
|
this.log = options.log ?? (() => {});
|
|
99
111
|
/** The relay guard's policy; the default guards nothing (`relay.mjs`). */
|
|
100
112
|
this.relay = options.relay ?? DEFAULT_RELAY;
|
|
@@ -140,7 +152,13 @@ export class OnlyneAgent {
|
|
|
140
152
|
* sent — the guard judges this session's own deliveries, not history.
|
|
141
153
|
*/
|
|
142
154
|
this.deliveredTo = new Set();
|
|
143
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The deliveries this process has already handed to the model, keyed by
|
|
157
|
+
* envelope id (a delivery's own identity). Keying it by task id would swallow
|
|
158
|
+
* every later envelope for a live task — the follow-up that never arrives —
|
|
159
|
+
* and the guard below answers a true re-delivery with `duplicate`.
|
|
160
|
+
*/
|
|
161
|
+
this.injectedDeliveries = new Set();
|
|
144
162
|
this.deliveredProse = new Set();
|
|
145
163
|
/** Pushes that arrived before the handshake finished; see `onFrame`. */
|
|
146
164
|
this.handshaking = false;
|
|
@@ -181,7 +199,8 @@ export class OnlyneAgent {
|
|
|
181
199
|
}
|
|
182
200
|
this.connected = false;
|
|
183
201
|
this.socket = null;
|
|
184
|
-
this.
|
|
202
|
+
this.activity.set({ connection: "detached", taskId: null });
|
|
203
|
+
this.notice("state", `detached (${reason})`);
|
|
185
204
|
}
|
|
186
205
|
|
|
187
206
|
/** One line for `/onlyne status`. */
|
|
@@ -196,6 +215,7 @@ export class OnlyneAgent {
|
|
|
196
215
|
tasks: this.activeTasks().map((task) => task.taskId),
|
|
197
216
|
pendingCompletion: this.pendingCompletion?.taskId ?? null,
|
|
198
217
|
lastError: this.lastError ? String(this.lastError.message ?? this.lastError) : null,
|
|
218
|
+
activity: this.activity.events.slice(0, 5),
|
|
199
219
|
stats: { ...this.stats },
|
|
200
220
|
};
|
|
201
221
|
}
|
|
@@ -253,7 +273,8 @@ export class OnlyneAgent {
|
|
|
253
273
|
this.deferredPushes = [];
|
|
254
274
|
this.rejectAll("connection lost");
|
|
255
275
|
this.stopHeartbeat();
|
|
256
|
-
this.
|
|
276
|
+
this.activity.set({ connection: this.closed ? "detached" : "reconnecting" });
|
|
277
|
+
this.notice("state", this.closed ? "detached" : "reconnecting");
|
|
257
278
|
if (socket && !socket.destroyed) socket.destroy();
|
|
258
279
|
this.scheduleReconnect();
|
|
259
280
|
}
|
|
@@ -263,7 +284,6 @@ export class OnlyneAgent {
|
|
|
263
284
|
const delay = this.ladder[Math.min(this.attempt, this.ladder.length - 1)];
|
|
264
285
|
this.attempt += 1;
|
|
265
286
|
this.stats.reconnects += 1;
|
|
266
|
-
this.log(`reconnecting in ${delay}ms`);
|
|
267
287
|
this.reconnectHandle = this.timer.set(() => {
|
|
268
288
|
this.reconnectHandle = null;
|
|
269
289
|
if (!this.closed) this.connect();
|
|
@@ -274,7 +294,6 @@ export class OnlyneAgent {
|
|
|
274
294
|
this.attempt = 0;
|
|
275
295
|
// From here until the welcome is adopted, pushes queue instead of running.
|
|
276
296
|
this.handshaking = true;
|
|
277
|
-
this.log("connected; sending hello");
|
|
278
297
|
const args = helloArgs({
|
|
279
298
|
role: this.role,
|
|
280
299
|
session: this.sessionId,
|
|
@@ -301,9 +320,16 @@ export class OnlyneAgent {
|
|
|
301
320
|
// A fresh connection has reported nothing: the next beat is news.
|
|
302
321
|
this.lastPhase = null;
|
|
303
322
|
this.agentState = this.tasks.size > 0 ? "idle" : "ready";
|
|
304
|
-
this.
|
|
305
|
-
|
|
306
|
-
|
|
323
|
+
this.activity.set({
|
|
324
|
+
role: welcome.role,
|
|
325
|
+
connection: "connected",
|
|
326
|
+
generation: welcome.generation,
|
|
327
|
+
// A reconnect inherits the task this process serves: the panel says so
|
|
328
|
+
// from the first beat, with the assignment still queued behind the hello.
|
|
329
|
+
taskId: this.activeTaskId() ?? null,
|
|
330
|
+
phase: this.agentState,
|
|
331
|
+
});
|
|
332
|
+
this.notice("state", `connected role=${welcome.role} gen=${welcome.generation} host=${welcome.hostCapabilities.join(",")}`);
|
|
307
333
|
const prose = welcome.prose.trim();
|
|
308
334
|
if (prose && !this.deliveredProse.has(prose)) {
|
|
309
335
|
this.deliveredProse.add(prose);
|
|
@@ -408,7 +434,7 @@ export class OnlyneAgent {
|
|
|
408
434
|
else if (op === "recycle") void this.onRecycle(args);
|
|
409
435
|
else if (op === "config_get") void this.onConfigGet(args);
|
|
410
436
|
else if (op === "bye") {
|
|
411
|
-
this.
|
|
437
|
+
this.notice("state", `host bye: ${args.reason ?? "unspecified"}`);
|
|
412
438
|
this.dropSocket();
|
|
413
439
|
} else if (op) this.log(`ignoring host op ${op}`);
|
|
414
440
|
}
|
|
@@ -426,6 +452,25 @@ export class OnlyneAgent {
|
|
|
426
452
|
this.log(`socket error: ${error?.message ?? error}`);
|
|
427
453
|
}
|
|
428
454
|
|
|
455
|
+
/**
|
|
456
|
+
* One human-facing event: record it, then draw the best host surface.
|
|
457
|
+
* Hosts with `ctx.ui.setWidget` redraw the panel. Other hosts keep a footer
|
|
458
|
+
* line and a `[pi-onlyne]` stderr line.
|
|
459
|
+
*
|
|
460
|
+
* @param {"in" | "dup" | "out" | "warn" | "state"} kind
|
|
461
|
+
* @param {string} text
|
|
462
|
+
*/
|
|
463
|
+
notice(kind, text) {
|
|
464
|
+
this.activity.note(kind, text);
|
|
465
|
+
if (this.surface.available?.widget) {
|
|
466
|
+
this.surface.widget?.(this.activity.lines());
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
// No panel: the footer carries the steady header line, stderr the event.
|
|
470
|
+
this.surface.status?.(this.activity.lines()[0]);
|
|
471
|
+
this.log(`${kind}: ${text}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
429
474
|
// --------------------------------------------------------------- reports
|
|
430
475
|
|
|
431
476
|
async reportReady() {
|
|
@@ -440,7 +485,7 @@ export class OnlyneAgent {
|
|
|
440
485
|
seq: this.seq,
|
|
441
486
|
}));
|
|
442
487
|
this.stats.reports += 1;
|
|
443
|
-
this.
|
|
488
|
+
this.notice("state", `ready ${taskId.slice(0, 8)}`);
|
|
444
489
|
} catch (error) {
|
|
445
490
|
this.log(`ready refused: ${error.message}`);
|
|
446
491
|
}
|
|
@@ -525,13 +570,16 @@ export class OnlyneAgent {
|
|
|
525
570
|
this.log("assign carried no task id; ignored");
|
|
526
571
|
return;
|
|
527
572
|
}
|
|
528
|
-
|
|
573
|
+
// The delivery's own identity, so a re-offer of the same message is caught
|
|
574
|
+
// and a genuinely new message for a running task gets through.
|
|
575
|
+
const deliveryId = envelope.id ?? `task:${taskId}`;
|
|
576
|
+
if (this.injectedDeliveries.has(deliveryId)) {
|
|
529
577
|
this.stats.duplicates += 1;
|
|
530
|
-
this.
|
|
578
|
+
this.notice("dup", `~~ task ${taskId.slice(0, 8)} re-delivered, already injected`);
|
|
531
579
|
await this.ack(taskId, true, "duplicate");
|
|
532
580
|
return;
|
|
533
581
|
}
|
|
534
|
-
this.
|
|
582
|
+
this.injectedDeliveries.add(deliveryId);
|
|
535
583
|
this.stats.assigns += 1;
|
|
536
584
|
if (typeof args.generation === "number") this.generation = args.generation;
|
|
537
585
|
|
|
@@ -541,20 +589,33 @@ export class OnlyneAgent {
|
|
|
541
589
|
if (proseIsNew) this.deliveredProse.add(prose);
|
|
542
590
|
const text = injectionText({ assign: { ...args, task_id: taskId }, proseIsNew, attachmentPaths: attachments.map((item) => item.path) });
|
|
543
591
|
|
|
544
|
-
this.tasks.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// a
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
592
|
+
const held = this.tasks.get(taskId);
|
|
593
|
+
// A completed record is not a live one: a new envelope for a task that
|
|
594
|
+
// already settled is fresh work under an old id, so it gets a fresh record.
|
|
595
|
+
if (held && !held.completed) {
|
|
596
|
+
// A new envelope for a task this session already holds — a follow-up, a
|
|
597
|
+
// redirect, a bounce back through a relay. The work record stays where it
|
|
598
|
+
// is: its delivered set keeps the relay guard's count, and its completion
|
|
599
|
+
// state still settles the task. Only the "since this instruction" counter
|
|
600
|
+
// moves, so the settled-without-completing watchdog measures the newest one.
|
|
601
|
+
held.turnsSinceAssign = 0;
|
|
602
|
+
held.envelopeId = envelope.id ?? held.envelopeId;
|
|
603
|
+
if (held.failed) held.failed = false;
|
|
604
|
+
} else {
|
|
605
|
+
this.tasks.set(taskId, {
|
|
606
|
+
taskId,
|
|
607
|
+
envelopeId: envelope.id ?? null,
|
|
608
|
+
// Who handed this task over: the relay guard's count mode does not count
|
|
609
|
+
// a send straight back to it (`relay.mjs`).
|
|
610
|
+
upstream: envelope.from?.role?.role ?? null,
|
|
611
|
+
turnsSinceAssign: 0,
|
|
612
|
+
turns: 0,
|
|
613
|
+
errored: false,
|
|
614
|
+
head: "",
|
|
615
|
+
failed: false,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
556
618
|
this.agentState = "running";
|
|
557
|
-
this.log(`assign ${taskId} from ${JSON.stringify(envelope.from ?? null)}; injecting ${text.length} chars`);
|
|
558
619
|
this.surface.wakeUser?.(text, attachments.map((item) => item.part));
|
|
559
620
|
this.surface.customEntry?.("onlyne-assign", {
|
|
560
621
|
taskId,
|
|
@@ -564,7 +625,8 @@ export class OnlyneAgent {
|
|
|
564
625
|
prose,
|
|
565
626
|
attachments: attachments.map((item) => item.path),
|
|
566
627
|
});
|
|
567
|
-
this.
|
|
628
|
+
this.activity.set({ taskId, phase: "running" });
|
|
629
|
+
this.notice("in", `task ${taskId.slice(0, 8)} from ${describePrincipal(envelope.from)} (${envelope.kind ?? "task"}): ${headOf(envelope.body?.text)}`);
|
|
568
630
|
this.startHeartbeat();
|
|
569
631
|
await this.ack(taskId, true, null);
|
|
570
632
|
}
|
|
@@ -581,7 +643,7 @@ export class OnlyneAgent {
|
|
|
581
643
|
async probe() {
|
|
582
644
|
try {
|
|
583
645
|
await this.heartbeat();
|
|
584
|
-
this.
|
|
646
|
+
this.notice("state", "probe answered with a heartbeat");
|
|
585
647
|
} catch (error) {
|
|
586
648
|
this.log(`probe heartbeat refused: ${error.message}`);
|
|
587
649
|
}
|
|
@@ -591,7 +653,7 @@ export class OnlyneAgent {
|
|
|
591
653
|
const task = stdinTaskText(args);
|
|
592
654
|
if (task) {
|
|
593
655
|
// The no-`inject` route: the host hands the payload over as a config key.
|
|
594
|
-
this.
|
|
656
|
+
this.notice("in", `stdin task: ${headOf(task.text)}`);
|
|
595
657
|
this.surface.wakeUser?.(`[onlyne] task body (delivered as stdin):\n\n${task.text}`, []);
|
|
596
658
|
return;
|
|
597
659
|
}
|
|
@@ -601,7 +663,7 @@ export class OnlyneAgent {
|
|
|
601
663
|
async onRecycle(args) {
|
|
602
664
|
this.stats.recycles += 1;
|
|
603
665
|
const taskId = args.task_id ?? this.activeTaskId();
|
|
604
|
-
this.
|
|
666
|
+
this.notice("state", `recycled: ${args.reason ?? "operator"}`);
|
|
605
667
|
if (taskId && args.outcome && this.tasks.get(taskId) && !this.tasks.get(taskId).completed) {
|
|
606
668
|
await this.complete(taskId, args.outcome, `recycled: ${args.reason ?? "operator"}`, {
|
|
607
669
|
exitProcess: false,
|
|
@@ -746,10 +808,11 @@ export class OnlyneAgent {
|
|
|
746
808
|
const reason = headOf(input.reason);
|
|
747
809
|
if (input.force !== true || !reason) {
|
|
748
810
|
this.log(refusal);
|
|
811
|
+
this.notice("warn", refusal);
|
|
749
812
|
throw new Error(`onlyne: ${refusal}`);
|
|
750
813
|
}
|
|
751
814
|
head = headOf(`${FORCED_PREFIX}${reason}${explicit ? ` | ${explicit}` : ""}`);
|
|
752
|
-
this.
|
|
815
|
+
this.notice("warn", `relay guard waived: ${reason}`);
|
|
753
816
|
}
|
|
754
817
|
}
|
|
755
818
|
return this.complete(taskId, normalizeOutcome(input.outcome), head);
|
|
@@ -781,15 +844,15 @@ export class OnlyneAgent {
|
|
|
781
844
|
const report = completeReport({ taskId, outcome: normalized, head: summary });
|
|
782
845
|
if (!this.connected) {
|
|
783
846
|
this.pendingCompletion = { taskId, report, outcome: normalized, exitProcess };
|
|
784
|
-
this.
|
|
785
|
-
this.
|
|
847
|
+
this.activity.set({ taskId, phase: `${normalized} queued` });
|
|
848
|
+
this.notice("warn", `complete ${taskId.slice(0, 8)} ${normalized} queued: socket down`);
|
|
786
849
|
return { taskId, outcome: normalized, head: summary, queued: true };
|
|
787
850
|
}
|
|
788
851
|
await this.request("report", report);
|
|
789
852
|
this.stats.completions += 1;
|
|
790
|
-
this.surface.status?.(`onlyne: ${taskId.slice(0, 8)} ${normalized}`);
|
|
791
853
|
this.surface.customEntry?.("onlyne-complete", { taskId, outcome: normalized, head: summary });
|
|
792
|
-
this.
|
|
854
|
+
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: normalized });
|
|
855
|
+
this.notice("out", `complete ${taskId.slice(0, 8)} ${normalized}${summary ? `: ${summary}` : ""}`);
|
|
793
856
|
if (this.activeTasks().length === 0) {
|
|
794
857
|
if (exitProcess) {
|
|
795
858
|
await this.reportSettled(taskId, normalized).catch((error) =>
|
|
@@ -841,7 +904,6 @@ export class OnlyneAgent {
|
|
|
841
904
|
}));
|
|
842
905
|
this.stats.reports += 1;
|
|
843
906
|
this.lastPhase = "idle";
|
|
844
|
-
this.log(`settled observation for ${taskId}: agent idle, outcome ${outcome}`);
|
|
845
907
|
return true;
|
|
846
908
|
}
|
|
847
909
|
|
|
@@ -852,7 +914,8 @@ export class OnlyneAgent {
|
|
|
852
914
|
try {
|
|
853
915
|
await this.request("report", pending.report);
|
|
854
916
|
this.stats.completions += 1;
|
|
855
|
-
this.
|
|
917
|
+
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
|
|
918
|
+
this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
|
|
856
919
|
if (pending.exitProcess && this.activeTasks().length === 0) {
|
|
857
920
|
await this.reportSettled(pending.taskId, pending.outcome).catch((error) =>
|
|
858
921
|
this.log(`settled observation refused: ${error.message}`),
|
|
@@ -903,7 +966,7 @@ export class OnlyneAgent {
|
|
|
903
966
|
const bytes = Buffer.from(image.data_base64, "base64");
|
|
904
967
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
905
968
|
writeFileSync(path, bytes, { mode: 0o600 });
|
|
906
|
-
this.
|
|
969
|
+
this.notice("state", `attachment ${basename(path)}`);
|
|
907
970
|
return [{ path, part: { type: "image", mime, data: image.data_base64, name } }];
|
|
908
971
|
} catch (error) {
|
|
909
972
|
this.log(`attachment write failed: ${error.message}`);
|
package/src/agent.test.mjs
CHANGED
|
@@ -11,10 +11,13 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { afterEach, test } from "node:test";
|
|
12
12
|
|
|
13
13
|
import { OnlyneAgent } from "./agent.mjs";
|
|
14
|
+
import { MAX_LINES, MAX_WIDTH } from "./activity.mjs";
|
|
14
15
|
import { createFrameDecoder, encodeFrame } from "./frame.mjs";
|
|
15
16
|
import { SEQ_BASE, readyReport } from "./protocol.mjs";
|
|
16
17
|
|
|
17
18
|
const VECTOR_DIR = fileURLToPath(new URL("../../../crates/onlyne-proto/tests/wire_vectors/", import.meta.url));
|
|
19
|
+
// The hello version is the manifest's, so a release bump travels here once.
|
|
20
|
+
const PACKAGE_VERSION = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
|
|
18
21
|
const ASSIGN_FRAME = existsSync(VECTOR_DIR)
|
|
19
22
|
? JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}adapter_host_assign.json`, "utf8")).frame)
|
|
20
23
|
: null;
|
|
@@ -155,13 +158,15 @@ class FakeHost {
|
|
|
155
158
|
|
|
156
159
|
/** The effect surface the agent drives, recorded for assertions. */
|
|
157
160
|
function fakeSurface(options = {}) {
|
|
158
|
-
const calls = { wakeUser: [], prose: [], entries: [], status: [], exits: [] };
|
|
161
|
+
const calls = { wakeUser: [], prose: [], entries: [], status: [], exits: [], widget: [] };
|
|
159
162
|
return {
|
|
160
163
|
calls,
|
|
161
164
|
available: {
|
|
162
165
|
wakeUser: true,
|
|
163
166
|
proseContext: true,
|
|
164
167
|
customEntry: true,
|
|
168
|
+
// Off by default: tests that do not opt into a panel keep the footer and log path.
|
|
169
|
+
widget: options.widget === true,
|
|
165
170
|
status: true,
|
|
166
171
|
exit: true,
|
|
167
172
|
isIdle: true,
|
|
@@ -181,6 +186,8 @@ function fakeSurface(options = {}) {
|
|
|
181
186
|
return true;
|
|
182
187
|
},
|
|
183
188
|
status: (text) => calls.status.push(text),
|
|
189
|
+
/** Every panel render, newest last; an `undefined` entry is a clear. */
|
|
190
|
+
widget: (lines) => calls.widget.push(lines),
|
|
184
191
|
welcome: () => {},
|
|
185
192
|
isIdle: () => (options.idle === undefined ? true : options.idle()),
|
|
186
193
|
exit: (reason) => calls.exits.push(reason),
|
|
@@ -399,7 +406,7 @@ test("a fresh agent opens with hello, registers and reports ready", async () =>
|
|
|
399
406
|
assert.deepEqual(hello, {
|
|
400
407
|
protocol: 1,
|
|
401
408
|
plugin: "pi-onlyne",
|
|
402
|
-
version:
|
|
409
|
+
version: PACKAGE_VERSION,
|
|
403
410
|
kind: "agent",
|
|
404
411
|
capabilities: ["register", "report", "inject", "recycle"],
|
|
405
412
|
mount: { role: "planner", session: SESSION_ID, task_id: TASK_ID, pid: process.pid },
|
|
@@ -451,6 +458,50 @@ test("an assign is injected once, acked, and a redelivery changes nothing", asyn
|
|
|
451
458
|
assert.deepEqual(second[1], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
|
|
452
459
|
});
|
|
453
460
|
|
|
461
|
+
// A follow-up arrives as its own envelope under the task id the session is
|
|
462
|
+
// already running. Keying the injection guard on the task swallowed it: the
|
|
463
|
+
// panel said `already injected`, nothing reached the model, and the row the
|
|
464
|
+
// server kept re-offering never settled.
|
|
465
|
+
test("a new envelope for a running task reaches the model and keeps its record", async () => {
|
|
466
|
+
const { agent, host, surface } = await startAgent();
|
|
467
|
+
agent.start();
|
|
468
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
469
|
+
|
|
470
|
+
host.notify("assign", assignArgs());
|
|
471
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
472
|
+
const record = agent.tasks.get(TASK_ID);
|
|
473
|
+
// Turns already run under this task, without driving the turn hooks: a real
|
|
474
|
+
// turn end also arms the settle fallback, which would complete the task.
|
|
475
|
+
record.turns = 3;
|
|
476
|
+
record.turnsSinceAssign = 2;
|
|
477
|
+
|
|
478
|
+
const base = assignArgs();
|
|
479
|
+
const follow = {
|
|
480
|
+
...base,
|
|
481
|
+
envelope: {
|
|
482
|
+
...base.envelope,
|
|
483
|
+
id: "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172",
|
|
484
|
+
body: { text: "actually, use the q8 variant" },
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
host.notify("assign", follow);
|
|
488
|
+
await waitFor(() => (surface.calls.wakeUser.length === 2 ? true : null));
|
|
489
|
+
assert.match(surface.calls.wakeUser[1].text, /q8 variant/);
|
|
490
|
+
const acks = await waitFor(() => (host.of("assign_ack").length === 2 ? host.of("assign_ack") : null));
|
|
491
|
+
assert.deepEqual(acks[1], { task_id: TASK_ID, accepted: true });
|
|
492
|
+
|
|
493
|
+
assert.equal(agent.tasks.get(TASK_ID), record, "the running work record survives");
|
|
494
|
+
assert.equal(record.turns, 3, "what the session already did under this task is not erased");
|
|
495
|
+
assert.equal(record.turnsSinceAssign, 0, "the watchdog counts from the newest instruction");
|
|
496
|
+
assert.equal(record.envelopeId, "4a3b2c1d-6e7f-4a90-8b1c-2d3e4f506172");
|
|
497
|
+
|
|
498
|
+
// The same envelope again is the true duplicate, and it changes nothing.
|
|
499
|
+
host.notify("assign", follow);
|
|
500
|
+
const third = await waitFor(() => (host.of("assign_ack").length === 3 ? host.of("assign_ack") : null));
|
|
501
|
+
assert.equal(surface.calls.wakeUser.length, 2);
|
|
502
|
+
assert.deepEqual(third[2], { task_id: TASK_ID, accepted: true, reason: "duplicate" });
|
|
503
|
+
});
|
|
504
|
+
|
|
454
505
|
// The live case in crates/onlyne-testkit/e2e/pi-live.sh found this: the client
|
|
455
506
|
// hands over a staged session by writing the hello reply and the first assign
|
|
456
507
|
// together, so both frames arrive in one read. The assignment must not be
|
|
@@ -1077,3 +1128,77 @@ test("the handoff ledger belongs to the session, not to one task", async () => {
|
|
|
1077
1128
|
assert.equal(completions(host).length, 1);
|
|
1078
1129
|
});
|
|
1079
1130
|
|
|
1131
|
+
// ---------------------------------------------------------------- the panel
|
|
1132
|
+
|
|
1133
|
+
test("a pi with the widget gets the panel and keeps a quiet scrollback", async () => {
|
|
1134
|
+
const surface = fakeSurface({ widget: true });
|
|
1135
|
+
const { agent, host, logs } = await startAgent({ surface });
|
|
1136
|
+
agent.start();
|
|
1137
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1138
|
+
host.notify("assign", assignArgs());
|
|
1139
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
1140
|
+
|
|
1141
|
+
const [render] = surface.calls.widget.slice(-1);
|
|
1142
|
+
// Header first, then the newest event: the assignment the host just handed over.
|
|
1143
|
+
assert.match(render[0], /^onlyne planner · connected · gen 1 · task 11111111 running$/);
|
|
1144
|
+
assert.match(render[1], /^\d\d:\d\d:\d\d <= task 11111111 from role:planner \(task\)/);
|
|
1145
|
+
assert.equal(agent.status().activity[0].kind, "in");
|
|
1146
|
+
// The point of the panel: a routine notice reaches the screen through it, once.
|
|
1147
|
+
assert.deepEqual(logs, [], "nothing about a healthy session reaches stderr");
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
test("a repeating fault folds into one panel line with a count", () => {
|
|
1151
|
+
const surface = fakeSurface({ widget: true });
|
|
1152
|
+
const agent = new OnlyneAgent({
|
|
1153
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1154
|
+
cwd: tmpdir(),
|
|
1155
|
+
role: "planner",
|
|
1156
|
+
sessionId: SESSION_ID,
|
|
1157
|
+
taskId: TASK_ID,
|
|
1158
|
+
surface,
|
|
1159
|
+
log: (line) => {
|
|
1160
|
+
throw new Error(`a host with the panel writes no stderr line: ${line}`);
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1164
|
+
agent.notice("warn", "heartbeat refused: connection lost");
|
|
1165
|
+
}
|
|
1166
|
+
const [render] = surface.calls.widget.slice(-1);
|
|
1167
|
+
assert.match(render[1], /!! heartbeat refused: connection lost x3$/);
|
|
1168
|
+
assert.equal(surface.calls.widget.length, 3, "every event redraws the same panel");
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("the panel stays inside its line and width budget", () => {
|
|
1172
|
+
const surface = fakeSurface({ widget: true });
|
|
1173
|
+
const agent = new OnlyneAgent({
|
|
1174
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1175
|
+
cwd: tmpdir(),
|
|
1176
|
+
role: "planner",
|
|
1177
|
+
sessionId: SESSION_ID,
|
|
1178
|
+
taskId: TASK_ID,
|
|
1179
|
+
surface,
|
|
1180
|
+
log: () => {},
|
|
1181
|
+
});
|
|
1182
|
+
for (let index = 0; index < 40; index += 1) {
|
|
1183
|
+
agent.notice("in", `task ${index} from gateway:wechat:room ${"长".repeat(400)}`);
|
|
1184
|
+
}
|
|
1185
|
+
const [render] = surface.calls.widget.slice(-1);
|
|
1186
|
+
assert.ok(render.length <= MAX_LINES, `panel grew to ${render.length} lines`);
|
|
1187
|
+
for (const line of render) {
|
|
1188
|
+
assert.ok(Array.from(line).length <= MAX_WIDTH, `line overruns its width: ${line}`);
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
test("a host without the widget keeps the footer line and the stderr line", async () => {
|
|
1193
|
+
const surface = fakeSurface();
|
|
1194
|
+
const { agent, host, logs } = await startAgent({ surface });
|
|
1195
|
+
agent.start();
|
|
1196
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1197
|
+
host.notify("assign", assignArgs());
|
|
1198
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
1199
|
+
|
|
1200
|
+
assert.deepEqual(surface.calls.widget, [], "no panel to draw");
|
|
1201
|
+
assert.match(surface.calls.status.at(-1), /^onlyne planner · connected · gen 1 · task 11111111 running$/);
|
|
1202
|
+
assert.ok(logs.some((line) => line.startsWith("in: task 11111111")), logs.join(" | "));
|
|
1203
|
+
});
|
|
1204
|
+
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,7 @@ interface PiSurface {
|
|
|
56
56
|
wakeUser: boolean;
|
|
57
57
|
proseContext: boolean;
|
|
58
58
|
customEntry: boolean;
|
|
59
|
+
widget: boolean;
|
|
59
60
|
status: boolean;
|
|
60
61
|
exit: boolean;
|
|
61
62
|
isIdle: boolean;
|
|
@@ -65,6 +66,7 @@ interface PiSurface {
|
|
|
65
66
|
wakeUser(text: string, parts?: ImagePartInput[]): boolean;
|
|
66
67
|
proseContext(text: string, welcome: WelcomeLike): boolean;
|
|
67
68
|
customEntry(customType: string, data: unknown): boolean;
|
|
69
|
+
widget(lines: string[] | undefined): void;
|
|
68
70
|
status(text: string): void;
|
|
69
71
|
welcome(welcome: WelcomeLike): void;
|
|
70
72
|
isIdle(): boolean;
|
|
@@ -292,6 +294,7 @@ export default function onlyne(pi: ExtensionAPI) {
|
|
|
292
294
|
|
|
293
295
|
pi.on("session_shutdown", async (event) => {
|
|
294
296
|
agent?.stop(`pi:${event.reason ?? "quit"}`);
|
|
297
|
+
surface?.widget?.(undefined);
|
|
295
298
|
agent = null;
|
|
296
299
|
surface = null;
|
|
297
300
|
registered = false;
|
package/src/pi-surface.mjs
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
// wakeUser pi.sendUserMessage(content, { deliverAs: "followUp" })
|
|
7
7
|
// proseContext pi.sendMessage({customType,...}, { deliverAs:"followUp", triggerTurn:false })
|
|
8
8
|
// customEntry pi.appendEntry(customType, data)
|
|
9
|
+
// widget ctx.ui.setWidget("onlyne", lines) / ctx.ui.setWidget("onlyne", undefined)
|
|
9
10
|
// status ctx.ui.setStatus("onlyne", text)
|
|
10
11
|
// exit ctx.shutdown()
|
|
11
12
|
// isIdle ctx.isIdle()
|
|
12
13
|
// registerTool / registerCommand are probed by index.ts itself.
|
|
13
14
|
|
|
15
|
+
import { WIDGET_KEY } from "./activity.mjs";
|
|
16
|
+
|
|
14
17
|
/**
|
|
15
18
|
* @param {{ pi: any, log: (line: string) => void, context: () => any }} options
|
|
16
19
|
*/
|
|
@@ -28,6 +31,7 @@ export function createSurface({ pi, log, context }) {
|
|
|
28
31
|
wakeUser: has(pi.sendUserMessage),
|
|
29
32
|
proseContext: has(pi.sendMessage),
|
|
30
33
|
customEntry: has(pi.appendEntry),
|
|
34
|
+
widget: has(ctx()?.ui?.setWidget),
|
|
31
35
|
status: true,
|
|
32
36
|
exit: true,
|
|
33
37
|
isIdle: true,
|
|
@@ -35,6 +39,14 @@ export function createSurface({ pi, log, context }) {
|
|
|
35
39
|
registerCommand: has(pi.registerCommand),
|
|
36
40
|
};
|
|
37
41
|
|
|
42
|
+
const widget = (lines) => {
|
|
43
|
+
try {
|
|
44
|
+
ctx()?.ui?.setWidget?.(WIDGET_KEY, lines);
|
|
45
|
+
} catch {
|
|
46
|
+
/* widget is decoration; never let it break the protocol */
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
38
50
|
const status = (text) => {
|
|
39
51
|
try {
|
|
40
52
|
ctx()?.ui?.setStatus?.("onlyne", text);
|
|
@@ -113,6 +125,7 @@ export function createSurface({ pi, log, context }) {
|
|
|
113
125
|
wakeUser,
|
|
114
126
|
proseContext,
|
|
115
127
|
customEntry,
|
|
128
|
+
widget,
|
|
116
129
|
status,
|
|
117
130
|
welcome(welcome) {
|
|
118
131
|
status(`onlyne: ${welcome.role}`);
|