pi-onlyne 1.2.0 → 1.2.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.
@@ -0,0 +1,176 @@
1
+ // The one question a background-task extension makes necessary: is this
2
+ // session's work still running somewhere the agent loop cannot see?
3
+ //
4
+ // `pi-background-tasks` and its relatives take a long command off the loop — the
5
+ // tool call returns a task id at once and the child process carries on. pi then
6
+ // waits for input while the work runs, so `ctx.isIdle()` alone would report a
7
+ // session as idle with a task in flight. This probe reads the extension's own
8
+ // live task list over the pi EventBus, and only when the extension is installed:
9
+ // without one of its tools there is nothing to recognise, nothing to query, and
10
+ // nothing to wait for.
11
+ //
12
+ // The contract is that package's documented `eventbus-v1` surface
13
+ // (`pi-background-tasks/docs/api/eventbus-v1.md`): one request frame in, one
14
+ // response frame out, both closed objects carrying a schema id. A response that
15
+ // never arrives, a frame that does not parse, an error response, and a host with
16
+ // no EventBus all read as "no background work known" and leave the plugin's own
17
+ // judgement untouched.
18
+
19
+ /** The tools that mark the extension as installed. */
20
+ export const BACKGROUND_TOOL_NAMES = Object.freeze([
21
+ "bg_run",
22
+ "bg_run_pi_attested",
23
+ "bg_status",
24
+ "bg_logs",
25
+ "bg_kill",
26
+ "bg_delegate",
27
+ "bg_result",
28
+ "fusion_reason",
29
+ "fusion_investigate",
30
+ "fusion_research",
31
+ "fusion_validate",
32
+ "fusion_web_fetch",
33
+ ]);
34
+
35
+ const REQUEST_CHANNEL = "pi-background-tasks:request:v1";
36
+ const RESPONSE_CHANNEL = "pi-background-tasks:response:v1";
37
+ const REQUEST_SCHEMA = "pi-background-tasks.extension-request.v1";
38
+ const RESPONSE_SCHEMA = "pi-background-tasks.extension-response.v1";
39
+
40
+ /** Task statuses that mean the work is still going. */
41
+ const LIVE_TASK_STATUS = "running";
42
+
43
+ /** How long one status query waits for its answer. */
44
+ export const DEFAULT_STATUS_TIMEOUT_MS = 500;
45
+
46
+ /** @param {string} name */
47
+ export function isBackgroundTool(name) {
48
+ return BACKGROUND_TOOL_NAMES.includes(name);
49
+ }
50
+
51
+ let requestCounter = 0;
52
+
53
+ function nextRequestId() {
54
+ requestCounter += 1;
55
+ return `onlyne-bg-status-${requestCounter}`;
56
+ }
57
+
58
+ function isResponseFor(frame, requestId) {
59
+ return frame
60
+ && typeof frame === "object"
61
+ && frame.schema_version === RESPONSE_SCHEMA
62
+ && frame.request_id === requestId;
63
+ }
64
+
65
+ /**
66
+ * One live-task question, asked of the EventBus and answered by whatever is
67
+ * listening. Every failure mode is inert: the probe reports `false` and says why
68
+ * once, then lets the caller's own judgement stand.
69
+ *
70
+ * @param {{
71
+ * events?: { emit: (channel: string, data: unknown) => void, on: (channel: string, handler: (data: unknown) => void) => () => void } | null,
72
+ * getToolNames?: () => string[] | null,
73
+ * log?: (line: string) => void,
74
+ * timeoutMs?: number,
75
+ * }} options
76
+ */
77
+ export function createBackgroundProbe({
78
+ events = null,
79
+ getToolNames = null,
80
+ log = () => {},
81
+ timeoutMs = DEFAULT_STATUS_TIMEOUT_MS,
82
+ } = {}) {
83
+ let installed = null;
84
+ const warned = new Set();
85
+ let unsubscribe = null;
86
+
87
+ const warnOnce = (reason) => {
88
+ if (warned.has(reason)) return;
89
+ warned.add(reason);
90
+ log(`background work: ${reason}`);
91
+ };
92
+
93
+ /** True once one of the extension's tools is registered; cached either way. */
94
+ function isInstalled() {
95
+ if (installed !== null) return installed;
96
+ let names = null;
97
+ try {
98
+ names = typeof getToolNames === "function" ? getToolNames() : null;
99
+ } catch (error) {
100
+ warnOnce(`tool list unreadable: ${error.message}`);
101
+ return false;
102
+ }
103
+ const list = Array.isArray(names) ? names : [];
104
+ installed = list.some(isBackgroundTool);
105
+ if (!installed) log("background work: no background-task extension in this session");
106
+ return installed;
107
+ }
108
+
109
+ /**
110
+ * One status round trip. The answer arrives on the response channel, so the
111
+ * listener lives exactly as long as the wait and the timer bounds it.
112
+ * @returns {Promise<boolean>}
113
+ */
114
+ function running() {
115
+ if (!isInstalled()) return Promise.resolve(false);
116
+ const bus = events;
117
+ if (!bus || typeof bus.emit !== "function" || typeof bus.on !== "function") {
118
+ warnOnce("event bus unavailable");
119
+ return Promise.resolve(false);
120
+ }
121
+ return new Promise((resolve) => {
122
+ const requestId = nextRequestId();
123
+ let settled = false;
124
+ let timer = null;
125
+ const finish = (answer) => {
126
+ if (settled) return;
127
+ settled = true;
128
+ clearTimeout(timer);
129
+ if (typeof unsubscribe === "function") unsubscribe();
130
+ unsubscribe = null;
131
+ resolve(answer);
132
+ };
133
+ try {
134
+ unsubscribe = bus.on(RESPONSE_CHANNEL, (frame) => {
135
+ if (!isResponseFor(frame, requestId)) return;
136
+ if (frame.ok !== true) {
137
+ warnOnce(`status query refused: ${String(frame.error ?? "unknown")}`);
138
+ finish(false);
139
+ return;
140
+ }
141
+ const tasks = frame.result?.tasks;
142
+ if (!Array.isArray(tasks)) {
143
+ warnOnce("status answer carried no task list");
144
+ finish(false);
145
+ return;
146
+ }
147
+ finish(tasks.some((task) => task?.status === LIVE_TASK_STATUS));
148
+ });
149
+ bus.emit(REQUEST_CHANNEL, {
150
+ schema_version: REQUEST_SCHEMA,
151
+ request_id: requestId,
152
+ operation: "status",
153
+ payload: {},
154
+ });
155
+ } catch (error) {
156
+ warnOnce(`status query failed: ${error.message}`);
157
+ finish(false);
158
+ return;
159
+ }
160
+ timer = setTimeout(() => {
161
+ warnOnce("status query timed out");
162
+ finish(false);
163
+ }, timeoutMs);
164
+ if (typeof timer?.unref === "function") timer.unref();
165
+ });
166
+ }
167
+
168
+ return {
169
+ installed: isInstalled,
170
+ running,
171
+ close() {
172
+ if (typeof unsubscribe === "function") unsubscribe();
173
+ unsubscribe = null;
174
+ },
175
+ };
176
+ }
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { createBackgroundProbe } from "./background-work.mjs";
5
+
6
+ const RESPONSE_CHANNEL = "pi-background-tasks:response:v1";
7
+ const RESPONSE_SCHEMA = "pi-background-tasks.extension-response.v1";
8
+
9
+ class FakeEventBus {
10
+ constructor(answer) {
11
+ this.answer = answer;
12
+ this.emits = [];
13
+ this.listeners = new Map();
14
+ this.drops = 0;
15
+ }
16
+
17
+ on(channel, handler) {
18
+ const handlers = this.listeners.get(channel) ?? new Set();
19
+ handlers.add(handler);
20
+ this.listeners.set(channel, handlers);
21
+ let active = true;
22
+ return () => {
23
+ if (!active) return;
24
+ active = false;
25
+ this.drops += 1;
26
+ handlers.delete(handler);
27
+ };
28
+ }
29
+
30
+ emit(channel, data) {
31
+ this.emits.push({ channel, data });
32
+ if (channel !== "pi-background-tasks:request:v1") return;
33
+ this.answer?.(data, (frame) => {
34
+ for (const handler of [...(this.listeners.get(RESPONSE_CHANNEL) ?? [])]) {
35
+ handler(frame);
36
+ }
37
+ });
38
+ }
39
+
40
+ listenerCount() {
41
+ return [...this.listeners.values()].reduce((total, handlers) => total + handlers.size, 0);
42
+ }
43
+ }
44
+
45
+ function response(request, body) {
46
+ return {
47
+ schema_version: RESPONSE_SCHEMA,
48
+ request_id: request.request_id,
49
+ ...body,
50
+ };
51
+ }
52
+
53
+ function probeFor(answer, timeoutMs = 20) {
54
+ const events = new FakeEventBus(answer);
55
+ const probe = createBackgroundProbe({
56
+ events,
57
+ getToolNames: () => ["bg_run"],
58
+ timeoutMs,
59
+ });
60
+ return { events, probe };
61
+ }
62
+
63
+ test("a session without a background-task tool makes no EventBus query", async () => {
64
+ const events = new FakeEventBus(() => {
65
+ assert.fail("an uninstalled background-task extension must not be queried");
66
+ });
67
+ const probe = createBackgroundProbe({
68
+ events,
69
+ getToolNames: () => ["read_file"],
70
+ });
71
+
72
+ assert.equal(await probe.running(), false);
73
+ assert.deepEqual(events.emits, []);
74
+ assert.equal(events.listenerCount(), 0);
75
+ });
76
+
77
+ test("a live background task answers true and drops the response listener", async () => {
78
+ const { events, probe } = probeFor((request, respond) => {
79
+ respond(response(request, { ok: true, result: { tasks: [{ status: "running" }] } }));
80
+ });
81
+
82
+ assert.equal(await probe.running(), true);
83
+ assert.equal(events.emits.length, 1);
84
+ assert.equal(events.listenerCount(), 0);
85
+ assert.equal(events.drops, 1);
86
+ });
87
+
88
+ test("a terminal background task list answers false and drops the response listener", async () => {
89
+ const { events, probe } = probeFor((request, respond) => {
90
+ respond(response(request, {
91
+ ok: true,
92
+ result: { tasks: [{ status: "completed" }, { status: "failed" }] },
93
+ }));
94
+ });
95
+
96
+ assert.equal(await probe.running(), false);
97
+ assert.equal(events.listenerCount(), 0);
98
+ assert.equal(events.drops, 1);
99
+ });
100
+
101
+ test("a refused status query answers false and drops the response listener", async () => {
102
+ const { events, probe } = probeFor((request, respond) => {
103
+ respond(response(request, { ok: false, error: "status unavailable" }));
104
+ });
105
+
106
+ assert.equal(await probe.running(), false);
107
+ assert.equal(events.listenerCount(), 0);
108
+ assert.equal(events.drops, 1);
109
+ });
110
+
111
+ test("a malformed status answer reads false and drops the response listener", async () => {
112
+ const { events, probe } = probeFor((request, respond) => {
113
+ respond(response(request, { ok: true, result: { tasks: "running" } }));
114
+ });
115
+
116
+ assert.equal(await probe.running(), false);
117
+ assert.equal(events.listenerCount(), 0);
118
+ assert.equal(events.drops, 1);
119
+ });
120
+
121
+ test("a background-task service that never answers reads false and drops the response listener", async () => {
122
+ const { events, probe } = probeFor(null, 10);
123
+
124
+ assert.equal(await probe.running(), false);
125
+ assert.equal(events.emits.length, 1);
126
+ assert.equal(events.listenerCount(), 0);
127
+ assert.equal(events.drops, 1);
128
+ });
package/src/index.ts CHANGED
@@ -9,9 +9,11 @@
9
9
  //
10
10
  // session_start -> read env + .pi/onlyne.json, connect, register tools
11
11
  // turn_start -> heartbeat{running}
12
- // turn_end -> heartbeat{idle}; the settle window opens
12
+ // turn_end -> one turn of a run ended; the phase is re-derived from pi
13
+ // and the settle window opens
13
14
  // message_end -> keep the last assistant text; a failed turn is `failed`
14
- // agent_settled -> settle decision: the idle ladder, or `failed` at once
15
+ // agent_settled -> heartbeat{idle} when the session waits for input, then
16
+ // the settle decision: the idle ladder, or `failed` at once
15
17
  // session_shutdown -> detach{reason}
16
18
 
17
19
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -68,6 +70,10 @@ interface PiSurface {
68
70
  status(text: string): void;
69
71
  welcome(welcome: WelcomeLike): void;
70
72
  isIdle(): boolean;
73
+ /** The phase rule: true only while the session waits for user input. */
74
+ waitingForInput(): Promise<boolean>;
75
+ /** Drops the background-task probe's EventBus subscription. */
76
+ closeBackground?(): void;
71
77
  exit(reason: string): void;
72
78
  }
73
79
 
@@ -326,6 +332,7 @@ export default function onlyne(pi: ExtensionAPI) {
326
332
 
327
333
  pi.on("session_shutdown", async (event) => {
328
334
  agent?.stop(`pi:${event.reason ?? "quit"}`);
335
+ surface?.closeBackground?.();
329
336
  surface?.widget?.(undefined);
330
337
  agent = null;
331
338
  surface = null;
@@ -10,9 +10,13 @@
10
10
  // status ctx.ui.setStatus("onlyne", text)
11
11
  // exit ctx.shutdown()
12
12
  // isIdle ctx.isIdle()
13
+ // pending ctx.hasPendingMessages()
14
+ // toolNames pi.getAllTools() (background-work.mjs)
15
+ // eventBus pi.events (background-work.mjs)
13
16
  // registerTool / registerCommand are probed by index.ts itself.
14
17
 
15
18
  import { WIDGET_KEY } from "./activity.mjs";
19
+ import { createBackgroundProbe } from "./background-work.mjs";
16
20
 
17
21
  /**
18
22
  * @param {{ pi: any, log: (line: string) => void, context: () => any }} options
@@ -27,6 +31,30 @@ export function createSurface({ pi, log, context }) {
27
31
  }
28
32
  };
29
33
 
34
+ /**
35
+ * The one question behind every phase the plugin reports. pi answers it; a
36
+ * probe that is missing, throws, or arrives without a context answers `false`
37
+ * because an unwitnessed session is a running one as far as this plugin can
38
+ * prove (`background-work.mjs` carries the second half of the question).
39
+ */
40
+ const piWaitsForInput = () => {
41
+ const current = ctx();
42
+ if (!current) return false;
43
+ try {
44
+ if (!has(current.isIdle) || !current.isIdle()) return false;
45
+ if (has(current.hasPendingMessages) && current.hasPendingMessages()) return false;
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ const background = createBackgroundProbe({
53
+ events: pi.events ?? null,
54
+ getToolNames: has(pi.getAllTools) ? () => (pi.getAllTools() ?? []).map((tool) => tool?.name) : null,
55
+ log,
56
+ });
57
+
30
58
  const available = {
31
59
  wakeUser: has(pi.sendUserMessage),
32
60
  proseContext: has(pi.sendMessage),
@@ -139,6 +167,18 @@ export function createSurface({ pi, log, context }) {
139
167
  return true;
140
168
  }
141
169
  },
170
+ /**
171
+ * The phase rule in one place: idle means waiting for user input, and a
172
+ * background-task extension holding live work keeps the session running
173
+ * even while pi itself waits.
174
+ */
175
+ async waitingForInput() {
176
+ if (!piWaitsForInput()) return false;
177
+ return !(await background.running());
178
+ },
179
+ closeBackground() {
180
+ background.close();
181
+ },
142
182
  exit(reason) {
143
183
  log(`exiting pi: ${reason}`);
144
184
  try {