pi-onlyne 1.1.2 → 1.2.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.
@@ -3,7 +3,7 @@
3
3
  // wire vector, so the input side is byte-identical to what the client sends.
4
4
 
5
5
  import assert from "node:assert/strict";
6
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { createServer } from "node:net";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
@@ -12,6 +12,7 @@ import { afterEach, test } from "node:test";
12
12
 
13
13
  import { OnlyneAgent } from "./agent.mjs";
14
14
  import { MAX_LINES, MAX_WIDTH } from "./activity.mjs";
15
+ import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
15
16
  import { createFrameDecoder, encodeFrame } from "./frame.mjs";
16
17
  import { SEQ_BASE, readyReport } from "./protocol.mjs";
17
18
 
@@ -22,6 +23,15 @@ const ASSIGN_FRAME = existsSync(VECTOR_DIR)
22
23
  ? JSON.parse(JSON.parse(readFileSync(`${VECTOR_DIR}adapter_host_assign.json`, "utf8")).frame)
23
24
  : null;
24
25
  const TASK_ID = "11111111-1111-4111-8111-111111111111";
26
+ /** A task the host hands this session, distinct from the id it was spawned with. */
27
+ const ASSIGNED_TASK_ID = "33333333-3333-4333-8333-333333333333";
28
+ /**
29
+ * The answer a real host gives one `handoff`: the child it minted, and the hop
30
+ * that child sits at. The hop is a figure no client can derive — it comes off
31
+ * the parent's causality — so a test asserting it reads the host's own number.
32
+ */
33
+ const CHILD_TASK_ID = "22222222-2222-4222-8222-222222222222";
34
+ const HANDOFF_ANSWER = { task_id: CHILD_TASK_ID, hop: 3, queued: true, op_id: `o-${CHILD_TASK_ID}` };
25
35
  const SESSION_ID = "8b1c";
26
36
  const PANE_KEY = "45e603f7-0772-48aa-bcf6-832272747713:b6d067b6-9255-4f5c-a13f-24f194ea0560";
27
37
  const PANE_TAB = "45e603f7-0772-48aa-bcf6-832272747713";
@@ -42,6 +52,34 @@ function heartbeats(host) {
42
52
  .map((args) => args.data.observed);
43
53
  }
44
54
 
55
+ /**
56
+ * The exact keys of a plugin observation outside a pane. `agent`, `resource` and
57
+ * the reconcile defaults are what the plugin states; `delivery` and `recovery`
58
+ * ride as placeholders the client overwrites with its own records, and no
59
+ * outcome or public view travels in a tuple at all.
60
+ */
61
+ const OBSERVED_KEYS = [
62
+ "version",
63
+ "generation_live",
64
+ "isolate_after",
65
+ "terminate_after",
66
+ "mismatch_count",
67
+ "agent",
68
+ "delivery",
69
+ "resource",
70
+ "recovery",
71
+ ];
72
+
73
+ /**
74
+ * The observation's keys with the pane binding discounted: `host` rides along
75
+ * when the process was spawned in an Orca pane, and the test host's own
76
+ * environment decides whether it does.
77
+ */
78
+ function dimensionKeys(observed) {
79
+ const { host, ...rest } = observed;
80
+ return Object.keys(rest);
81
+ }
82
+
45
83
  /** Poll until `predicate` holds, so a test never races the event loop. */
46
84
  async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
47
85
  const deadline = Date.now() + timeoutMs;
@@ -63,9 +101,12 @@ async function waitFor(predicate, { timeoutMs = 2_000, stepMs = 5 } = {}) {
63
101
  * the client has acknowledged the outcome. `failSend` refuses every `send` the
64
102
  * way a client with no route to the target would. The ready and heartbeat
65
103
  * reports are never held: the handshake waits on them.
104
+ * `handoffError` refuses every `handoff` with that error body, the way a client
105
+ * that cannot find the task the session names would; with none, the fake answers
106
+ * the child the proto documents.
66
107
  */
67
108
  class FakeHost {
68
- constructor({ coalesceAssign = null, holdCompletion = false, failSend = false } = {}) {
109
+ constructor({ coalesceAssign = null, holdCompletion = false, failSend = false, handoffError = null } = {}) {
69
110
  this.server = createServer((socket) => this.onConnection(socket));
70
111
  this.frames = [];
71
112
  this.sockets = new Set();
@@ -73,6 +114,7 @@ class FakeHost {
73
114
  this.coalesceAssign = coalesceAssign;
74
115
  this.holdCompletion = holdCompletion;
75
116
  this.failSend = failSend;
117
+ this.handoffError = handoffError;
76
118
  this.held = [];
77
119
  }
78
120
 
@@ -126,6 +168,11 @@ class FakeHost {
126
168
  }));
127
169
  return;
128
170
  }
171
+ if (frame.op === "handoff") {
172
+ body = this.handoffError
173
+ ? { ok: false, error: this.handoffError }
174
+ : { ok: true, data: HANDOFF_ANSWER };
175
+ }
129
176
  const reply = encodeFrame({ reply_to: frame.id, ...body });
130
177
  const push = frame.op === "hello" && this.coalesceAssign
131
178
  ? encodeFrame({ op: "assign", args: this.coalesceAssign })
@@ -202,11 +249,12 @@ async function startAgent({
202
249
  coalesceAssign = null,
203
250
  holdCompletion = false,
204
251
  failSend = false,
252
+ handoffError = null,
205
253
  relay = null,
206
254
  } = {}) {
207
255
  const dir = mkdtempSync(join(tmpdir(), "pi-onlyne-agent-"));
208
256
  const socketPath = join(dir, "s");
209
- const host = new FakeHost({ coalesceAssign, holdCompletion, failSend });
257
+ const host = new FakeHost({ coalesceAssign, holdCompletion, failSend, handoffError });
210
258
  await host.listen(socketPath);
211
259
  const logs = [];
212
260
  const agent = new OnlyneAgent({
@@ -298,7 +346,8 @@ test("every heartbeat names the Orca pane this process was spawned in", async ()
298
346
  });
299
347
  // The binding rides beside the state dimensions instead of replacing them.
300
348
  assert.equal(observed.agent, "running");
301
- assert.equal(observed.public, "working");
349
+ assert.equal(observed.resource, "attached");
350
+ assert.deepEqual(Object.keys(observed), [...OBSERVED_KEYS, "host"]);
302
351
  assert.equal(observed.version.generation, 1);
303
352
  });
304
353
  });
@@ -535,7 +584,7 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
535
584
  assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
536
585
  });
537
586
 
538
- test("turn hooks report running then idle, and a settle completes done with the head", async () => {
587
+ test("turn hooks report running then idle, and the settle after them re-sends the assignment", async () => {
539
588
  const { agent, host, surface } = await startAgent();
540
589
  agent.start();
541
590
  await waitFor(() => host.of("report").length >= 1);
@@ -545,31 +594,34 @@ test("turn hooks report running then idle, and a settle completes done with the
545
594
  agent.onTurnStart();
546
595
  const running = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
547
596
  assert.equal(running.kind, "heartbeat");
548
- assert.equal(running.data.observed.public, "working");
597
+ assert.equal(running.data.observed.resource, "attached");
598
+ assert.deepEqual(dimensionKeys(running.data.observed), OBSERVED_KEYS, "a running beat carries exactly the dimensions the plugin can see");
549
599
  assert.equal(running.data.observed.version.generation, 1);
550
600
 
551
601
  agent.onTurnEnd();
552
602
  const idle = await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
553
- assert.equal(idle.data.observed.public, "idle");
603
+ assert.equal(idle.kind, "heartbeat");
604
+ assert.deepEqual(dimensionKeys(idle.data.observed), OBSERVED_KEYS, "an idle beat claims no dimension the plugin does not own");
554
605
 
555
606
  agent.noteAssistantText("OK");
556
607
  agent.onSettled();
557
- const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
558
- assert.deepEqual(complete, { kind: "complete", data: { task_id: TASK_ID, outcome: "done", head: "OK" } });
559
- assert.deepEqual(agent.status().tasks, []);
560
- // The completion ends the session: the process that ran it is asked to leave.
561
- assert.deepEqual(surface.calls.exits, ["done"]);
562
- // Heartbeats stop with the last task, so a settled session stops writing.
563
- const reports = host.of("report").length;
564
- await new Promise((resolve) => setTimeout(resolve, 120));
565
- assert.equal(host.of("report").length, reports);
608
+ // A turn that ends without a completion leaves the task open: the settle sends
609
+ // the assignment again rather than reporting an outcome the model never
610
+ // claimed. `onlyne_complete` is the only path to `done`.
611
+ const reminded = await waitFor(() =>
612
+ surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
613
+ );
614
+ assert.match(reminded[1].text, /build it/, "the reminder carries the task text");
615
+ assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
616
+ assert.deepEqual(surface.calls.exits, [], "a task with a rung left is not failed");
566
617
  });
567
618
 
568
619
  // The live case found this ordering too: pi's turn-end hook fires in the same
569
620
  // millisecond as the settle that reports the completion, so the turn-end
570
- // heartbeat lands after the completion report. `observed` is a whole snapshot,
571
- // and one carrying `delivery: none`/`outcome: pending` puts the session back to
572
- // `idle` after the host has already recorded `exited`.
621
+ // heartbeat lands after the completion report. The beat no longer carries a
622
+ // delivery or recovery the host would believe — the client composes those from
623
+ // its own records — but a snapshot of an agent dimension nobody is watching any
624
+ // more is still a frame the protocol does not need, so the plugin stays quiet.
573
625
  test("a turn-end heartbeat after the completion never leaves the plugin", async () => {
574
626
  const { agent, host, surface } = await startAgent();
575
627
  agent.start();
@@ -588,7 +640,7 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
588
640
  assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
589
641
  });
590
642
 
591
- test("a busy pi holds the completion until it is idle", async () => {
643
+ test("a busy pi holds the settle decision until it is idle", async () => {
592
644
  let idle = false;
593
645
  const surface = fakeSurface({ idle: () => idle });
594
646
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
@@ -597,14 +649,95 @@ test("a busy pi holds the completion until it is idle", async () => {
597
649
  host.notify("assign", assignArgs());
598
650
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
599
651
  agent.onTurnEnd();
600
- agent.noteAssistantText("still working");
601
652
  agent.onSettled();
602
653
  await new Promise((resolve) => setTimeout(resolve, 80));
603
- assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
654
+ assert.equal(surface.calls.wakeUser.length, 1, "a busy pi is not reminded");
604
655
 
605
656
  idle = true;
657
+ const reminded = await waitFor(() =>
658
+ surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
659
+ );
660
+ assert.match(reminded[1].text, /build it/, "the decision waits for the idle it is about");
661
+ });
662
+
663
+ // The idle ladder (`docs/SWARM-REFACTOR-GRILLME.md` §4.2): a turn that ends
664
+ // without a completion exit re-sends the assignment, and the idle that finds the
665
+ // bound spent fails the task instead of settling it `done`. The bound is the
666
+ // workspace's (`config.mjs`, two by default) and the count lives on the record
667
+ // `onAssign` wrote for the task, which is why a task can be reminded twice and
668
+ // 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 () => {
670
+ const { agent, host, surface } = await startAgent();
671
+ assert.equal(agent.idleReminders, DEFAULT_IDLE_REMINDERS, "the bound is the workspace's, two by default");
672
+ agent.start();
673
+ await waitFor(() => host.of("report").length >= 1);
674
+ // The assignment carries an image, so the reminder has an attachment path to
675
+ // name and a part it must not hand over twice.
676
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
677
+ const assignment = structuredClone(assignArgs());
678
+ assignment.envelope.body = {
679
+ text: "build it",
680
+ image: { data_base64: bytes.toString("base64"), mime: "image/png", name: "shot.png" },
681
+ };
682
+ host.notify("assign", assignment);
683
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
684
+ assert.equal(surface.calls.wakeUser[0].parts.length, 1, "the injection carries the image");
685
+
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
+ }
712
+
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.
715
+ agent.onTurnStart();
716
+ agent.onTurnEnd();
717
+ agent.onSettled();
606
718
  const complete = await waitFor(() => host.of("report").find((report) => report.kind === "complete"));
607
- assert.equal(complete.data.head, "still working");
719
+ assert.deepEqual(complete.data, {
720
+ task_id: TASK_ID,
721
+ outcome: "failed",
722
+ head: "no completion after 2 idle reminders",
723
+ });
724
+ 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");
726
+
727
+ // A turn that hands the outcome over is never reminded: the tool call is the
728
+ // 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();
738
+ 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"]);
608
741
  });
609
742
 
610
743
  test("an assigned task that never ran is not completed", async () => {
@@ -660,9 +793,9 @@ test("an explicit tool outcome wins and a second completion is refused", async (
660
793
  });
661
794
 
662
795
  // The completion body is what the tool call handed over. The sentence a turn
663
- // ends on is only the fallback for a task whose argument carries nothing, and
664
- // the auto rule reports exactly that fallback field — so an explicit argument
665
- // can never be displaced, in either call path.
796
+ // ends on is only the fallback for a call whose argument carries nothing, and a
797
+ // call that carries a `text` reports it byte for byte — so the argument can
798
+ // never be displaced, before or after the call.
666
799
  test("an explicit tool argument is the head over the last assistant text", async () => {
667
800
  const { agent, host, surface } = await startAgent();
668
801
  agent.start();
@@ -767,10 +900,7 @@ test("a completion after a running beat publishes the settled observation before
767
900
  );
768
901
  const settled = reports.at(-1).data.observed;
769
902
  assert.equal(settled.agent, "idle");
770
- assert.equal(settled.outcome, "done");
771
- assert.equal(settled.delivery, "accepted");
772
- assert.equal(settled.recovery, "draining");
773
- assert.equal(settled.public, "exited");
903
+ assert.deepEqual(dimensionKeys(settled), OBSERVED_KEYS, "the last observation states the agent and nothing else");
774
904
  assert.deepEqual(surface.calls.exits, ["done"]);
775
905
  });
776
906
 
@@ -821,7 +951,7 @@ test("a queued completion and its settled observation flush in order after recon
821
951
  ],
822
952
  "the settled observation rides after the flushed completion: " + JSON.stringify(reports),
823
953
  );
824
- assert.equal(reports.at(-1).data.observed.outcome, "done");
954
+ assert.deepEqual(dimensionKeys(reports.at(-1).data.observed), OBSERVED_KEYS, "the flushed tail states the agent and nothing else");
825
955
  });
826
956
 
827
957
  test("an inbound image is written under the workspace and handed to pi", async () => {
@@ -1128,6 +1258,90 @@ test("the handoff ledger belongs to the session, not to one task", async () => {
1128
1258
  assert.equal(completions(host).length, 1);
1129
1259
  });
1130
1260
 
1261
+ // ------------------------------------------------------------ the handoff tool
1262
+
1263
+ test("a handoff names the task this session holds and hands the answer back", async () => {
1264
+ const { agent, host } = await startAgent();
1265
+ agent.start();
1266
+ await waitFor(() => host.of("report").length >= 1);
1267
+
1268
+ // The task the host handed this session is the family's parent, so that id is
1269
+ // the one the request names: the spawn id stays behind it.
1270
+ const args = structuredClone(assignArgs());
1271
+ args.task_id = ASSIGNED_TASK_ID;
1272
+ args.envelope.causality.task = ASSIGNED_TASK_ID;
1273
+ host.notify("assign", args);
1274
+ await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
1275
+
1276
+ const result = await agent.handoffFromTool({ to: "builder", text: "take it from here" });
1277
+ assert.deepEqual(host.of("handoff"), [
1278
+ { task_id: ASSIGNED_TASK_ID, to: "builder", text: "take it from here", image: null },
1279
+ ]);
1280
+ assert.deepEqual(result, {
1281
+ taskId: CHILD_TASK_ID,
1282
+ hop: HANDOFF_ANSWER.hop,
1283
+ queued: true,
1284
+ opId: `o-${CHILD_TASK_ID}`,
1285
+ to: "builder",
1286
+ });
1287
+ });
1288
+
1289
+ test("a handoff reads the image the model named and types it from its path", async () => {
1290
+ const { agent, host, dir } = await startAgent();
1291
+ agent.start();
1292
+ await waitFor(() => host.of("report").length >= 1);
1293
+
1294
+ const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
1295
+ const shot = join(dir, "shot.png");
1296
+ writeFileSync(shot, bytes);
1297
+ await agent.handoffFromTool({ to: "builder", text: "the shot", imagePath: shot });
1298
+ assert.deepEqual(host.of("handoff")[0].image, {
1299
+ data_base64: bytes.toString("base64"),
1300
+ mime: "image/png",
1301
+ name: "shot.png",
1302
+ });
1303
+
1304
+ // A path this plugin cannot type is refused here, so no half-typed handoff
1305
+ // reaches the wire.
1306
+ const bad = join(dir, "shot.bmp");
1307
+ writeFileSync(bad, bytes);
1308
+ await assert.rejects(
1309
+ () => agent.handoffFromTool({ to: "builder", text: "the shot", imagePath: bad }),
1310
+ /unsupported image type/,
1311
+ );
1312
+ assert.equal(host.of("handoff").length, 1);
1313
+ });
1314
+
1315
+ test("a send types its image from the path too", async () => {
1316
+ const { agent, host, dir } = await startAgent();
1317
+ agent.start();
1318
+ await waitFor(() => host.of("report").length >= 1);
1319
+
1320
+ const path = join(dir, "diagram.webp");
1321
+ writeFileSync(path, Buffer.from([1, 2, 3]));
1322
+ await agent.sendFromTool({ to: "writer", text: "the diagram", imagePath: path });
1323
+
1324
+ assert.deepEqual(host.of("send")[0].body.image, {
1325
+ data_base64: "AQID",
1326
+ mime: "image/webp",
1327
+ name: "diagram.webp",
1328
+ });
1329
+ });
1330
+
1331
+ test("a handoff the client refuses throws the client's own reason", async () => {
1332
+ const { agent, host } = await startAgent({
1333
+ handoffError: { code: "no_family", message: "no task family for that id" },
1334
+ });
1335
+ agent.start();
1336
+ await waitFor(() => host.of("report").length >= 1);
1337
+
1338
+ await assert.rejects(
1339
+ () => agent.handoffFromTool({ to: "builder", text: "take it" }),
1340
+ /no_family: no task family for that id/,
1341
+ );
1342
+ assert.equal(host.of("handoff").length, 1, "a refusal is not retried");
1343
+ });
1344
+
1131
1345
  // ---------------------------------------------------------------- the panel
1132
1346
 
1133
1347
  test("a pi with the widget gets the panel and keeps a quiet scrollback", async () => {
package/src/config.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // generate-time template advice), so the only consumer is this extension. A
7
7
  // malformed or missing file falls back to the defaults and reports a warning
8
8
  // instead of disabling the session: the extension's own `enabled` key is the one
9
- // deliberate off switch.
9
+ // deliberate off switch. A key whose value is unusable — the idle bound below,
10
+ // say — keeps the one default it names and leaves the rest of the file alone.
10
11
 
11
12
  import { readFileSync } from "node:fs";
12
13
  import { join } from "node:path";
@@ -14,15 +15,26 @@ import { join } from "node:path";
14
15
  /** Where the switch file lives, relative to the pi working directory. */
15
16
  export const CONFIG_RELATIVE_PATH = join(".pi", "onlyne.json");
16
17
 
17
- /** Defaults: on, and connecting as soon as a session starts. */
18
- export const DEFAULT_CONFIG = Object.freeze({ enabled: true, autoStart: true });
18
+ /**
19
+ * How many idle reminders one task may collect before the ladder fails it
20
+ * (`agent.mjs` `settleNow`): two, so the third idle without a completion is the
21
+ * failure.
22
+ */
23
+ export const DEFAULT_IDLE_REMINDERS = 2;
24
+
25
+ /** Defaults: on, connecting as soon as a session starts, and the idle bound. */
26
+ export const DEFAULT_CONFIG = Object.freeze({
27
+ enabled: true,
28
+ autoStart: true,
29
+ idleReminders: DEFAULT_IDLE_REMINDERS,
30
+ });
19
31
 
20
32
  /**
21
33
  * Read `.pi/onlyne.json`.
22
34
  *
23
35
  * @param {string} cwd
24
36
  * @param {{ readFile?: (path: string) => string }} [options]
25
- * @returns {{ enabled: boolean, autoStart: boolean, path: string, warning: string | null, present: boolean }}
37
+ * @returns {{ enabled: boolean, autoStart: boolean, idleReminders: number, path: string, warning: string | null, present: boolean }}
26
38
  */
27
39
  export function loadConfig(cwd, options = {}) {
28
40
  const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
@@ -48,11 +60,21 @@ export function loadConfig(cwd, options = {}) {
48
60
  return { ...DEFAULT_CONFIG, path, warning: `${path} must hold a JSON object; using defaults`, present: true };
49
61
  }
50
62
  const watch = parsed.watch && typeof parsed.watch === "object" ? parsed.watch : {};
63
+ // The bound is a count, so only a non-negative integer is a value: a string,
64
+ // a fraction or a negative would either count nothing or count forever.
65
+ // Zero is a value — it says the first idle without a completion is already
66
+ // the failure — and it is the operator's call to make.
67
+ const idleReminders = parsed.idleReminders;
68
+ const usable = Number.isInteger(idleReminders) && idleReminders >= 0;
51
69
  return {
52
70
  enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
53
71
  autoStart: typeof watch.autoStart === "boolean" ? watch.autoStart : DEFAULT_CONFIG.autoStart,
72
+ idleReminders: usable ? idleReminders : DEFAULT_CONFIG.idleReminders,
54
73
  path,
55
- warning: null,
74
+ warning:
75
+ idleReminders !== undefined && !usable
76
+ ? `${path} idleReminders must be a non-negative integer; using ${DEFAULT_CONFIG.idleReminders}`
77
+ : null,
56
78
  present: true,
57
79
  };
58
80
  }
package/src/index.ts CHANGED
@@ -9,9 +9,9 @@
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}
12
+ // turn_end -> heartbeat{idle}; the settle window opens
13
13
  // message_end -> keep the last assistant text; a failed turn is `failed`
14
- // agent_settled -> completion exit: done|failed
14
+ // agent_settled -> settle decision: the idle ladder, or `failed` at once
15
15
  // session_shutdown -> detach{reason}
16
16
 
17
17
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -165,10 +165,11 @@ export default function onlyne(pi: ExtensionAPI) {
165
165
  name: "onlyne_complete",
166
166
  label: "Onlyne complete",
167
167
  description:
168
- "End this onlyne task with an explicit outcome. Call it once, when the assigned work is finished (outcome=done), provably impossible (outcome=failed), or withdrawn (outcome=cancelled). Without this call the session still completes on its own: done, or failed when the turn errored. In a workspace whose relay policy (relay.toml) names the handoffs this session owes, the call is refused until each one has gone out.",
168
+ "End this onlyne task with an explicit outcome. Call it once, when the assigned work is finished (outcome=done), provably impossible (outcome=failed), or withdrawn (outcome=cancelled). This call is the only way the task reaches done: a turn that ends without it leaves the task open, the session re-sends you the assignment up to the workspace's idle-reminder bound, and the idle that finds the bound spent fails the task and ends the session. In a workspace whose relay policy (relay.toml) names the handoffs this session owes, the call is refused until each one has gone out.",
169
169
  promptSnippet: "Finish the current onlyne task with an outcome and a one-line summary",
170
170
  promptGuidelines: [
171
171
  "Use onlyne_complete at the end of an onlyne task, naming the outcome and the result in one line; the summary becomes the ledger head.",
172
+ "If the assignment is sent to you again while it is still open, the previous turn ended without a completion: finish the work and call onlyne_complete.",
172
173
  "If onlyne_complete answers 'relay guard', the session still owes a downstream handoff: make it with onlyne_send and call onlyne_complete again. Close the session anyway only when the handoff is genuinely impossible, with force: true and a reason.",
173
174
  ],
174
175
  parameters: Type.Object({
@@ -196,6 +197,36 @@ export default function onlyne(pi: ExtensionAPI) {
196
197
  } catch (error) {
197
198
  log(`registerTool(onlyne_complete) refused: ${error instanceof Error ? error.message : String(error)}`);
198
199
  }
200
+ try {
201
+ pi.registerTool(defineTool({
202
+ name: "onlyne_handoff",
203
+ label: "Onlyne handoff",
204
+ description:
205
+ "Hand this session's task on to the next hop of its family. The host mints one child task for the named role, names this task as the child's parent_task, raises the hop by one, and lets the family's budget, labels, origin and deadline ride along, so the child continues the run this session serves. Use it for the next slot of a ring or a chain; onlyne_send{kind:\"task\"} starts a new family at hop 0, and onlyne_send{kind:\"note\"} is free text.",
206
+ promptSnippet: "Hand this task on to the next role of its family",
207
+ promptGuidelines: [
208
+ "Use onlyne_handoff when the work goes on to the next role of the run this session serves: the child the host mints carries the same family id, hop budget, labels, origin and deadline, and this task becomes its parent_task.",
209
+ "Use onlyne_send with kind=\"task\" when a role should get work of its own: that child is hop 0 of a family this session starts.",
210
+ "Use onlyne_send with kind=\"note\" for free text to a role, which carries no task and no hop.",
211
+ ],
212
+ parameters: Type.Object({
213
+ to: Type.String({ description: "target role name, e.g. builder" }),
214
+ text: Type.String({ description: "handoff text for the next role of the run" }),
215
+ image: Type.Optional(Type.String({ description: "absolute path to a png/jpeg/gif/webp image to attach" })),
216
+ }),
217
+ async execute(_toolCallId, params) {
218
+ if (!agent) throw new Error("onlyne: session is not connected");
219
+ const result = await agent.handoffFromTool({
220
+ to: params.to,
221
+ text: params.text,
222
+ imagePath: params.image ?? null,
223
+ });
224
+ return textResult(`handed on to ${result.to} as ${result.taskId} at hop ${result.hop}`, result);
225
+ },
226
+ }));
227
+ } catch (error) {
228
+ log(`registerTool(onlyne_handoff) refused: ${error instanceof Error ? error.message : String(error)}`);
229
+ }
199
230
  try {
200
231
  pi.registerCommand("onlyne", {
201
232
  description: "Onlyne session status: connection, task, reports",
@@ -256,6 +287,7 @@ export default function onlyne(pi: ExtensionAPI) {
256
287
  taskId: identity.taskId,
257
288
  surface,
258
289
  relay,
290
+ idleReminders: config.idleReminders,
259
291
  log,
260
292
  });
261
293
  log(`session ${identity.sessionId} role=${identity.role} socket=${socketPath}`);