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.
package/src/agent.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // The agent side of the onlyne adapter protocol: one connection to
2
2
  // `<role workspace>/.onlyne/run/s`, the hello/welcome handshake, assign
3
- // delivery, turn-state reports, the completion exit, probe, recycle and
4
- // detach — plus reconnect when the client restarts under it.
3
+ // delivery, turn-state reports, the completion exit and the idle reminder
4
+ // ladder that leads to it, probe, recycle and detach — plus reconnect when the
5
+ // client restarts under it.
5
6
  //
6
7
  // Everything pi-specific lives behind `surface` (see pi-surface.mjs): this
7
8
  // module decides *what* the protocol says and hands the *effects* to the
@@ -19,6 +20,7 @@ import { createConnection } from "node:net";
19
20
  import { basename, join } from "node:path";
20
21
  import { createFrameDecoder, encodeFrame } from "./frame.mjs";
21
22
  import { createActivity } from "./activity.mjs";
23
+ import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
22
24
  import {
23
25
  DEFAULT_HEARTBEAT_MS,
24
26
  assignAckArgs,
@@ -36,7 +38,6 @@ import {
36
38
  sendEnvelope,
37
39
  sessionRegisterArgs,
38
40
  SEQ_BASE,
39
- settledReport,
40
41
  stdinTaskText,
41
42
  welcomeFrom,
42
43
  } from "./protocol.mjs";
@@ -93,6 +94,7 @@ export class OnlyneAgent {
93
94
  * requestTimeoutMs?: number,
94
95
  * helloTimeoutMs?: number,
95
96
  * settleFallbackMs?: number,
97
+ * idleReminders?: number,
96
98
  * createConnection?: (path: string) => any,
97
99
  * timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
98
100
  * }} options
@@ -116,6 +118,12 @@ export class OnlyneAgent {
116
118
  this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
117
119
  this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
118
120
  this.settleFallbackMs = options.settleFallbackMs ?? SETTLE_FALLBACK_MS;
121
+ /**
122
+ * How many idle reminders one task may collect: the bound that turns the
123
+ * third idle without a completion into a failure (`settleNow`). It comes
124
+ * from the workspace's `.pi/onlyne.json` (`config.mjs`).
125
+ */
126
+ this.idleReminders = options.idleReminders ?? DEFAULT_IDLE_REMINDERS;
119
127
  this.createConnection = options.createConnection ?? ((path) => createConnection(path));
120
128
  // The pane this process was spawned in, reported on every heartbeat so the
121
129
  // supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
@@ -494,14 +502,15 @@ export class OnlyneAgent {
494
502
  /**
495
503
  * One heartbeat for the task this connection serves.
496
504
  *
497
- * A task this plugin already completed gets none. `observed` is a full
498
- * snapshot, so a heartbeat sent after the completion report would put
499
- * `delivery: none` and `outcome: pending` back over the terminal tuple the
500
- * host derived from it, and the reducer accepts that snapshot: the session
501
- * would read `idle` again after having read `exited`. The e2e case
502
- * `crates/onlyne-testkit/e2e/pi-live.sh` caught exactly this race, where a
503
- * turn-end heartbeat left over from the finishing turn landed three
504
- * milliseconds behind the completion.
505
+ * A task this plugin already completed gets none: the beat is a full
506
+ * snapshot of what the plugin can see, and after the completion the agent's
507
+ * remaining turns are not this session's business — the row belongs to the
508
+ * settlement the completion earned. The client rewrites the tuple's `delivery`
509
+ * and `recovery` from its own records, so a late beat could no longer undo
510
+ * the drain even if it sent one. The e2e case
511
+ * `crates/onlyne-testkit/e2e/pi-live.sh` caught this race, where a turn-end
512
+ * heartbeat left over from the finishing turn landed three milliseconds
513
+ * behind the completion.
505
514
  */
506
515
  async heartbeat(agent = this.agentState) {
507
516
  if (!this.connected) return;
@@ -584,10 +593,16 @@ export class OnlyneAgent {
584
593
  if (typeof args.generation === "number") this.generation = args.generation;
585
594
 
586
595
  const attachments = this.writeAttachments(taskId, envelope);
596
+ const attachmentPaths = attachments.map((item) => item.path);
587
597
  const prose = typeof args.prose === "string" ? args.prose.trim() : "";
588
598
  const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
589
599
  if (proseIsNew) this.deliveredProse.add(prose);
590
- const text = injectionText({ assign: { ...args, task_id: taskId }, proseIsNew, attachmentPaths: attachments.map((item) => item.path) });
600
+ // The assignment as the injection saw it, retained so the idle ladder can
601
+ // re-send it (`remind`): the task identity, its source, its kind, the task
602
+ // text with whatever handoff lines a relay put inside it, and the paths of
603
+ // the attachments this call already wrote.
604
+ const assignment = { ...args, task_id: taskId };
605
+ const text = injectionText({ assign: assignment, proseIsNew, attachmentPaths });
591
606
 
592
607
  const held = this.tasks.get(taskId);
593
608
  // A completed record is not a live one: a new envelope for a task that
@@ -598,9 +613,16 @@ export class OnlyneAgent {
598
613
  // is: its delivered set keeps the relay guard's count, and its completion
599
614
  // state still settles the task. Only the "since this instruction" counter
600
615
  // moves, so the settled-without-completing watchdog measures the newest one.
616
+ // The ladder restarts with it: the reminder count, the idle episode it was
617
+ // charged to (`remindedAt`), and the failure an errored turn proved all
618
+ // belong to the instruction being replaced.
601
619
  held.turnsSinceAssign = 0;
620
+ held.reminders = 0;
621
+ held.remindedAt = 0;
622
+ held.errored = false;
602
623
  held.envelopeId = envelope.id ?? held.envelopeId;
603
- if (held.failed) held.failed = false;
624
+ held.assignment = assignment;
625
+ held.attachmentPaths = attachmentPaths;
604
626
  } else {
605
627
  this.tasks.set(taskId, {
606
628
  taskId,
@@ -612,7 +634,12 @@ export class OnlyneAgent {
612
634
  turns: 0,
613
635
  errored: false,
614
636
  head: "",
615
- failed: false,
637
+ assignment,
638
+ attachmentPaths,
639
+ /** Idle reminders sent for this task; the bound is `idleReminders`. */
640
+ reminders: 0,
641
+ /** The `turnsSinceAssign` the last reminder was charged to. */
642
+ remindedAt: 0,
616
643
  });
617
644
  }
618
645
  this.agentState = "running";
@@ -691,17 +718,18 @@ export class OnlyneAgent {
691
718
  this.armSettleFallback();
692
719
  }
693
720
 
694
- /** pi will not continue on its own: run the completion exit. */
721
+ /** pi will not continue on its own: take the settle decision for this idle. */
695
722
  onSettled() {
696
723
  this.clearSettleFallback();
697
724
  this.trySettle();
698
725
  }
699
726
 
700
727
  /**
701
- * The one completion trigger. pi keeps `isIdle()` false while it is running,
728
+ * The one settle decision. pi keeps `isIdle()` false while it is running,
702
729
  * retrying, compacting, or holding a queued continuation, so a settle signal
703
- * that arrives during any of those waits instead of reporting a premature
704
- * outcome.
730
+ * that arrives during any of those waits instead of deciding on a session
731
+ * that is still busy. The fallback timer and `agent_settled` both land here,
732
+ * and `settleNow` makes the decision idempotent for one idle episode.
705
733
  */
706
734
  trySettle() {
707
735
  if (this.closed || this.activeTasks().length === 0) return;
@@ -709,14 +737,13 @@ export class OnlyneAgent {
709
737
  this.armSettleFallback();
710
738
  return;
711
739
  }
712
- void this.settleNow().catch((error) => this.log(`completion failed: ${error.message}`));
740
+ void this.settleNow().catch((error) => this.log(`settle decision failed: ${error.message}`));
713
741
  }
714
742
 
715
- /** A failed turn: the task's outcome is `failed` unless it already ended. */
743
+ /** A failed turn: the task's outcome is `failed`, with the error as its head. */
716
744
  onTurnError(text) {
717
745
  for (const task of this.tasks.values()) {
718
746
  if (task.completed) continue;
719
- task.failed = true;
720
747
  task.errored = true;
721
748
  if (text) task.head = headOf(text);
722
749
  }
@@ -724,13 +751,15 @@ export class OnlyneAgent {
724
751
  }
725
752
 
726
753
  /**
727
- * The last assistant text seen, kept as the completion summary for a task the
728
- * model never handed an explicit argument over for.
754
+ * The last assistant text seen, kept as the completion summary for a task
755
+ * whose `onlyne_complete` call hands no argument over.
729
756
  *
730
757
  * This is the fallback, never the deliverable: `onlyne_complete`'s `text` is
731
758
  * reported byte for byte by `completeFromTool` and is never written back
732
759
  * here, so the sentence a turn happened to end on cannot stand in for a
733
- * payload the tool call carried.
760
+ * payload the tool call carried. Nothing else reads it: an idle that runs out
761
+ * of reminders fails the task and says why, rather than reporting the text of
762
+ * a turn nobody completed.
734
763
  */
735
764
  noteAssistantText(text) {
736
765
  const flat = headOf(text);
@@ -756,26 +785,78 @@ export class OnlyneAgent {
756
785
  }
757
786
 
758
787
  /**
759
- * The auto outcome rule: every active task whose turn produced output ends
760
- * `done` (or `failed` when the turn errored), with the last assistant text as
761
- * its head. That fallback is the only head this path may report — an explicit
762
- * `onlyne_complete` argument is the tool path's, and this rule runs after it,
763
- * for the tasks it left unsettled. A task assigned but not yet turned is left
764
- * alone: the injected message has not run yet, and completing now would lie.
788
+ * The idle ladder's decision, taken for every task this session still holds
789
+ * when a settle signal finds the agent idle.
790
+ *
791
+ * An errored turn is proof on its own — pi may skip the clean turn end — so
792
+ * its task is reported `failed` at once, with the error as its head. Every
793
+ * other open task gets one rung of the ladder: the plugin re-sends the
794
+ * assignment (`remind`) and counts it, and the idle that finds the bound
795
+ * already spent reports `failed` and leaves the session.
796
+ *
797
+ * `onlyne_complete` is the only path to `done`. A turn that ends without it
798
+ * leaves the task `idle_waiting` (the design's own word for it), and the
799
+ * answer is the reinforcing prompt the model gets instead of a completion it
800
+ * never claimed.
801
+ *
802
+ * A task whose injected message has not run a turn is left alone at every
803
+ * rung: the reminder would re-send an assignment the model may not have read
804
+ * yet, and failing now would claim work that never happened.
805
+ *
806
+ * A rung belongs to an idle episode, not to a settle signal: `remindedAt`
807
+ * records the `turnsSinceAssign` the last reminder was charged to, so the
808
+ * fallback timer and an `agent_settled` answering the same turn end spend one
809
+ * rung between them.
765
810
  */
766
811
  async settleNow() {
767
812
  for (const task of [...this.tasks.values()]) {
768
813
  if (task.completed) continue;
769
- // A turn has to have run: a full turn end is the ordinary proof, and an
770
- // errored turn is proof enough on its own (pi may skip the clean turn_end).
771
- if (task.turnsSinceAssign === 0 && !task.errored) continue;
772
- await this.complete(task.taskId, task.failed ? "failed" : "done", task.head);
814
+ if (task.errored) {
815
+ await this.complete(task.taskId, "failed", task.head);
816
+ continue;
817
+ }
818
+ if (task.turnsSinceAssign === 0 || task.remindedAt === task.turnsSinceAssign) continue;
819
+ task.remindedAt = task.turnsSinceAssign;
820
+ if (task.reminders < this.idleReminders) {
821
+ this.remind(task);
822
+ continue;
823
+ }
824
+ const rungs = `${task.reminders} idle reminder${task.reminders === 1 ? "" : "s"}`;
825
+ await this.complete(task.taskId, "failed", `no completion after ${rungs}`);
773
826
  }
774
827
  }
775
828
 
776
829
  /**
777
- * `onlyne_complete`: an explicit outcome from the model, which wins over the
778
- * auto rule.
830
+ * One rung of the idle ladder: hand the assignment back to the model.
831
+ *
832
+ * The reminder is the injection's own text — `injectionText` with the prose
833
+ * flag off, so the role prose already in the session's context is not
834
+ * repeated — under one line saying why it is back. The task text is where a
835
+ * relay carries its handoff lines, so a handoff the model still owes comes
836
+ * back with it.
837
+ *
838
+ * The attachments travel as the paths the injection wrote, never as parts:
839
+ * the images were handed to pi once, and re-attaching them would put the same
840
+ * bytes into the context a second time.
841
+ *
842
+ * @param {any} task a record with `assignment` and `attachmentPaths` retained
843
+ * from `onAssign`
844
+ */
845
+ remind(task) {
846
+ task.reminders += 1;
847
+ const rung = `reminder ${task.reminders} of ${this.idleReminders}`;
848
+ const text = [
849
+ `[onlyne] your turn ended without a completion exit; this task is still open (${rung}). Call onlyne_complete when it is finished.`,
850
+ "",
851
+ injectionText({ assign: task.assignment, proseIsNew: false, attachmentPaths: task.attachmentPaths }),
852
+ ].join("\n");
853
+ this.surface.wakeUser?.(text, []);
854
+ this.notice("out", `${rung} for task ${task.taskId.slice(0, 8)}`);
855
+ }
856
+
857
+ /**
858
+ * `onlyne_complete`: the model's explicit outcome, and the only path to
859
+ * `done`.
779
860
  *
780
861
  * A non-empty `text` is the completion body: it is what the model handed
781
862
  * over, reported verbatim as the ledger's one-line `head`, so the last
@@ -855,7 +936,7 @@ export class OnlyneAgent {
855
936
  this.notice("out", `complete ${taskId.slice(0, 8)} ${normalized}${summary ? `: ${summary}` : ""}`);
856
937
  if (this.activeTasks().length === 0) {
857
938
  if (exitProcess) {
858
- await this.reportSettled(taskId, normalized).catch((error) =>
939
+ await this.reportSettled(taskId).catch((error) =>
859
940
  this.log(`settled observation refused: ${error.message}`),
860
941
  );
861
942
  }
@@ -881,25 +962,29 @@ export class OnlyneAgent {
881
962
  }
882
963
 
883
964
  /**
884
- * One last observation before the process leaves: the tuple the host settled
885
- * plus `agent: idle`.
965
+ * One last observation before the process leaves: the agent dimension at
966
+ * `idle`.
886
967
  *
887
968
  * The completion settles the row from the tuple the client holds, which still
888
969
  * says `running` when the turn that finished was the last report sent, and
889
970
  * nothing observes the process afterwards. This report is what makes an
890
971
  * exited session read idle. It is skipped when the last beat was already
891
- * idle — the settled tuple is then already right — and it is a request for
972
+ * idle — the agent dimension is already right — and it is a request for
892
973
  * the same reason the completion is: the answer is the handover, and a
893
974
  * failure here must not stop the exit that the durable completion earned.
975
+ *
976
+ * It carries no completion fact. The outcome belongs to `report.complete`, and
977
+ * the drain the completion opens belongs to the client: an observation states
978
+ * where the agent is, not what the session's intent has done.
894
979
  */
895
- async reportSettled(taskId, outcome) {
980
+ async reportSettled(taskId) {
896
981
  if (!this.connected || this.lastPhase === "idle") return false;
897
982
  this.seq += 1;
898
- await this.request("report", settledReport({
983
+ await this.request("report", heartbeatReport({
899
984
  taskId,
900
- outcome,
901
985
  generation: this.generation,
902
986
  seq: this.seq,
987
+ agent: "idle",
903
988
  host: this.host,
904
989
  }));
905
990
  this.stats.reports += 1;
@@ -917,7 +1002,7 @@ export class OnlyneAgent {
917
1002
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
918
1003
  this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
919
1004
  if (pending.exitProcess && this.activeTasks().length === 0) {
920
- await this.reportSettled(pending.taskId, pending.outcome).catch((error) =>
1005
+ await this.reportSettled(pending.taskId).catch((error) =>
921
1006
  this.log(`settled observation refused: ${error.message}`),
922
1007
  );
923
1008
  this.exitSession(pending.outcome);
@@ -928,6 +1013,21 @@ export class OnlyneAgent {
928
1013
  }
929
1014
  }
930
1015
 
1016
+ /**
1017
+ * One inline image read off the path the model named, in the wire's
1018
+ * `ImagePart` shape. `mimeForPath` refuses an extension outside the core's
1019
+ * four mime types, and `imagePart` refuses a payload above the byte ceiling.
1020
+ * @param {string | null | undefined} path
1021
+ */
1022
+ imageFromPath(path) {
1023
+ if (!path) return null;
1024
+ return imagePart({
1025
+ data: readFileSync(path),
1026
+ mime: mimeForPath(path),
1027
+ name: path.split("/").pop() ?? null,
1028
+ });
1029
+ }
1030
+
931
1031
  /**
932
1032
  * `onlyne_send`: submit one envelope.
933
1033
  * @param {{ to: string, text?: string, kind?: string, imagePath?: string | null }} input
@@ -935,11 +1035,7 @@ export class OnlyneAgent {
935
1035
  async sendFromTool(input) {
936
1036
  if (!this.connected) throw new Error("onlyne: client socket is not connected");
937
1037
  const kind = input.kind === "task" ? "task" : "note";
938
- let image = null;
939
- if (input.imagePath) {
940
- const bytes = readFileSync(input.imagePath);
941
- image = imagePart({ data: bytes, mime, name: input.imagePath.split("/").pop() ?? null });
942
- }
1038
+ const image = this.imageFromPath(input.imagePath);
943
1039
  const envelope = sendEnvelope({ from: this.role, to: input.to, kind, text: input.text ?? "", image });
944
1040
  const data = await this.request("send", envelope);
945
1041
  // Recorded only after the client answered the `send`: a refused envelope was
@@ -949,6 +1045,36 @@ export class OnlyneAgent {
949
1045
  return { queued: true, op_id: envelope.op_id ?? null, kind, to: input.to, data };
950
1046
  }
951
1047
 
1048
+ /**
1049
+ * `onlyne_handoff`: hand this session's task on to the next hop of its family.
1050
+ *
1051
+ * The task named in the request is the session's own current one, so the child
1052
+ * the host mints under it is a continuation of the family this session serves:
1053
+ * the host reads the parent's causality, derives the child through
1054
+ * `Causality::child_of`, and answers the child's task id and hop. The client's
1055
+ * own refusal is raised out of here as it arrived.
1056
+ * @param {{ to: string, text?: string, imagePath?: string | null }} input
1057
+ */
1058
+ async handoffFromTool(input) {
1059
+ if (!this.connected) throw new Error("onlyne: client socket is not connected");
1060
+ const taskId = this.activeTaskId();
1061
+ if (!taskId) throw new Error("onlyne: no task is assigned to this session");
1062
+ const data = await this.request("handoff", {
1063
+ task_id: taskId,
1064
+ to: input.to,
1065
+ text: input.text ?? "",
1066
+ image: this.imageFromPath(input.imagePath),
1067
+ });
1068
+ const answer = data ?? {};
1069
+ return {
1070
+ taskId: answer.task_id ?? null,
1071
+ hop: answer.hop ?? null,
1072
+ queued: answer.queued ?? false,
1073
+ opId: answer.op_id ?? null,
1074
+ to: input.to,
1075
+ };
1076
+ }
1077
+
952
1078
  // ------------------------------------------------------------ attachments
953
1079
 
954
1080
  /**