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 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/integrations/pi-onlyne" # read once, at generate time
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-onlyne"] }
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 integrations/pi-onlyne <ws>/.onlyne/agent/pi-onlyne
76
- printf '{"packages":["../.onlyne/agent/pi-onlyne"]}\n' > <ws>/.pi/settings.json
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/integrations/pi-onlyne -ns -nc
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-onlyne/relay.toml` in a generated
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
- - **Task ids here are single-use.** The plugin acks duplicate `assign` deliveries for the
285
- same task (`reason: "duplicate"`) without a second injection, and remembers the id for
286
- the life of the connection. The client today mints a fresh uuid per task, so this only
287
- ever fires on a genuine redelivery.
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 plugin's stderr line `relay guard from …` names the source and `required=…` the policy; `relay guard: missing handoff …` names the delivered set |
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 integrations/pi-onlyne
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/integrations/pi-onlyne" # 只在 generate 时读一次
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-onlyne"] }
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 integrations/pi-onlyne <ws>/.onlyne/agent/pi-onlyne
70
- printf '{"packages":["../.onlyne/agent/pi-onlyne"]}\n' > <ws>/.pi/settings.json
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/integrations/pi-onlyne -ns -nc
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-onlyne/relay.toml`,手工安装则是插件目录下的 `relay.toml`。
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
- - **任务 id 在连接生命周期内一次性使用**:同一任务的重复 `assign` 插件只回
247
- `reason: "duplicate"` 的 ack,不重复注入,并记住这个 id 直到连接结束。当今 client 每个任务
248
- 都是新 uuid,所以这条只在真正的重投上生效。
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 | 插件 stderr 的 `relay guard from …` 说明来源、`required=…` 说明策略;`relay guard: missing handoff …` 列出已投递集合 |
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 integrations/pi-onlyne
324
+ cd plugins/onlyne-agent-pi
308
325
  node --test src/*.test.mjs # 帧编解码、协议词汇、agent 状态机、配置、接力守卫
309
326
  ```
310
327
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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
6
  "license": "MIT",
@@ -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
- this.injectedTasks = new Set();
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.surface.status?.("onlyne: detached");
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.surface.status?.(this.closed ? "onlyne: detached" : "onlyne: reconnecting");
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.surface.status?.(`onlyne: ${welcome.role}`);
305
- this.log(`welcome role=${welcome.role} generation=${welcome.generation} capabilities=${welcome.hostCapabilities.join(",")}`);
306
- this.surface.welcome?.(welcome);
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.log(`host bye: ${args.reason ?? "unspecified"}`);
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.log(`ready reported for ${taskId}`);
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
- if (this.injectedTasks.has(taskId)) {
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.log(`assign for ${taskId} already injected; acking without a second injection`);
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.injectedTasks.add(taskId);
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.set(taskId, {
545
- taskId,
546
- envelopeId: envelope.id ?? null,
547
- // Who handed this task over: the relay guard's count mode does not count
548
- // a send straight back to it (`relay.mjs`).
549
- upstream: envelope.from?.role?.role ?? null,
550
- turnsSinceAssign: 0,
551
- turns: 0,
552
- errored: false,
553
- head: "",
554
- failed: false,
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.surface.status?.(`onlyne: ${taskId.slice(0, 8)} running`);
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.log("probe answered with a heartbeat");
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.log("task body received through config_get/stdin");
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.log(`recycle task=${taskId ?? "?"} reason=${args.reason ?? "?"} outcome=${args.outcome ?? "-"}`);
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.log(`relay guard waived for ${taskId}: ${reason}`);
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.surface.status?.(`onlyne: ${taskId.slice(0, 8)} ${normalized} (queued)`);
785
- this.log(`completion for ${taskId} queued: socket is down`);
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.log(`completion ${taskId} ${normalized} head=${JSON.stringify(summary.slice(0, 60))}`);
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.log(`queued completion for ${pending.taskId} flushed after reconnect`);
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.log(`attachment written to ${path} (${bytes.length} bytes)`);
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}`);
@@ -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: "1.0.0",
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;
@@ -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}`);