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 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 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/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
@@ -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 plugin's stderr line `relay guard from …` names the source and `required=…` the policy; `relay guard: missing handoff …` names the delivered set |
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 integrations/pi-onlyne
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/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 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/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 必须收到过接力
@@ -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 | 插件 stderr 的 `relay guard from …` 说明来源、`required=…` 说明策略;`relay guard: missing handoff …` 列出已投递集合 |
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 integrations/pi-onlyne
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.0.0",
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
  }
@@ -1,18 +1,13 @@
1
- # Manual-installation escape hatch for the relay guard.
1
+ # Onlyne relay guard policy — copy to `relay.toml` next to this package's `package.json`.
2
2
  #
3
- # A generated workspace gets its guard policy from spec.toml ([[client]] rows
4
- # `relay_required = ["writer"]`, `relay_count = 2` — `relay_required_count` is
5
- # accepted as the guard file's own spelling), which the client injects into
6
- # every session it spawns. This file is for installations that manage their
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
- # relay_required wins over relay_count when both are present.
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
- # Downstream roles one of this role's sessions must have handed work to before
14
- # it may report a terminal outcome:
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
@@ -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.surface.status?.("onlyne: detached");
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.surface.status?.(this.closed ? "onlyne: detached" : "onlyne: reconnecting");
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.surface.status?.(`onlyne: ${welcome.role}`);
305
- this.log(`welcome role=${welcome.role} generation=${welcome.generation} capabilities=${welcome.hostCapabilities.join(",")}`);
306
- this.surface.welcome?.(welcome);
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.log(`host bye: ${args.reason ?? "unspecified"}`);
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.log(`ready reported for ${taskId}`);
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.log(`assign for ${taskId} already injected; acking without a second injection`);
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.surface.status?.(`onlyne: ${taskId.slice(0, 8)} running`);
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.log("probe answered with a heartbeat");
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.log("task body received through config_get/stdin");
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.log(`recycle task=${taskId ?? "?"} reason=${args.reason ?? "?"} outcome=${args.outcome ?? "-"}`);
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.log(`relay guard waived for ${taskId}: ${reason}`);
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.surface.status?.(`onlyne: ${taskId.slice(0, 8)} ${normalized} (queued)`);
785
- this.log(`completion for ${taskId} queued: socket is down`);
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.log(`completion ${taskId} ${normalized} head=${JSON.stringify(summary.slice(0, 60))}`);
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.log(`queued completion for ${pending.taskId} flushed after reconnect`);
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.log(`attachment written to ${path} (${bytes.length} bytes)`);
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}`);
@@ -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 },
@@ -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;
@@ -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}`);