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.
- package/README.md +323 -21
- package/README.zh.md +35 -16
- package/package.json +1 -1
- package/src/agent.live.test.mjs +1 -0
- package/src/agent.mjs +73 -63
- package/src/agent.test.mjs +161 -101
- package/src/background-work.mjs +176 -0
- package/src/background-work.test.mjs +128 -0
- package/src/index.ts +9 -2
- package/src/pi-surface.mjs +40 -0
|
@@ -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 ->
|
|
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 ->
|
|
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;
|
package/src/pi-surface.mjs
CHANGED
|
@@ -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 {
|