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.
@@ -11,7 +11,6 @@ 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";
15
14
  import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
16
15
  import { createFrameDecoder, encodeFrame } from "./frame.mjs";
17
16
  import { SEQ_BASE, readyReport } from "./protocol.mjs";
@@ -206,6 +205,7 @@ class FakeHost {
206
205
  /** The effect surface the agent drives, recorded for assertions. */
207
206
  function fakeSurface(options = {}) {
208
207
  const calls = { wakeUser: [], prose: [], entries: [], status: [], exits: [], widget: [] };
208
+ const flag = (value, fallback) => (value === undefined ? fallback : typeof value === "function" ? value() : value);
209
209
  return {
210
210
  calls,
211
211
  available: {
@@ -237,6 +237,9 @@ function fakeSurface(options = {}) {
237
237
  widget: (lines) => calls.widget.push(lines),
238
238
  welcome: () => {},
239
239
  isIdle: () => (options.idle === undefined ? true : options.idle()),
240
+ async waitingForInput() {
241
+ return Boolean(flag(options.waiting, true) && !flag(options.backgroundWork, false));
242
+ },
240
243
  exit: (reason) => calls.exits.push(reason),
241
244
  };
242
245
  }
@@ -345,7 +348,7 @@ test("every heartbeat names the Orca pane this process was spawned in", async ()
345
348
  orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF, handle: "term_1" },
346
349
  });
347
350
  // The binding rides beside the state dimensions instead of replacing them.
348
- assert.equal(observed.agent, "running");
351
+ assert.equal(observed.agent, "idle", "the default fake surface is waiting for input");
349
352
  assert.equal(observed.resource, "attached");
350
353
  assert.deepEqual(Object.keys(observed), [...OBSERVED_KEYS, "host"]);
351
354
  assert.equal(observed.version.generation, 1);
@@ -370,24 +373,6 @@ test("a reconnect reports the same pane", async () => {
370
373
  });
371
374
  });
372
375
 
373
- test("a second task in the same pane reports the same pane", async () => {
374
- await inOrcaPane(PANE_ENV, async () => {
375
- const { host } = await mountedAgent();
376
- host.notify("assign", assignArgs());
377
- await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
378
- host.notify("probe", {});
379
- await waitFor(() => (heartbeats(host).length ? true : null));
380
-
381
- // The binding belongs to the process, not to the task: handing this pane
382
- // another task cannot change where the process runs.
383
- host.notify("assign", { ...assignArgs(), task_id: "22222222-2222-4222-8222-222222222222" });
384
- await waitFor(() => (host.of("assign_ack").length === 2 ? true : null));
385
- host.notify("probe", {});
386
- const reports = await waitFor(() => (heartbeats(host).length >= 2 ? heartbeats(host) : null));
387
- assert.deepEqual(reports.at(-1).host, reports[0].host);
388
- });
389
- });
390
-
391
376
  test("a pi outside an Orca pane reports no host at all", async () => {
392
377
  await inOrcaPane({}, async () => {
393
378
  const { host } = await mountedAgent();
@@ -397,23 +382,7 @@ test("a pi outside an Orca pane reports no host at all", async () => {
397
382
 
398
383
  const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
399
384
  assert.equal("host" in observed, false, "no pane, no binding");
400
- assert.equal(observed.agent, "running", "the tuple is still a full observation");
401
- });
402
- });
403
-
404
- test("a pane that exports no handle reports the pane without inventing one", async () => {
405
- await inOrcaPane({ ORCA_PANE_KEY: PANE_KEY }, async () => {
406
- const { host } = await mountedAgent();
407
- host.notify("assign", assignArgs());
408
- await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
409
- host.notify("probe", {});
410
-
411
- const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
412
- // The pane key carries both ids; the handle is a field the environment
413
- // either has or has not, and a missing one is absent rather than empty.
414
- assert.deepEqual(observed.host, {
415
- orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF },
416
- });
385
+ assert.equal(observed.agent, "idle", "the tuple is still a full observation");
417
386
  });
418
387
  });
419
388
 
@@ -584,7 +553,7 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
584
553
  assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
585
554
  });
586
555
 
587
- test("turn hooks report running then idle, and the settle after them re-sends the assignment", async () => {
556
+ test("a waiting turn end reports idle, and the settle after it re-sends the assignment", async () => {
588
557
  const { agent, host, surface } = await startAgent();
589
558
  agent.start();
590
559
  await waitFor(() => host.of("report").length >= 1);
@@ -616,6 +585,29 @@ test("turn hooks report running then idle, and the settle after them re-sends th
616
585
  assert.deepEqual(surface.calls.exits, [], "a task with a rung left is not failed");
617
586
  });
618
587
 
588
+ test("a busy turn end reports no idle beat and does not settle the ladder", async () => {
589
+ const surface = fakeSurface({ waiting: false });
590
+ const { agent, host } = await startAgent({ surface });
591
+ agent.start();
592
+ await waitFor(() => host.of("report").length >= 1);
593
+ host.notify("assign", assignArgs());
594
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
595
+
596
+ agent.onTurnStart();
597
+ await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
598
+ agent.onTurnEnd();
599
+ agent.onSettled();
600
+ await waitFor(() => heartbeats(host).length >= 3);
601
+
602
+ assert.equal(
603
+ heartbeats(host).some((observed) => observed.agent === "idle"),
604
+ false,
605
+ "the turn-end beat follows the busy surface rather than assuming the hook means idle",
606
+ );
607
+ assert.equal(surface.calls.wakeUser.length, 1, "the ladder waits while the turn is still running");
608
+ assert.deepEqual(surface.calls.exits, []);
609
+ });
610
+
619
611
  // The live case found this ordering too: pi's turn-end hook fires in the same
620
612
  // millisecond as the settle that reports the completion, so the turn-end
621
613
  // heartbeat lands after the completion report. The beat no longer carries a
@@ -640,9 +632,9 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
640
632
  assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
641
633
  });
642
634
 
643
- test("a busy pi holds the settle decision until it is idle", async () => {
644
- let idle = false;
645
- const surface = fakeSurface({ idle: () => idle });
635
+ test("a busy pi holds the settle decision until it is waiting for input", async () => {
636
+ let waiting = false;
637
+ const surface = fakeSurface({ waiting: () => waiting });
646
638
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
647
639
  agent.start();
648
640
  await waitFor(() => host.of("report").length >= 1);
@@ -653,20 +645,44 @@ test("a busy pi holds the settle decision until it is idle", async () => {
653
645
  await new Promise((resolve) => setTimeout(resolve, 80));
654
646
  assert.equal(surface.calls.wakeUser.length, 1, "a busy pi is not reminded");
655
647
 
656
- idle = true;
648
+ waiting = true;
657
649
  const reminded = await waitFor(() =>
658
650
  surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
659
651
  );
660
652
  assert.match(reminded[1].text, /build it/, "the decision waits for the idle it is about");
661
653
  });
662
654
 
655
+ test("a live background task holds the ladder off until its task is terminal", async () => {
656
+ let backgroundWork = true;
657
+ const surface = fakeSurface({ backgroundWork: () => backgroundWork });
658
+ const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
659
+ agent.start();
660
+ await waitFor(() => host.of("report").length >= 1);
661
+ host.notify("assign", assignArgs());
662
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
663
+
664
+ agent.onTurnStart();
665
+ agent.onTurnEnd();
666
+ agent.onSettled();
667
+ await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
668
+ await new Promise((resolve) => setTimeout(resolve, 80));
669
+ assert.equal(surface.calls.wakeUser.length, 1, "work off the agent loop keeps the ladder off");
670
+
671
+ backgroundWork = false;
672
+ agent.onSettled();
673
+ const reminded = await waitFor(() =>
674
+ surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
675
+ );
676
+ assert.match(reminded[1].text, /build it/, "the terminal task releases the idle ladder");
677
+ });
678
+
663
679
  // The idle ladder (`docs/SWARM-REFACTOR-GRILLME.md` §4.2): a turn that ends
664
680
  // without a completion exit re-sends the assignment, and the idle that finds the
665
681
  // bound spent fails the task instead of settling it `done`. The bound is the
666
682
  // workspace's (`config.mjs`, two by default) and the count lives on the record
667
683
  // `onAssign` wrote for the task, which is why a task can be reminded twice and
668
684
  // failed on the third idle without the tool ever being called.
669
- test("an idle without a completion is reminded, the idle past the bound fails the task, and a completed turn is not reminded", async () => {
685
+ test("only settled idles spend ladder rungs, ordinary turns reset them, and the bound fails the task", async () => {
670
686
  const { agent, host, surface } = await startAgent();
671
687
  assert.equal(agent.idleReminders, DEFAULT_IDLE_REMINDERS, "the bound is the workspace's, two by default");
672
688
  agent.start();
@@ -683,35 +699,59 @@ test("an idle without a completion is reminded, the idle past the bound fails th
683
699
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
684
700
  assert.equal(surface.calls.wakeUser[0].parts.length, 1, "the injection carries the image");
685
701
 
686
- // Two idles, two reminders: the assignment comes back whole — header, task
687
- // text and all — and the model is told why it is seeing it again.
688
- for (const rung of [1, 2]) {
689
- agent.onTurnStart();
690
- agent.onTurnEnd();
691
- agent.onSettled();
692
- const sent = await waitFor(() =>
693
- surface.calls.wakeUser.length === 1 + rung ? surface.calls.wakeUser : null,
694
- );
695
- const reminder = sent.at(-1);
696
- assert.match(reminder.text, new RegExp(`reminder ${rung} of 2`));
697
- assert.match(reminder.text, /build it/, "the reminder carries the task text");
698
- assert.match(
699
- reminder.text,
700
- /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
701
- "and the identity and source the injection named",
702
- );
703
- assert.match(reminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
704
- assert.deepEqual(reminder.parts, [], "and the image itself is not handed over twice");
705
- assert.doesNotMatch(reminder.text, /Read the incoming task/, "the role prose is already in the context");
706
- assert.deepEqual(
707
- host.of("report").filter((report) => report.kind === "complete"),
708
- [],
709
- `rung ${rung}: an open task is not settled while it has a rung left`,
710
- );
711
- }
702
+ // The first turn belongs to the session itself. Its settled idle spends rung
703
+ // one. The turn that reminder wakes does not reset the episode, so its own
704
+ // settled idle spends rung two rather than starting over.
705
+ agent.onTurnStart();
706
+ agent.onTurnEnd();
707
+ agent.onSettled();
708
+ const first = await waitFor(() =>
709
+ surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
710
+ );
711
+ const firstReminder = first.at(-1);
712
+ assert.match(firstReminder.text, /reminder 1 of 2/);
713
+ assert.match(firstReminder.text, /build it/, "the reminder carries the task text");
714
+ assert.match(
715
+ firstReminder.text,
716
+ /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
717
+ "and the identity and source the injection named",
718
+ );
719
+ assert.match(firstReminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
720
+ assert.deepEqual(firstReminder.parts, [], "and the image itself is not handed over twice");
721
+ assert.doesNotMatch(firstReminder.text, /Read the incoming task/, "the role prose is already in the context");
712
722
 
713
- // The third idle: the bound is spent, so the open task fails and the session
714
- // leaves — the tool was never called, and no completion was ever reported.
723
+ agent.onTurnStart();
724
+ agent.onTurnEnd();
725
+ agent.onSettled();
726
+ const secondRung = await waitFor(() =>
727
+ surface.calls.wakeUser.length === 3 ? surface.calls.wakeUser : null,
728
+ );
729
+ assert.match(secondRung.at(-1).text, /reminder 2 of 2/, "the reminder-woken turn keeps the episode's count");
730
+ assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
731
+
732
+ // The next turn is the one the second reminder wakes, so it does not reset
733
+ // the episode. Let that turn end without a settle, then start a turn of the
734
+ // session's own: that ordinary turn begins a fresh episode.
735
+ agent.onTurnStart();
736
+ agent.onTurnEnd();
737
+ agent.onTurnStart();
738
+ agent.onTurnEnd();
739
+ agent.onSettled();
740
+ const reset = await waitFor(() =>
741
+ surface.calls.wakeUser.length === 4 ? surface.calls.wakeUser : null,
742
+ );
743
+ assert.match(reset.at(-1).text, /reminder 1 of 2/, "an ordinary turn clears the episode's rung count");
744
+
745
+ agent.onTurnStart();
746
+ agent.onTurnEnd();
747
+ agent.onSettled();
748
+ const secondFresh = await waitFor(() =>
749
+ surface.calls.wakeUser.length === 5 ? surface.calls.wakeUser : null,
750
+ );
751
+ assert.match(secondFresh.at(-1).text, /reminder 2 of 2/);
752
+
753
+ // The third idle in the fresh episode spends the bound, so the open task
754
+ // fails and the session leaves — the tool was never called.
715
755
  agent.onTurnStart();
716
756
  agent.onTurnEnd();
717
757
  agent.onSettled();
@@ -722,22 +762,22 @@ test("an idle without a completion is reminded, the idle past the bound fails th
722
762
  head: "no completion after 2 idle reminders",
723
763
  });
724
764
  assert.deepEqual(surface.calls.exits, ["failed"], "the ladder's failure leaves the session");
725
- assert.equal(surface.calls.wakeUser.length, 3, "the bound is spent: no third reminder");
765
+ assert.equal(surface.calls.wakeUser.length, 5, "the bound is spent: no third reminder");
726
766
 
727
767
  // A turn that hands the outcome over is never reminded: the tool call is the
728
768
  // exit the ladder exists to get.
729
- const second = await startAgent();
730
- second.agent.start();
731
- await waitFor(() => second.host.of("report").length >= 1);
732
- second.host.notify("assign", assignArgs());
733
- await waitFor(() => (second.surface.calls.wakeUser.length === 1 ? true : null));
734
- second.agent.onTurnStart();
735
- second.agent.onTurnEnd();
736
- await second.agent.completeFromTool({ outcome: "done", text: "built it" });
737
- second.agent.onSettled();
769
+ const completed = await startAgent();
770
+ completed.agent.start();
771
+ await waitFor(() => completed.host.of("report").length >= 1);
772
+ completed.host.notify("assign", assignArgs());
773
+ await waitFor(() => (completed.surface.calls.wakeUser.length === 1 ? true : null));
774
+ completed.agent.onTurnStart();
775
+ completed.agent.onTurnEnd();
776
+ await completed.agent.completeFromTool({ outcome: "done", text: "built it" });
777
+ completed.agent.onSettled();
738
778
  await new Promise((resolve) => setTimeout(resolve, 60));
739
- assert.equal(second.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
740
- assert.deepEqual(second.surface.calls.exits, ["done"]);
779
+ assert.equal(completed.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
780
+ assert.deepEqual(completed.surface.calls.exits, ["done"]);
741
781
  });
742
782
 
743
783
  test("an assigned task that never ran is not completed", async () => {
@@ -873,39 +913,10 @@ test("the exit waits for the client's acknowledgement of the completion report",
873
913
  assert.deepEqual(surface.calls.exits, ["done"]);
874
914
  });
875
915
 
876
- // A session that only ever reported `running` and then completed leaves the
877
- // ledger saying `running` forever: the client settles the row from the
878
- // completion report and hears nothing more. One final observation, sent after
879
- // the completion is acknowledged and before the process leaves, is what makes
880
- // an exited session read idle.
881
- test("a completion after a running beat publishes the settled observation before the exit", async () => {
882
- const { agent, host, surface } = await startAgent();
883
- agent.start();
884
- await waitFor(() => host.of("report").length >= 1);
885
- host.notify("assign", assignArgs());
886
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
887
- agent.onTurnStart();
888
- await waitFor(() =>
889
- host.of("report").find((report) => report.data?.observed?.agent === "running"),
890
- );
891
- agent.noteAssistantText("OK");
892
-
893
- await agent.complete(TASK_ID, "done", "OK");
894
- const reports = host.of("report").filter((report) => report.kind !== "ready");
895
- const kinds = reports.map((report) => report.kind);
896
- assert.deepEqual(
897
- kinds.slice(-2),
898
- ["complete", "heartbeat"],
899
- "the settled observation follows the completion: " + JSON.stringify(kinds),
900
- );
901
- const settled = reports.at(-1).data.observed;
902
- assert.equal(settled.agent, "idle");
903
- assert.deepEqual(dimensionKeys(settled), OBSERVED_KEYS, "the last observation states the agent and nothing else");
904
- assert.deepEqual(surface.calls.exits, ["done"]);
905
- });
906
-
907
- test("a session whose last beat was idle publishes no extra observation", async () => {
908
- const { agent, host, surface } = await startAgent();
916
+ test("a reconnect beat re-derives the phase from the surface", async () => {
917
+ let waiting = true;
918
+ const surface = fakeSurface({ waiting: () => waiting });
919
+ const { agent, host } = await startAgent({ surface });
909
920
  agent.start();
910
921
  await waitFor(() => host.of("report").length >= 1);
911
922
  host.notify("assign", assignArgs());
@@ -913,45 +924,21 @@ test("a session whose last beat was idle publishes no extra observation", async
913
924
  agent.onTurnStart();
914
925
  agent.onTurnEnd();
915
926
  await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
916
- agent.noteAssistantText("OK");
917
- const before = host.of("report").filter((report) => report.kind === "heartbeat").length;
918
-
919
- await agent.complete(TASK_ID, "done", "OK");
920
- assert.equal(
921
- host.of("report").filter((report) => report.kind === "heartbeat").length,
922
- before,
923
- "an already-idle session needs no second idle observation",
924
- );
925
- assert.deepEqual(surface.calls.exits, ["done"]);
926
- });
927
-
928
- test("a queued completion and its settled observation flush in order after reconnect", async () => {
929
- const { agent, host, surface } = await startAgent();
930
- agent.start();
931
- await waitFor(() => host.of("report").length >= 1);
932
- host.notify("assign", assignArgs());
933
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
934
- agent.onTurnStart();
935
- agent.onTurnEnd();
936
- agent.noteAssistantText("done offline");
937
927
 
928
+ const beforeReconnect = host.frames.length;
929
+ waiting = false;
938
930
  for (const socket of [...host.sockets]) socket.destroy();
939
931
  await waitFor(() => (agent.status().connected === false ? true : null));
940
- await agent.completeFromTool({ outcome: "done" });
941
- assert.deepEqual(surface.calls.exits, [], "nothing exits before the report lands");
942
-
943
- await waitFor(() => (host.connections >= 2 ? true : null));
944
- await waitFor(() => (surface.calls.exits.length === 1 ? true : null));
945
- const reports = host.of("report").filter((report) => report.kind !== "ready");
946
- assert.deepEqual(
947
- reports.slice(-2).map((report) => [report.kind, report.data.observed?.agent ?? null]),
948
- [
949
- ["complete", null],
950
- ["heartbeat", "idle"],
951
- ],
952
- "the settled observation rides after the flushed completion: " + JSON.stringify(reports),
932
+ const beat = await waitFor(() => host.frames
933
+ .slice(beforeReconnect)
934
+ .map((entry) => entry.frame)
935
+ .find((frame) => frame.op === "report" && frame.args?.kind === "heartbeat"));
936
+ assert.equal(beat.args.data.observed.agent, "running", "the reconnect asks the surface again");
937
+ assert.equal(
938
+ heartbeats(host).at(-1).agent,
939
+ "running",
940
+ "the old idle beat does not survive the reconnect",
953
941
  );
954
- assert.deepEqual(dimensionKeys(reports.at(-1).data.observed), OBSERVED_KEYS, "the flushed tail states the agent and nothing else");
955
942
  });
956
943
 
957
944
  test("an inbound image is written under the workspace and handed to pi", async () => {
@@ -986,7 +973,7 @@ test("a probe is answered with a heartbeat for the live task", async () => {
986
973
  const heartbeat = await waitFor(() =>
987
974
  host.of("report").find((report) => report.kind === "heartbeat" && report.data.task_id === TASK_ID),
988
975
  );
989
- assert.equal(heartbeat.data.observed.agent, "running");
976
+ assert.equal(heartbeat.data.observed.agent, "idle", "the probe re-derives a waiting session");
990
977
  assert.ok(heartbeat.data.seq > SEQ_BASE, "each heartbeat advances the plugin's own sequence");
991
978
  });
992
979
 
@@ -1382,27 +1369,6 @@ test("a repeating fault folds into one panel line with a count", () => {
1382
1369
  assert.equal(surface.calls.widget.length, 3, "every event redraws the same panel");
1383
1370
  });
1384
1371
 
1385
- test("the panel stays inside its line and width budget", () => {
1386
- const surface = fakeSurface({ widget: true });
1387
- const agent = new OnlyneAgent({
1388
- socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
1389
- cwd: tmpdir(),
1390
- role: "planner",
1391
- sessionId: SESSION_ID,
1392
- taskId: TASK_ID,
1393
- surface,
1394
- log: () => {},
1395
- });
1396
- for (let index = 0; index < 40; index += 1) {
1397
- agent.notice("in", `task ${index} from gateway:wechat:room ${"长".repeat(400)}`);
1398
- }
1399
- const [render] = surface.calls.widget.slice(-1);
1400
- assert.ok(render.length <= MAX_LINES, `panel grew to ${render.length} lines`);
1401
- for (const line of render) {
1402
- assert.ok(Array.from(line).length <= MAX_WIDTH, `line overruns its width: ${line}`);
1403
- }
1404
- });
1405
-
1406
1372
  test("a host without the widget keeps the footer line and the stderr line", async () => {
1407
1373
  const surface = fakeSurface();
1408
1374
  const { agent, host, logs } = await startAgent({ surface });
@@ -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
+ }