pi-onlyne 1.0.0 → 1.1.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 +25 -8
- package/README.zh.md +24 -8
- package/package.json +6 -3
- package/relay.toml.example +10 -15
- package/src/activity.mjs +96 -0
- package/src/activity.test.mjs +69 -0
- package/src/agent.mjs +64 -24
- package/src/agent.test.mjs +83 -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 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 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
|
|
@@ -334,7 +351,7 @@ client injected none, §5).
|
|
|
334
351
|
| `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
352
|
| `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
353
|
| 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
|
|
354
|
+
| `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
355
|
| `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
356
|
| `frame_too_large` | a body above 8 MiB | only reachable through an oversize outbound image; the ceiling is the core's |
|
|
340
357
|
| tools missing | `pi.registerTool` is absent in that pi version | `/onlyne status`; the capability table above |
|
|
@@ -348,7 +365,7 @@ client injected none, §5).
|
|
|
348
365
|
## 9. Development
|
|
349
366
|
|
|
350
367
|
```bash
|
|
351
|
-
cd
|
|
368
|
+
cd plugins/onlyne-agent-pi
|
|
352
369
|
node --test src/*.test.mjs # framing, protocol, agent state machine, config, relay guard
|
|
353
370
|
```
|
|
354
371
|
|
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 pi-onlyne # 用户级:这台机器上每个 pi 进程都会加载
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
发布名是 npm 上的 `pi-onlyne`,`pi install 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 必须收到过接力
|
|
@@ -290,7 +306,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
290
306
|
| `ready refused: internal: unknown session for …` | 插件为 client 从未暂存的任务报了 ready(手工起 pi 时的正常现象) | 让 client 拉起 pi,而不是手工起 |
|
|
291
307
|
| `assign` 一直不来 | client 的 `session_command` 没能拉起 pi,或 `inject` 被降级 | client 日志里的 spawn 行;`/onlyne status` 看能力集 |
|
|
292
308
|
| 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 |
|
|
309
|
+
| `onlyne_complete` 回答 `relay guard: missing handoff to: …` | 工作区的 spec(或顶替它的 `relay.toml`)点名了一个本会话从未触达的 role | 日常通知显示在 `onlyne` 面板;stderr 保留 `relay guard from …` 等拒绝、socket 错误、超时与帧错误;`required=…` 说明策略;`relay guard: missing handoff …` 列出已投递集合 |
|
|
294
310
|
| `hello` 后立刻 `forbidden` / 断连 | mount role 与 client 的 role 不一致 | `hello.args.mount.role` 对该工作区的 role |
|
|
295
311
|
| `frame_too_large` | 正文超过 8 MiB | 只会由超限的出站图片触发;上限来自核心 |
|
|
296
312
|
| 工具缺失 | 该 pi 版本没有 `pi.registerTool` | `/onlyne status`;对照上面的能力表 |
|
|
@@ -304,7 +320,7 @@ stderr 告警并忽略,把机会让回文件。
|
|
|
304
320
|
## 9. 开发与验证
|
|
305
321
|
|
|
306
322
|
```bash
|
|
307
|
-
cd
|
|
323
|
+
cd plugins/onlyne-agent-pi
|
|
308
324
|
node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫
|
|
309
325
|
```
|
|
310
326
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-onlyne",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Onlyne agent adapter for pi: the session lifecycle an onlyne role client expects from a pi host.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
6
7
|
"license": "MIT",
|
|
7
8
|
"repository": {
|
|
8
9
|
"type": "git",
|
|
@@ -31,10 +32,12 @@
|
|
|
31
32
|
]
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
|
-
"test": "node --test src/*.test.mjs"
|
|
35
|
+
"test": "node --test src/*.test.mjs",
|
|
36
|
+
"test:live": "node --test src/agent.live.test.mjs"
|
|
35
37
|
},
|
|
36
38
|
"peerDependencies": {
|
|
37
39
|
"@earendil-works/pi-coding-agent": "*",
|
|
38
40
|
"typebox": "*"
|
|
39
|
-
}
|
|
41
|
+
},
|
|
42
|
+
"peerDevDependencies": {}
|
|
40
43
|
}
|
package/relay.toml.example
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Onlyne relay guard policy — copy to `relay.toml` next to this package's `package.json`.
|
|
2
2
|
#
|
|
3
|
-
# A generated workspace
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# own workspace: drop it beside package.json and the guard reads it when the
|
|
8
|
-
# environment carries no policy. Environment wins over this file; no policy in
|
|
9
|
-
# either place leaves the guard off.
|
|
3
|
+
# Manual installations only. A generated workspace states the same policy in the
|
|
4
|
+
# server spec's `[[client]]` entry (`relay_required` / `relay_count`), which the
|
|
5
|
+
# client injects as `ONLYNE_RELAY_REQUIRED` / `ONLYNE_RELAY_COUNT`. Those
|
|
6
|
+
# environment variables win, and a file they shadow is ignored outright.
|
|
10
7
|
#
|
|
11
|
-
#
|
|
8
|
+
# Closed subset: flat `key = value` lines, the two keys below, one-line arrays of
|
|
9
|
+
# double-quoted strings, `#` comments. Anything outside that warns on stderr and
|
|
10
|
+
# is ignored. `relay_required` wins when both keys are present.
|
|
12
11
|
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# relay_required = ["writer", "auditor"]
|
|
16
|
-
|
|
17
|
-
# ... or this many distinct downstream roles:
|
|
18
|
-
# relay_required_count = 2
|
|
12
|
+
relay_required = ["writer"] # these roles must have received a handoff
|
|
13
|
+
relay_required_count = 2 # ... or this many distinct downstream roles
|
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;
|
|
@@ -181,7 +193,8 @@ export class OnlyneAgent {
|
|
|
181
193
|
}
|
|
182
194
|
this.connected = false;
|
|
183
195
|
this.socket = null;
|
|
184
|
-
this.
|
|
196
|
+
this.activity.set({ connection: "detached", taskId: null });
|
|
197
|
+
this.notice("state", `detached (${reason})`);
|
|
185
198
|
}
|
|
186
199
|
|
|
187
200
|
/** One line for `/onlyne status`. */
|
|
@@ -196,6 +209,7 @@ export class OnlyneAgent {
|
|
|
196
209
|
tasks: this.activeTasks().map((task) => task.taskId),
|
|
197
210
|
pendingCompletion: this.pendingCompletion?.taskId ?? null,
|
|
198
211
|
lastError: this.lastError ? String(this.lastError.message ?? this.lastError) : null,
|
|
212
|
+
activity: this.activity.events.slice(0, 5),
|
|
199
213
|
stats: { ...this.stats },
|
|
200
214
|
};
|
|
201
215
|
}
|
|
@@ -253,7 +267,8 @@ export class OnlyneAgent {
|
|
|
253
267
|
this.deferredPushes = [];
|
|
254
268
|
this.rejectAll("connection lost");
|
|
255
269
|
this.stopHeartbeat();
|
|
256
|
-
this.
|
|
270
|
+
this.activity.set({ connection: this.closed ? "detached" : "reconnecting" });
|
|
271
|
+
this.notice("state", this.closed ? "detached" : "reconnecting");
|
|
257
272
|
if (socket && !socket.destroyed) socket.destroy();
|
|
258
273
|
this.scheduleReconnect();
|
|
259
274
|
}
|
|
@@ -263,7 +278,6 @@ export class OnlyneAgent {
|
|
|
263
278
|
const delay = this.ladder[Math.min(this.attempt, this.ladder.length - 1)];
|
|
264
279
|
this.attempt += 1;
|
|
265
280
|
this.stats.reconnects += 1;
|
|
266
|
-
this.log(`reconnecting in ${delay}ms`);
|
|
267
281
|
this.reconnectHandle = this.timer.set(() => {
|
|
268
282
|
this.reconnectHandle = null;
|
|
269
283
|
if (!this.closed) this.connect();
|
|
@@ -274,7 +288,6 @@ export class OnlyneAgent {
|
|
|
274
288
|
this.attempt = 0;
|
|
275
289
|
// From here until the welcome is adopted, pushes queue instead of running.
|
|
276
290
|
this.handshaking = true;
|
|
277
|
-
this.log("connected; sending hello");
|
|
278
291
|
const args = helloArgs({
|
|
279
292
|
role: this.role,
|
|
280
293
|
session: this.sessionId,
|
|
@@ -301,9 +314,16 @@ export class OnlyneAgent {
|
|
|
301
314
|
// A fresh connection has reported nothing: the next beat is news.
|
|
302
315
|
this.lastPhase = null;
|
|
303
316
|
this.agentState = this.tasks.size > 0 ? "idle" : "ready";
|
|
304
|
-
this.
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
this.activity.set({
|
|
318
|
+
role: welcome.role,
|
|
319
|
+
connection: "connected",
|
|
320
|
+
generation: welcome.generation,
|
|
321
|
+
// A reconnect inherits the task this process serves: the panel says so
|
|
322
|
+
// from the first beat, with the assignment still queued behind the hello.
|
|
323
|
+
taskId: this.activeTaskId() ?? null,
|
|
324
|
+
phase: this.agentState,
|
|
325
|
+
});
|
|
326
|
+
this.notice("state", `connected role=${welcome.role} gen=${welcome.generation} host=${welcome.hostCapabilities.join(",")}`);
|
|
307
327
|
const prose = welcome.prose.trim();
|
|
308
328
|
if (prose && !this.deliveredProse.has(prose)) {
|
|
309
329
|
this.deliveredProse.add(prose);
|
|
@@ -408,7 +428,7 @@ export class OnlyneAgent {
|
|
|
408
428
|
else if (op === "recycle") void this.onRecycle(args);
|
|
409
429
|
else if (op === "config_get") void this.onConfigGet(args);
|
|
410
430
|
else if (op === "bye") {
|
|
411
|
-
this.
|
|
431
|
+
this.notice("state", `host bye: ${args.reason ?? "unspecified"}`);
|
|
412
432
|
this.dropSocket();
|
|
413
433
|
} else if (op) this.log(`ignoring host op ${op}`);
|
|
414
434
|
}
|
|
@@ -426,6 +446,25 @@ export class OnlyneAgent {
|
|
|
426
446
|
this.log(`socket error: ${error?.message ?? error}`);
|
|
427
447
|
}
|
|
428
448
|
|
|
449
|
+
/**
|
|
450
|
+
* One human-facing event: record it, then draw the best host surface.
|
|
451
|
+
* Hosts with `ctx.ui.setWidget` redraw the panel. Other hosts keep a footer
|
|
452
|
+
* line and a `[pi-onlyne]` stderr line.
|
|
453
|
+
*
|
|
454
|
+
* @param {"in" | "dup" | "out" | "warn" | "state"} kind
|
|
455
|
+
* @param {string} text
|
|
456
|
+
*/
|
|
457
|
+
notice(kind, text) {
|
|
458
|
+
this.activity.note(kind, text);
|
|
459
|
+
if (this.surface.available?.widget) {
|
|
460
|
+
this.surface.widget?.(this.activity.lines());
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// No panel: the footer carries the steady header line, stderr the event.
|
|
464
|
+
this.surface.status?.(this.activity.lines()[0]);
|
|
465
|
+
this.log(`${kind}: ${text}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
429
468
|
// --------------------------------------------------------------- reports
|
|
430
469
|
|
|
431
470
|
async reportReady() {
|
|
@@ -440,7 +479,7 @@ export class OnlyneAgent {
|
|
|
440
479
|
seq: this.seq,
|
|
441
480
|
}));
|
|
442
481
|
this.stats.reports += 1;
|
|
443
|
-
this.
|
|
482
|
+
this.notice("state", `ready ${taskId.slice(0, 8)}`);
|
|
444
483
|
} catch (error) {
|
|
445
484
|
this.log(`ready refused: ${error.message}`);
|
|
446
485
|
}
|
|
@@ -527,7 +566,7 @@ export class OnlyneAgent {
|
|
|
527
566
|
}
|
|
528
567
|
if (this.injectedTasks.has(taskId)) {
|
|
529
568
|
this.stats.duplicates += 1;
|
|
530
|
-
this.
|
|
569
|
+
this.notice("dup", `task ${taskId.slice(0, 8)} already injected`);
|
|
531
570
|
await this.ack(taskId, true, "duplicate");
|
|
532
571
|
return;
|
|
533
572
|
}
|
|
@@ -554,7 +593,6 @@ export class OnlyneAgent {
|
|
|
554
593
|
failed: false,
|
|
555
594
|
});
|
|
556
595
|
this.agentState = "running";
|
|
557
|
-
this.log(`assign ${taskId} from ${JSON.stringify(envelope.from ?? null)}; injecting ${text.length} chars`);
|
|
558
596
|
this.surface.wakeUser?.(text, attachments.map((item) => item.part));
|
|
559
597
|
this.surface.customEntry?.("onlyne-assign", {
|
|
560
598
|
taskId,
|
|
@@ -564,7 +602,8 @@ export class OnlyneAgent {
|
|
|
564
602
|
prose,
|
|
565
603
|
attachments: attachments.map((item) => item.path),
|
|
566
604
|
});
|
|
567
|
-
this.
|
|
605
|
+
this.activity.set({ taskId, phase: "running" });
|
|
606
|
+
this.notice("in", `task ${taskId.slice(0, 8)} from ${describePrincipal(envelope.from)} (${envelope.kind ?? "task"}): ${headOf(envelope.body?.text)}`);
|
|
568
607
|
this.startHeartbeat();
|
|
569
608
|
await this.ack(taskId, true, null);
|
|
570
609
|
}
|
|
@@ -581,7 +620,7 @@ export class OnlyneAgent {
|
|
|
581
620
|
async probe() {
|
|
582
621
|
try {
|
|
583
622
|
await this.heartbeat();
|
|
584
|
-
this.
|
|
623
|
+
this.notice("state", "probe answered with a heartbeat");
|
|
585
624
|
} catch (error) {
|
|
586
625
|
this.log(`probe heartbeat refused: ${error.message}`);
|
|
587
626
|
}
|
|
@@ -591,7 +630,7 @@ export class OnlyneAgent {
|
|
|
591
630
|
const task = stdinTaskText(args);
|
|
592
631
|
if (task) {
|
|
593
632
|
// The no-`inject` route: the host hands the payload over as a config key.
|
|
594
|
-
this.
|
|
633
|
+
this.notice("in", `stdin task: ${headOf(task.text)}`);
|
|
595
634
|
this.surface.wakeUser?.(`[onlyne] task body (delivered as stdin):\n\n${task.text}`, []);
|
|
596
635
|
return;
|
|
597
636
|
}
|
|
@@ -601,7 +640,7 @@ export class OnlyneAgent {
|
|
|
601
640
|
async onRecycle(args) {
|
|
602
641
|
this.stats.recycles += 1;
|
|
603
642
|
const taskId = args.task_id ?? this.activeTaskId();
|
|
604
|
-
this.
|
|
643
|
+
this.notice("state", `recycled: ${args.reason ?? "operator"}`);
|
|
605
644
|
if (taskId && args.outcome && this.tasks.get(taskId) && !this.tasks.get(taskId).completed) {
|
|
606
645
|
await this.complete(taskId, args.outcome, `recycled: ${args.reason ?? "operator"}`, {
|
|
607
646
|
exitProcess: false,
|
|
@@ -746,10 +785,11 @@ export class OnlyneAgent {
|
|
|
746
785
|
const reason = headOf(input.reason);
|
|
747
786
|
if (input.force !== true || !reason) {
|
|
748
787
|
this.log(refusal);
|
|
788
|
+
this.notice("warn", refusal);
|
|
749
789
|
throw new Error(`onlyne: ${refusal}`);
|
|
750
790
|
}
|
|
751
791
|
head = headOf(`${FORCED_PREFIX}${reason}${explicit ? ` | ${explicit}` : ""}`);
|
|
752
|
-
this.
|
|
792
|
+
this.notice("warn", `relay guard waived: ${reason}`);
|
|
753
793
|
}
|
|
754
794
|
}
|
|
755
795
|
return this.complete(taskId, normalizeOutcome(input.outcome), head);
|
|
@@ -781,15 +821,15 @@ export class OnlyneAgent {
|
|
|
781
821
|
const report = completeReport({ taskId, outcome: normalized, head: summary });
|
|
782
822
|
if (!this.connected) {
|
|
783
823
|
this.pendingCompletion = { taskId, report, outcome: normalized, exitProcess };
|
|
784
|
-
this.
|
|
785
|
-
this.
|
|
824
|
+
this.activity.set({ taskId, phase: `${normalized} queued` });
|
|
825
|
+
this.notice("warn", `complete ${taskId.slice(0, 8)} ${normalized} queued: socket down`);
|
|
786
826
|
return { taskId, outcome: normalized, head: summary, queued: true };
|
|
787
827
|
}
|
|
788
828
|
await this.request("report", report);
|
|
789
829
|
this.stats.completions += 1;
|
|
790
|
-
this.surface.status?.(`onlyne: ${taskId.slice(0, 8)} ${normalized}`);
|
|
791
830
|
this.surface.customEntry?.("onlyne-complete", { taskId, outcome: normalized, head: summary });
|
|
792
|
-
this.
|
|
831
|
+
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: normalized });
|
|
832
|
+
this.notice("out", `complete ${taskId.slice(0, 8)} ${normalized}${summary ? `: ${summary}` : ""}`);
|
|
793
833
|
if (this.activeTasks().length === 0) {
|
|
794
834
|
if (exitProcess) {
|
|
795
835
|
await this.reportSettled(taskId, normalized).catch((error) =>
|
|
@@ -841,7 +881,6 @@ export class OnlyneAgent {
|
|
|
841
881
|
}));
|
|
842
882
|
this.stats.reports += 1;
|
|
843
883
|
this.lastPhase = "idle";
|
|
844
|
-
this.log(`settled observation for ${taskId}: agent idle, outcome ${outcome}`);
|
|
845
884
|
return true;
|
|
846
885
|
}
|
|
847
886
|
|
|
@@ -852,7 +891,8 @@ export class OnlyneAgent {
|
|
|
852
891
|
try {
|
|
853
892
|
await this.request("report", pending.report);
|
|
854
893
|
this.stats.completions += 1;
|
|
855
|
-
this.
|
|
894
|
+
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
|
|
895
|
+
this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
|
|
856
896
|
if (pending.exitProcess && this.activeTasks().length === 0) {
|
|
857
897
|
await this.reportSettled(pending.taskId, pending.outcome).catch((error) =>
|
|
858
898
|
this.log(`settled observation refused: ${error.message}`),
|
|
@@ -903,7 +943,7 @@ export class OnlyneAgent {
|
|
|
903
943
|
const bytes = Buffer.from(image.data_base64, "base64");
|
|
904
944
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
905
945
|
writeFileSync(path, bytes, { mode: 0o600 });
|
|
906
|
-
this.
|
|
946
|
+
this.notice("state", `attachment ${basename(path)}`);
|
|
907
947
|
return [{ path, part: { type: "image", mime, data: image.data_base64, name } }];
|
|
908
948
|
} catch (error) {
|
|
909
949
|
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 },
|
|
@@ -1077,3 +1084,77 @@ test("the handoff ledger belongs to the session, not to one task", async () => {
|
|
|
1077
1084
|
assert.equal(completions(host).length, 1);
|
|
1078
1085
|
});
|
|
1079
1086
|
|
|
1087
|
+
// ---------------------------------------------------------------- the panel
|
|
1088
|
+
|
|
1089
|
+
test("a pi with the widget gets the panel and keeps a quiet scrollback", async () => {
|
|
1090
|
+
const surface = fakeSurface({ widget: true });
|
|
1091
|
+
const { agent, host, logs } = await startAgent({ surface });
|
|
1092
|
+
agent.start();
|
|
1093
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1094
|
+
host.notify("assign", assignArgs());
|
|
1095
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
1096
|
+
|
|
1097
|
+
const [render] = surface.calls.widget.slice(-1);
|
|
1098
|
+
// Header first, then the newest event: the assignment the host just handed over.
|
|
1099
|
+
assert.match(render[0], /^onlyne planner · connected · gen 1 · task 11111111 running$/);
|
|
1100
|
+
assert.match(render[1], /^\d\d:\d\d:\d\d <= task 11111111 from role:planner \(task\)/);
|
|
1101
|
+
assert.equal(agent.status().activity[0].kind, "in");
|
|
1102
|
+
// The point of the panel: a routine notice reaches the screen through it, once.
|
|
1103
|
+
assert.deepEqual(logs, [], "nothing about a healthy session reaches stderr");
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test("a repeating fault folds into one panel line with a count", () => {
|
|
1107
|
+
const surface = fakeSurface({ widget: true });
|
|
1108
|
+
const agent = new OnlyneAgent({
|
|
1109
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1110
|
+
cwd: tmpdir(),
|
|
1111
|
+
role: "planner",
|
|
1112
|
+
sessionId: SESSION_ID,
|
|
1113
|
+
taskId: TASK_ID,
|
|
1114
|
+
surface,
|
|
1115
|
+
log: (line) => {
|
|
1116
|
+
throw new Error(`a host with the panel writes no stderr line: ${line}`);
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1120
|
+
agent.notice("warn", "heartbeat refused: connection lost");
|
|
1121
|
+
}
|
|
1122
|
+
const [render] = surface.calls.widget.slice(-1);
|
|
1123
|
+
assert.match(render[1], /!! heartbeat refused: connection lost x3$/);
|
|
1124
|
+
assert.equal(surface.calls.widget.length, 3, "every event redraws the same panel");
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
test("the panel stays inside its line and width budget", () => {
|
|
1128
|
+
const surface = fakeSurface({ widget: true });
|
|
1129
|
+
const agent = new OnlyneAgent({
|
|
1130
|
+
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1131
|
+
cwd: tmpdir(),
|
|
1132
|
+
role: "planner",
|
|
1133
|
+
sessionId: SESSION_ID,
|
|
1134
|
+
taskId: TASK_ID,
|
|
1135
|
+
surface,
|
|
1136
|
+
log: () => {},
|
|
1137
|
+
});
|
|
1138
|
+
for (let index = 0; index < 40; index += 1) {
|
|
1139
|
+
agent.notice("in", `task ${index} from gateway:wechat:room ${"长".repeat(400)}`);
|
|
1140
|
+
}
|
|
1141
|
+
const [render] = surface.calls.widget.slice(-1);
|
|
1142
|
+
assert.ok(render.length <= MAX_LINES, `panel grew to ${render.length} lines`);
|
|
1143
|
+
for (const line of render) {
|
|
1144
|
+
assert.ok(Array.from(line).length <= MAX_WIDTH, `line overruns its width: ${line}`);
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
test("a host without the widget keeps the footer line and the stderr line", async () => {
|
|
1149
|
+
const surface = fakeSurface();
|
|
1150
|
+
const { agent, host, logs } = await startAgent({ surface });
|
|
1151
|
+
agent.start();
|
|
1152
|
+
await waitFor(() => (host.of("report").length >= 1 ? true : null));
|
|
1153
|
+
host.notify("assign", assignArgs());
|
|
1154
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
1155
|
+
|
|
1156
|
+
assert.deepEqual(surface.calls.widget, [], "no panel to draw");
|
|
1157
|
+
assert.match(surface.calls.status.at(-1), /^onlyne planner · connected · gen 1 · task 11111111 running$/);
|
|
1158
|
+
assert.ok(logs.some((line) => line.startsWith("in: task 11111111")), logs.join(" | "));
|
|
1159
|
+
});
|
|
1160
|
+
|
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}`);
|