pi-onlyne 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -167,8 +175,6 @@ export class OnlyneAgent {
167
175
  this.lastError = null;
168
176
  /** Whether pi has already been asked to end this process (`exitSession`). */
169
177
  this.exitRequested = false;
170
- /** Agent phase of the last heartbeat this connection sent, if any. */
171
- this.lastPhase = null;
172
178
  this.stats = { assigns: 0, duplicates: 0, injections: 0, completions: 0, reports: 0, reconnects: 0, recycles: 0 };
173
179
  }
174
180
 
@@ -317,9 +323,10 @@ export class OnlyneAgent {
317
323
  }
318
324
  this.welcome = welcome;
319
325
  this.connected = true;
320
- // A fresh connection has reported nothing: the next beat is news.
321
- this.lastPhase = null;
322
- this.agentState = this.tasks.size > 0 ? "idle" : "ready";
326
+ // A fresh connection has reported nothing, so the phase is a question, not
327
+ // an answer: the first beat below re-derives it from pi. A taskless session
328
+ // is the ready pool, and ready is its own state.
329
+ this.agentState = this.tasks.size > 0 ? await this.derivedPhase() : "ready";
323
330
  this.activity.set({
324
331
  role: welcome.role,
325
332
  connection: "connected",
@@ -492,33 +499,55 @@ export class OnlyneAgent {
492
499
  }
493
500
 
494
501
  /**
495
- * One heartbeat for the task this connection serves.
502
+ * One heartbeat for the task this connection serves. The phase is the one the
503
+ * rule names: `idle` only while the session waits for user input, `running`
504
+ * for everything else, re-derived from pi on every beat so a phase that went
505
+ * stale when a run started again cannot survive a tick.
496
506
  *
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.
507
+ * A task this plugin already completed gets none: the beat is a full
508
+ * snapshot of what the plugin can see, and after the completion the agent's
509
+ * remaining turns are not this session's business — the row belongs to the
510
+ * settlement the completion earned. The client rewrites the tuple's `delivery`
511
+ * and `recovery` from its own records, so a late beat could no longer undo
512
+ * the drain even if it sent one. The e2e case
513
+ * `crates/onlyne-testkit/e2e/pi-live.sh` caught this race, where a turn-end
514
+ * heartbeat left over from the finishing turn landed three milliseconds
515
+ * behind the completion.
505
516
  */
506
- async heartbeat(agent = this.agentState) {
517
+ async heartbeat(agent = null) {
507
518
  if (!this.connected) return;
508
519
  const taskId = this.activeTaskId();
509
520
  if (!taskId) return;
510
521
  if (this.tasks.get(taskId)?.completed) return;
511
- this.agentState = agent;
522
+ const phase = agent ?? (await this.derivedPhase());
523
+ this.agentState = phase;
512
524
  this.seq += 1;
513
525
  await this.request("report", heartbeatReport({
514
526
  taskId,
515
527
  generation: this.generation,
516
528
  seq: this.seq,
517
- agent,
529
+ agent: phase,
518
530
  host: this.host,
519
531
  }));
520
532
  this.stats.reports += 1;
521
- this.lastPhase = agent;
533
+ }
534
+
535
+ /**
536
+ * Where the session actually is, in the only two words the wire has for it.
537
+ * The surface asks pi, and adds the one case pi cannot see: work a
538
+ * background-task extension took off the agent loop.
539
+ * @returns {Promise<"idle"|"running">}
540
+ */
541
+ async derivedPhase() {
542
+ try {
543
+ const waiting = await this.surface.waitingForInput?.();
544
+ return waiting === true ? "idle" : "running";
545
+ } catch (error) {
546
+ // A surface that cannot answer has not witnessed a session waiting for
547
+ // input, and the rule reads that as running.
548
+ this.log(`input-waiting probe failed: ${error.message}`);
549
+ return "running";
550
+ }
522
551
  }
523
552
 
524
553
  /** Tasks still owing a completion; a finished task keeps its record. */
@@ -584,10 +613,16 @@ export class OnlyneAgent {
584
613
  if (typeof args.generation === "number") this.generation = args.generation;
585
614
 
586
615
  const attachments = this.writeAttachments(taskId, envelope);
616
+ const attachmentPaths = attachments.map((item) => item.path);
587
617
  const prose = typeof args.prose === "string" ? args.prose.trim() : "";
588
618
  const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
589
619
  if (proseIsNew) this.deliveredProse.add(prose);
590
- const text = injectionText({ assign: { ...args, task_id: taskId }, proseIsNew, attachmentPaths: attachments.map((item) => item.path) });
620
+ // The assignment as the injection saw it, retained so the idle ladder can
621
+ // re-send it (`remind`): the task identity, its source, its kind, the task
622
+ // text with whatever handoff lines a relay put inside it, and the paths of
623
+ // the attachments this call already wrote.
624
+ const assignment = { ...args, task_id: taskId };
625
+ const text = injectionText({ assign: assignment, proseIsNew, attachmentPaths });
591
626
 
592
627
  const held = this.tasks.get(taskId);
593
628
  // A completed record is not a live one: a new envelope for a task that
@@ -598,9 +633,17 @@ export class OnlyneAgent {
598
633
  // is: its delivered set keeps the relay guard's count, and its completion
599
634
  // state still settles the task. Only the "since this instruction" counter
600
635
  // moves, so the settled-without-completing watchdog measures the newest one.
636
+ // The ladder restarts with it: the reminder count, the idle episode it was
637
+ // charged to (`remindedAt`), and the failure an errored turn proved all
638
+ // belong to the instruction being replaced.
601
639
  held.turnsSinceAssign = 0;
640
+ held.reminders = 0;
641
+ held.remindedAt = 0;
642
+ held.reminderWokeTurn = false;
643
+ held.errored = false;
602
644
  held.envelopeId = envelope.id ?? held.envelopeId;
603
- if (held.failed) held.failed = false;
645
+ held.assignment = assignment;
646
+ held.attachmentPaths = attachmentPaths;
604
647
  } else {
605
648
  this.tasks.set(taskId, {
606
649
  taskId,
@@ -612,7 +655,14 @@ export class OnlyneAgent {
612
655
  turns: 0,
613
656
  errored: false,
614
657
  head: "",
615
- failed: false,
658
+ assignment,
659
+ attachmentPaths,
660
+ /** Idle reminders sent for this task; the bound is `idleReminders`. */
661
+ reminders: 0,
662
+ /** The `turnsSinceAssign` the last reminder was charged to. */
663
+ remindedAt: 0,
664
+ /** Whether the next turn is the one this plugin's own reminder woke. */
665
+ reminderWokeTurn: false,
616
666
  });
617
667
  }
618
668
  this.agentState = "running";
@@ -675,62 +725,87 @@ export class OnlyneAgent {
675
725
 
676
726
  // ------------------------------------------------------------ pi → plugin
677
727
 
678
- /** A turn started: the plugin's own agent fact is `running`. */
728
+ /**
729
+ * A turn started: the plugin's own agent fact is `running`, and the idle
730
+ * ladder starts over. The count belongs to one idle episode, so a session
731
+ * that ran again owns a fresh bound; the one turn this plugin's own reminder
732
+ * woke belongs to the episode that reminder belongs to, and keeps it.
733
+ */
679
734
  onTurnStart() {
680
- const task = [...this.tasks.values()].find((item) => !item.completed);
681
- if (task) task.turns += 1;
735
+ for (const task of this.tasks.values()) {
736
+ if (task.completed) continue;
737
+ task.turns += 1;
738
+ if (task.reminderWokeTurn) {
739
+ task.reminderWokeTurn = false;
740
+ continue;
741
+ }
742
+ task.turnsSinceAssign = 0;
743
+ task.reminders = 0;
744
+ task.remindedAt = 0;
745
+ }
682
746
  void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
683
747
  }
684
748
 
685
- /** A turn ended: the agent is idle, and the settle window starts. */
749
+ /**
750
+ * A turn ended: one turn of a run that may still have more, and the settle
751
+ * window starts. The phase is re-derived, so the beat says `running` while pi
752
+ * keeps working and says `idle` only once the session waits for input.
753
+ */
686
754
  onTurnEnd() {
687
755
  for (const task of this.tasks.values()) {
688
756
  if (!task.completed) task.turnsSinceAssign += 1;
689
757
  }
690
- void this.heartbeat("idle").catch((error) => this.log(`heartbeat refused: ${error.message}`));
758
+ void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
691
759
  this.armSettleFallback();
692
760
  }
693
761
 
694
- /** pi will not continue on its own: run the completion exit. */
762
+ /**
763
+ * pi has settled: this is the moment the rule names as waiting for input, and
764
+ * the settle decision belongs to it.
765
+ */
695
766
  onSettled() {
696
767
  this.clearSettleFallback();
697
- this.trySettle();
768
+ void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
769
+ void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
698
770
  }
699
771
 
700
772
  /**
701
- * The one completion trigger. pi keeps `isIdle()` false while it is running,
702
- * 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.
773
+ * The one settle decision. pi keeps `isIdle()` false while it is running,
774
+ * retrying, compacting, or holding a queued continuation, and a background task
775
+ * keeps work running after pi itself has settled, so a settle signal that
776
+ * arrives during any of those waits re-arms instead of deciding on a session
777
+ * that is still busy. The fallback timer and `agent_settled` both land here,
778
+ * and `settleNow` makes the decision idempotent for one idle episode.
705
779
  */
706
- trySettle() {
780
+ async trySettle() {
707
781
  if (this.closed || this.activeTasks().length === 0) return;
708
- if (this.surface.isIdle?.() === false) {
782
+ if (await this.derivedPhase() !== "idle") {
709
783
  this.armSettleFallback();
710
784
  return;
711
785
  }
712
- void this.settleNow().catch((error) => this.log(`completion failed: ${error.message}`));
786
+ await this.settleNow().catch((error) => this.log(`settle decision failed: ${error.message}`));
713
787
  }
714
788
 
715
- /** A failed turn: the task's outcome is `failed` unless it already ended. */
789
+ /** A failed turn: the task's outcome is `failed`, with the error as its head. */
716
790
  onTurnError(text) {
717
791
  for (const task of this.tasks.values()) {
718
792
  if (task.completed) continue;
719
- task.failed = true;
720
793
  task.errored = true;
721
794
  if (text) task.head = headOf(text);
722
795
  }
723
- this.trySettle();
796
+ void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
724
797
  }
725
798
 
726
799
  /**
727
- * The last assistant text seen, kept as the completion summary for a task the
728
- * model never handed an explicit argument over for.
800
+ * The last assistant text seen, kept as the completion summary for a task
801
+ * whose `onlyne_complete` call hands no argument over.
729
802
  *
730
803
  * This is the fallback, never the deliverable: `onlyne_complete`'s `text` is
731
804
  * reported byte for byte by `completeFromTool` and is never written back
732
805
  * here, so the sentence a turn happened to end on cannot stand in for a
733
- * payload the tool call carried.
806
+ * payload the tool call carried. Nothing else reads it: an idle that runs out
807
+ * of reminders fails the task and says why, rather than reporting the text of
808
+ * a turn nobody completed.
734
809
  */
735
810
  noteAssistantText(text) {
736
811
  const flat = headOf(text);
@@ -745,7 +820,7 @@ export class OnlyneAgent {
745
820
  if (this.closed || this.activeTasks().length === 0) return;
746
821
  this.settleHandle = this.timer.set(() => {
747
822
  this.settleHandle = null;
748
- this.trySettle();
823
+ void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
749
824
  }, this.settleFallbackMs);
750
825
  }
751
826
 
@@ -756,26 +831,81 @@ export class OnlyneAgent {
756
831
  }
757
832
 
758
833
  /**
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.
834
+ * The idle ladder's decision, taken for every task this session still holds
835
+ * when a settle signal finds the agent idle.
836
+ *
837
+ * An errored turn is proof on its own — pi may skip the clean turn end — so
838
+ * its task is reported `failed` at once, with the error as its head. Every
839
+ * other open task gets one rung of the ladder: the plugin re-sends the
840
+ * assignment (`remind`) and counts it, and the idle that finds the bound
841
+ * already spent reports `failed` and leaves the session.
842
+ *
843
+ * `onlyne_complete` is the only path to `done`. A turn that ends without it
844
+ * leaves the task `idle_waiting` (the design's own word for it), and the
845
+ * answer is the reinforcing prompt the model gets instead of a completion it
846
+ * never claimed.
847
+ *
848
+ * A task whose injected message has not run a turn is left alone at every
849
+ * rung: the reminder would re-send an assignment the model may not have read
850
+ * yet, and failing now would claim work that never happened.
851
+ *
852
+ * A rung belongs to an idle episode, not to a settle signal: `remindedAt`
853
+ * records the `turnsSinceAssign` the last reminder was charged to, so the
854
+ * fallback timer and an `agent_settled` answering the same turn end spend one
855
+ * rung between them.
765
856
  */
766
857
  async settleNow() {
767
858
  for (const task of [...this.tasks.values()]) {
768
859
  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);
860
+ if (task.errored) {
861
+ await this.complete(task.taskId, "failed", task.head);
862
+ continue;
863
+ }
864
+ if (task.turnsSinceAssign === 0 || task.remindedAt === task.turnsSinceAssign) continue;
865
+ task.remindedAt = task.turnsSinceAssign;
866
+ if (task.reminders < this.idleReminders) {
867
+ this.remind(task);
868
+ continue;
869
+ }
870
+ const rungs = `${task.reminders} idle reminder${task.reminders === 1 ? "" : "s"}`;
871
+ await this.complete(task.taskId, "failed", `no completion after ${rungs}`);
773
872
  }
774
873
  }
775
874
 
776
875
  /**
777
- * `onlyne_complete`: an explicit outcome from the model, which wins over the
778
- * auto rule.
876
+ * One rung of the idle ladder: hand the assignment back to the model.
877
+ *
878
+ * The reminder is the injection's own text — `injectionText` with the prose
879
+ * flag off, so the role prose already in the session's context is not
880
+ * repeated — under one line saying why it is back. The task text is where a
881
+ * relay carries its handoff lines, so a handoff the model still owes comes
882
+ * back with it.
883
+ *
884
+ * The attachments travel as the paths the injection wrote, never as parts:
885
+ * the images were handed to pi once, and re-attaching them would put the same
886
+ * bytes into the context a second time.
887
+ *
888
+ * @param {any} task a record with `assignment` and `attachmentPaths` retained
889
+ * from `onAssign`
890
+ */
891
+ remind(task) {
892
+ task.reminders += 1;
893
+ // The turn this reminder is about to wake belongs to the same episode, so
894
+ // `onTurnStart` spends no reset on it and the bound stays reachable.
895
+ task.reminderWokeTurn = true;
896
+ const rung = `reminder ${task.reminders} of ${this.idleReminders}`;
897
+ const text = [
898
+ `[onlyne] your turn ended without a completion exit; this task is still open (${rung}). Call onlyne_complete when it is finished.`,
899
+ "",
900
+ injectionText({ assign: task.assignment, proseIsNew: false, attachmentPaths: task.attachmentPaths }),
901
+ ].join("\n");
902
+ this.surface.wakeUser?.(text, []);
903
+ this.notice("out", `${rung} for task ${task.taskId.slice(0, 8)}`);
904
+ }
905
+
906
+ /**
907
+ * `onlyne_complete`: the model's explicit outcome, and the only path to
908
+ * `done`.
779
909
  *
780
910
  * A non-empty `text` is the completion body: it is what the model handed
781
911
  * over, reported verbatim as the ledger's one-line `head`, so the last
@@ -854,11 +984,6 @@ export class OnlyneAgent {
854
984
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: normalized });
855
985
  this.notice("out", `complete ${taskId.slice(0, 8)} ${normalized}${summary ? `: ${summary}` : ""}`);
856
986
  if (this.activeTasks().length === 0) {
857
- if (exitProcess) {
858
- await this.reportSettled(taskId, normalized).catch((error) =>
859
- this.log(`settled observation refused: ${error.message}`),
860
- );
861
- }
862
987
  this.stopHeartbeat();
863
988
  if (exitProcess) this.exitSession(normalized);
864
989
  }
@@ -880,33 +1005,6 @@ export class OnlyneAgent {
880
1005
  this.surface.exit?.(reason);
881
1006
  }
882
1007
 
883
- /**
884
- * One last observation before the process leaves: the tuple the host settled
885
- * plus `agent: idle`.
886
- *
887
- * The completion settles the row from the tuple the client holds, which still
888
- * says `running` when the turn that finished was the last report sent, and
889
- * nothing observes the process afterwards. This report is what makes an
890
- * 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
892
- * the same reason the completion is: the answer is the handover, and a
893
- * failure here must not stop the exit that the durable completion earned.
894
- */
895
- async reportSettled(taskId, outcome) {
896
- if (!this.connected || this.lastPhase === "idle") return false;
897
- this.seq += 1;
898
- await this.request("report", settledReport({
899
- taskId,
900
- outcome,
901
- generation: this.generation,
902
- seq: this.seq,
903
- host: this.host,
904
- }));
905
- this.stats.reports += 1;
906
- this.lastPhase = "idle";
907
- return true;
908
- }
909
-
910
1008
  async flushPendingCompletion() {
911
1009
  const pending = this.pendingCompletion;
912
1010
  if (!pending || !this.connected) return;
@@ -917,9 +1015,6 @@ export class OnlyneAgent {
917
1015
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
918
1016
  this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
919
1017
  if (pending.exitProcess && this.activeTasks().length === 0) {
920
- await this.reportSettled(pending.taskId, pending.outcome).catch((error) =>
921
- this.log(`settled observation refused: ${error.message}`),
922
- );
923
1018
  this.exitSession(pending.outcome);
924
1019
  }
925
1020
  } catch (error) {
@@ -928,6 +1023,21 @@ export class OnlyneAgent {
928
1023
  }
929
1024
  }
930
1025
 
1026
+ /**
1027
+ * One inline image read off the path the model named, in the wire's
1028
+ * `ImagePart` shape. `mimeForPath` refuses an extension outside the core's
1029
+ * four mime types, and `imagePart` refuses a payload above the byte ceiling.
1030
+ * @param {string | null | undefined} path
1031
+ */
1032
+ imageFromPath(path) {
1033
+ if (!path) return null;
1034
+ return imagePart({
1035
+ data: readFileSync(path),
1036
+ mime: mimeForPath(path),
1037
+ name: path.split("/").pop() ?? null,
1038
+ });
1039
+ }
1040
+
931
1041
  /**
932
1042
  * `onlyne_send`: submit one envelope.
933
1043
  * @param {{ to: string, text?: string, kind?: string, imagePath?: string | null }} input
@@ -935,11 +1045,7 @@ export class OnlyneAgent {
935
1045
  async sendFromTool(input) {
936
1046
  if (!this.connected) throw new Error("onlyne: client socket is not connected");
937
1047
  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
- }
1048
+ const image = this.imageFromPath(input.imagePath);
943
1049
  const envelope = sendEnvelope({ from: this.role, to: input.to, kind, text: input.text ?? "", image });
944
1050
  const data = await this.request("send", envelope);
945
1051
  // Recorded only after the client answered the `send`: a refused envelope was
@@ -949,6 +1055,36 @@ export class OnlyneAgent {
949
1055
  return { queued: true, op_id: envelope.op_id ?? null, kind, to: input.to, data };
950
1056
  }
951
1057
 
1058
+ /**
1059
+ * `onlyne_handoff`: hand this session's task on to the next hop of its family.
1060
+ *
1061
+ * The task named in the request is the session's own current one, so the child
1062
+ * the host mints under it is a continuation of the family this session serves:
1063
+ * the host reads the parent's causality, derives the child through
1064
+ * `Causality::child_of`, and answers the child's task id and hop. The client's
1065
+ * own refusal is raised out of here as it arrived.
1066
+ * @param {{ to: string, text?: string, imagePath?: string | null }} input
1067
+ */
1068
+ async handoffFromTool(input) {
1069
+ if (!this.connected) throw new Error("onlyne: client socket is not connected");
1070
+ const taskId = this.activeTaskId();
1071
+ if (!taskId) throw new Error("onlyne: no task is assigned to this session");
1072
+ const data = await this.request("handoff", {
1073
+ task_id: taskId,
1074
+ to: input.to,
1075
+ text: input.text ?? "",
1076
+ image: this.imageFromPath(input.imagePath),
1077
+ });
1078
+ const answer = data ?? {};
1079
+ return {
1080
+ taskId: answer.task_id ?? null,
1081
+ hop: answer.hop ?? null,
1082
+ queued: answer.queued ?? false,
1083
+ opId: answer.op_id ?? null,
1084
+ to: input.to,
1085
+ };
1086
+ }
1087
+
952
1088
  // ------------------------------------------------------------ attachments
953
1089
 
954
1090
  /**