pi-onlyne 1.2.0 → 1.2.2

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,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
+ });
@@ -8,8 +8,6 @@ import assert from "node:assert/strict";
8
8
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
- import { readFileSync } from "node:fs";
12
- import { fileURLToPath } from "node:url";
13
11
  import { afterEach, test } from "node:test";
14
12
 
15
13
  import { CONFIG_RELATIVE_PATH, DEFAULT_CONFIG, loadConfig, sessionIdentity } from "./config.mjs";
@@ -80,9 +78,3 @@ test("the session identity gate needs all three variables", () => {
80
78
  assert.equal(sessionIdentity({}), null);
81
79
  assert.equal(sessionIdentity({ ONLYNE_ROLE: "", ONLYNE_SESSION_ID: "s", ONLYNE_TASK_ID: "t" }), null);
82
80
  });
83
-
84
- test("the shipped switch file documents only the keys the plugin reads", () => {
85
- const path = fileURLToPath(new URL("../onlyne.json.example", import.meta.url));
86
- const example = JSON.parse(readFileSync(path, "utf8"));
87
- assert.deepEqual(example, { enabled: true, watch: { autoStart: true } });
88
- });
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;
@@ -2,7 +2,7 @@
2
2
  // through the pi extension API, with a probe for each optional member so an
3
3
  // older pi degrades instead of throwing.
4
4
  //
5
- // Probed members (measured against pi 0.85.1):
5
+ // Probed members (measured against pi 0.87.1):
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)
@@ -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),
@@ -55,19 +83,35 @@ export function createSurface({ pi, log, context }) {
55
83
  }
56
84
  };
57
85
 
58
- /** One pi user message; images ride along as pi image content parts. */
86
+ /**
87
+ * One pi user message; images ride along as pi image content parts.
88
+ *
89
+ * pi reads an image part as the flat `ImageContent` of its message types —
90
+ * `data` plus `mimeType` — and normalizes every part before the message is
91
+ * built, so a part missing either string stops the whole delivery inside pi.
92
+ * pi reports that failure in its own pane and hands nothing back to this
93
+ * plugin, so an unusable part is dropped here and the assignment still
94
+ * travels: the injection text already names the file that was written.
95
+ */
59
96
  const wakeUser = (text, parts = []) => {
60
97
  if (!available.wakeUser) {
61
98
  log("pi has no sendUserMessage; the assignment reached the session log only");
62
99
  return false;
63
100
  }
64
- const content = parts.length === 0
101
+ const images = parts.filter(
102
+ (part) => typeof part?.data === "string" && typeof part?.mime === "string" && part.mime,
103
+ );
104
+ if (images.length !== parts.length) {
105
+ log(`attachment carried no base64 data or no media type; ${parts.length - images.length} dropped, the task text still went`);
106
+ }
107
+ const content = images.length === 0
65
108
  ? text
66
109
  : [
67
110
  { type: "text", text },
68
- ...parts.map((part) => ({
111
+ ...images.map((part) => ({
69
112
  type: "image",
70
- source: { type: "base64", mediaType: part.mime, data: part.data },
113
+ data: part.data,
114
+ mimeType: part.mime,
71
115
  })),
72
116
  ];
73
117
  try {
@@ -139,6 +183,18 @@ export function createSurface({ pi, log, context }) {
139
183
  return true;
140
184
  }
141
185
  },
186
+ /**
187
+ * The phase rule in one place: idle means waiting for user input, and a
188
+ * background-task extension holding live work keeps the session running
189
+ * even while pi itself waits.
190
+ */
191
+ async waitingForInput() {
192
+ if (!piWaitsForInput()) return false;
193
+ return !(await background.running());
194
+ },
195
+ closeBackground() {
196
+ background.close();
197
+ },
142
198
  exit(reason) {
143
199
  log(`exiting pi: ${reason}`);
144
200
  try {
@@ -0,0 +1,160 @@
1
+ // The pi-facing half of the surface: exactly what `wakeUser` hands
2
+ // `pi.sendUserMessage`. Every shape asserted here is pi's own declaration —
3
+ // `sendUserMessage(content: string | (TextContent | ImageContent)[])`, with
4
+ // `ImageContent` the flat `{ type: "image", data, mimeType }` of
5
+ // `@earendil-works/pi-ai` (measured against pi 0.87.1). pi normalizes each part
6
+ // before it builds the message, so a part in any other shape — the nested
7
+ // Anthropic `source: { type, media_type, data }` this plugin once sent — stops
8
+ // the whole delivery inside pi, and pi reports that in its own pane without
9
+ // handing anything back to the plugin.
10
+
11
+ import assert from "node:assert/strict";
12
+ import { test } from "node:test";
13
+
14
+ import { createSurface } from "./pi-surface.mjs";
15
+
16
+ /** The injection text `agent.mjs` builds for one assignment. */
17
+ const TASK_TEXT = "[onlyne] task 11111111-1111-4111-8111-111111111111 from role:planner";
18
+
19
+ /** One inline image part in the shape the agent hands the surface. */
20
+ const PNG_PART = { type: "image", mime: "image/png", data: "iVBORw0KGgoAAAANSUhEUg==", name: "shot.png" };
21
+
22
+ /** pi's own `UserMessage.content` for that text and that part, key for key. */
23
+ const PNG_CONTENT = [
24
+ { type: "text", text: TASK_TEXT },
25
+ { type: "image", data: "iVBORw0KGgoAAAANSUhEUg==", mimeType: "image/png" },
26
+ ];
27
+
28
+ /**
29
+ * A surface over a hand-written fake pi: every `sendUserMessage` arrives with
30
+ * the arguments it was given, recorded whole, and every surface log line with
31
+ * it. `refuse` makes one call throw synchronously, the way a pi that does not
32
+ * know an option does.
33
+ * @param {{ refuse?: (content: unknown, options: unknown) => boolean }} [options]
34
+ */
35
+ function fakeSurface({ refuse = null } = {}) {
36
+ const calls = [];
37
+ const lines = [];
38
+ const surface = createSurface({
39
+ pi: {
40
+ sendUserMessage(content, options) {
41
+ calls.push({ content, options });
42
+ if (refuse?.(content, options)) throw new Error("this pi does not take that option");
43
+ },
44
+ },
45
+ log: (line) => lines.push(line),
46
+ context: () => null,
47
+ });
48
+ return { calls, lines, surface };
49
+ }
50
+
51
+ test("an image attachment reaches pi as one flat image content part beside the task text", () => {
52
+ const { calls, lines, surface } = fakeSurface();
53
+
54
+ assert.equal(surface.wakeUser(TASK_TEXT, [PNG_PART]), true);
55
+ assert.equal(calls.length, 1, "one message, not one call per part");
56
+ // The whole array, key for key: a nested `source` / `mediaType` part carries
57
+ // keys pi does not declare and lacks the two it does, so it cannot pass this.
58
+ assert.deepEqual(calls[0].content, PNG_CONTENT);
59
+ assert.deepEqual(lines, [], "a readable attachment is delivered, not reported");
60
+ });
61
+
62
+ test("task text with no attachments reaches pi as a bare string", () => {
63
+ const { calls, surface } = fakeSurface();
64
+
65
+ assert.equal(surface.wakeUser(TASK_TEXT, []), true);
66
+ assert.equal(calls.length, 1);
67
+ // pi takes the string path of `content`; a one-element array of text parts is
68
+ // a different message and a needless normalization round trip.
69
+ assert.equal(calls[0].content, TASK_TEXT);
70
+ });
71
+
72
+ test("an attachment pi cannot read is dropped, logged, and the task text still travels", () => {
73
+ const { calls, lines, surface } = fakeSurface();
74
+
75
+ // Each part below fails one clause of what pi needs: no base64 data, no media
76
+ // type, an empty media type. Sending any of them stops the whole delivery
77
+ // inside pi, so all three go and the text goes alone.
78
+ assert.equal(
79
+ surface.wakeUser(TASK_TEXT, [
80
+ { type: "image", mime: "image/png", name: "no-data.png" },
81
+ { type: "image", data: "iVBORw0KGgoAAAANSUhEUg==", name: "no-mime.png" },
82
+ { type: "image", data: "iVBORw0KGgoAAAANSUhEUg==", mime: "" },
83
+ ]),
84
+ true,
85
+ );
86
+ assert.equal(calls.length, 1);
87
+ assert.equal(calls[0].content, TASK_TEXT);
88
+ assert.equal(lines.length, 1);
89
+ assert.match(lines[0], /3 dropped/);
90
+ assert.match(lines[0], /the task text still went/);
91
+
92
+ // Dropping is per part: a usable image beside an unusable one still rides.
93
+ assert.equal(surface.wakeUser(TASK_TEXT, [{ type: "image", mime: "image/png" }, PNG_PART]), true);
94
+ assert.deepEqual(calls[1].content, PNG_CONTENT);
95
+ assert.equal(lines.length, 2);
96
+ assert.match(lines[1], /1 dropped/);
97
+ });
98
+
99
+ test("delivery asks for followUp and a refusal of that option retries with the same content", () => {
100
+ const asked = fakeSurface();
101
+
102
+ assert.equal(asked.surface.wakeUser(TASK_TEXT, [PNG_PART]), true);
103
+ assert.deepEqual(asked.calls, [{ content: PNG_CONTENT, options: { deliverAs: "followUp" } }]);
104
+
105
+ // An older pi throws on the option it does not know. The message itself is
106
+ // fine, so the retry drops the option and keeps the content.
107
+ const refusing = fakeSurface({ refuse: (_content, options) => Boolean(options) });
108
+
109
+ assert.equal(refusing.surface.wakeUser(TASK_TEXT, [PNG_PART]), true);
110
+ assert.equal(refusing.calls.length, 2);
111
+ assert.deepEqual(refusing.calls[0].options, { deliverAs: "followUp" });
112
+ assert.equal(refusing.calls[1].options, undefined);
113
+ assert.deepEqual(refusing.calls[1].content, PNG_CONTENT);
114
+ assert.deepEqual(refusing.lines, [], "a delivery that landed on the retry is not a failure");
115
+ });
116
+
117
+ /**
118
+ * A surface over a fake pi that answers the two questions the phase is read
119
+ * from, plus the cases where pi answers neither.
120
+ * @param {{ isIdle?: () => boolean, hasPendingMessages?: () => boolean, throwOn?: "idle" | "pending" }} [pi]
121
+ */
122
+ function idleSurface(pi = {}) {
123
+ const lines = [];
124
+ return {
125
+ lines,
126
+ surface: createSurface({ pi, log: (line) => lines.push(line), context: () => pi }),
127
+ };
128
+ }
129
+
130
+ test("only a session waiting on its next message reads as idle", async () => {
131
+ const waiting = idleSurface({ isIdle: () => true, hasPendingMessages: () => false });
132
+ assert.equal(await waiting.surface.waitingForInput(), true);
133
+
134
+ // A queued continuation is input already on its way, so the session is not
135
+ // waiting for the user even though no turn is running.
136
+ const queued = idleSurface({ isIdle: () => true, hasPendingMessages: () => true });
137
+ assert.equal(await queued.surface.waitingForInput(), false);
138
+
139
+ // A turn in flight, a retry, and a compaction all read as not idle here.
140
+ const busy = idleSurface({ isIdle: () => false, hasPendingMessages: () => false });
141
+ assert.equal(await busy.surface.waitingForInput(), false);
142
+ });
143
+
144
+ test("a phase pi was never asked about reads as running, not idle", async () => {
145
+ // The probes are optional members of an older pi, and a session with no
146
+ // context yet has witnessed nothing: both answer `false` rather than guess.
147
+ const noProbes = idleSurface({});
148
+ assert.equal(await noProbes.surface.waitingForInput(), false);
149
+
150
+ const noContext = createSurface({ pi: { isIdle: () => true }, log: () => {}, context: () => null });
151
+ assert.equal(await noContext.waitingForInput(), false);
152
+
153
+ const throwing = idleSurface({
154
+ isIdle: () => true,
155
+ hasPendingMessages: () => {
156
+ throw new Error("pi is mid-replay");
157
+ },
158
+ });
159
+ assert.equal(await throwing.surface.waitingForInput(), false);
160
+ });
@@ -219,16 +219,6 @@ test("observationFor attaches the host only when there is one", () => {
219
219
  assert.deepEqual(dimensions, bare);
220
220
  });
221
221
 
222
- test("heartbeatReport carries the host inside observed", () => {
223
- const report = heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 1, agent: "idle", host: hostBinding(PANE_ENV) });
224
- assert.equal(report.data.observed.host.orca.pane_key, PANE_KEY);
225
- assert.equal(report.data.observed.agent, "idle");
226
- assert.equal(
227
- "host" in heartbeatReport({ taskId: "t1", generation: 1, seq: SEQ_BASE + 2, agent: "idle" }).data.observed,
228
- false
229
- );
230
- });
231
-
232
222
  test("a completion names an outcome and a single-line head", () => {
233
223
  assert.deepEqual(completeReport({ taskId: "t1", outcome: "failed", head: "broke\non line two" }), {
234
224
  kind: "complete",