pi-onlyne 2.1.0 → 2.3.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
@@ -10,6 +10,16 @@
10
10
  // surface, which is why the whole state machine is testable against a plain
11
11
  // Node unix socket.
12
12
  //
13
+ // One completion path, and it is the model's: an explicit `onlyne_complete`
14
+ // (`completeFromTool`) or the host's `recycle{outcome}`. A turn that ends
15
+ // without one — cleanly or with a provider error — decides nothing here: the
16
+ // client's own turn-end rule owns the ending (`docs/v2-CONTRACT.md` §3c: one
17
+ // nudge, then the delivery settles `blocked`) and its own sweeps own a session
18
+ // whose agent died. A failed turn only supplies the head fallback, because pi
19
+ // retries a retryable provider error inside the same run and the client's nudge
20
+ // can restart a session a moment after it settles — a report sent on the error
21
+ // would race work that is still going.
22
+ //
13
23
  // What the human sees travels through one funnel, `notice` below: the pi widget
14
24
  // panel when the host has `ctx.ui.setWidget`, the footer status line plus a
15
25
  // `[pi-onlyne]` stderr line when it has not. `log` keeps the diagnostics — a
@@ -46,14 +56,24 @@ export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
46
56
  export const HELLO_TIMEOUT_MS = 5_000;
47
57
  /** Default bound on one request round trip. */
48
58
  export const REQUEST_TIMEOUT_MS = 30_000;
59
+
49
60
  /**
50
- * How long after an errored turn the plugin waits for `agent_settled` before it
51
- * reports the failure it witnessed itself.
61
+ * Capabilities this plugin implements on the wire.
62
+ *
63
+ * `resume` is the one that changes what a `task` scope does with this runtime.
64
+ * Pi keeps its conversation in its own store (`--session-dir`), so a session the
65
+ * client releases is one it can bring back by starting the role's `resume` argv
66
+ * — `pi --session <id>` — over the same store. Without the declaration the
67
+ * client keeps the process instead and the scope degrades to "process alive,
68
+ * session alive"; with it, an idle `task` session has its process released and
69
+ * the family's next delivery lands in the same conversation.
70
+ *
71
+ * The release is this plugin's own exit, not a kill: `onRecycle` stops and calls
72
+ * `exitSession`, and pi flushes its session file at `agent_settled`. The client
73
+ * asks first and closes the container only after a grace, which is what makes
74
+ * the ordering safe (`AGENTS.md` §10).
52
75
  */
53
- export const SETTLE_FALLBACK_MS = 2_000;
54
-
55
- /** Capabilities this plugin implements on the wire. */
56
- export const CAPABILITIES = ["register", "report", "inject", "recycle"];
76
+ export const CAPABILITIES = ["register", "report", "inject", "recycle", "resume"];
57
77
 
58
78
  export class OnlyneAgent {
59
79
  /**
@@ -71,17 +91,18 @@ export class OnlyneAgent {
71
91
  * ladder?: number[],
72
92
  * requestTimeoutMs?: number,
73
93
  * helloTimeoutMs?: number,
74
- * settleFallbackMs?: number,
75
94
  * createConnection?: (path: string) => any,
76
95
  * timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
77
96
  * }} options
78
97
  */
79
98
  constructor(options) {
80
99
  this.socketPath = options.socketPath;
100
+ this.resolveSocket = typeof options.resolveSocket === "function" ? options.resolveSocket : null;
81
101
  this.cwd = options.cwd;
82
102
  this.role = options.role;
83
103
  this.sessionId = options.sessionId;
84
104
  this.envTaskId = options.taskId;
105
+ this.resident = Boolean(options.resident);
85
106
  this.surface = options.surface;
86
107
  /** The widget panel's model (`activity.mjs`); seeded with the local role. */
87
108
  this.activity = options.activity ?? createActivity();
@@ -92,7 +113,6 @@ export class OnlyneAgent {
92
113
  this.ladder = options.ladder ?? RECONNECT_LADDER_MS;
93
114
  this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
94
115
  this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
95
- this.settleFallbackMs = options.settleFallbackMs ?? SETTLE_FALLBACK_MS;
96
116
  this.createConnection = options.createConnection ?? ((path) => createConnection(path));
97
117
  // The pane this process was spawned in, reported on every heartbeat so the
98
118
  // supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
@@ -122,7 +142,6 @@ export class OnlyneAgent {
122
142
  this.attempt = 0;
123
143
  this.reconnectHandle = null;
124
144
  this.heartbeatHandle = null;
125
- this.settleHandle = null;
126
145
  /**
127
146
  * The heartbeat round in flight, and what the next pass of it should say.
128
147
  * `heartbeat` never lets two rounds overlap, so `beatRound` doubles as the
@@ -226,6 +245,16 @@ export class OnlyneAgent {
226
245
 
227
246
  connect() {
228
247
  if (this.closed || this.socket) return;
248
+ if (this.resolveSocket) {
249
+ try {
250
+ const refreshed = this.resolveSocket();
251
+ if (typeof refreshed === "string" && refreshed.length > 0) {
252
+ this.socketPath = refreshed;
253
+ }
254
+ } catch {
255
+ /* keep previous socketPath */
256
+ }
257
+ }
229
258
  let socket;
230
259
  try {
231
260
  socket = this.createConnection(this.socketPath);
@@ -269,6 +298,7 @@ export class OnlyneAgent {
269
298
  /** Tear the current socket down and schedule the next attempt. */
270
299
  dropSocket() {
271
300
  const socket = this.socket;
301
+ const wasConnected = this.connected;
272
302
  this.socket = null;
273
303
  this.connected = false;
274
304
  this.handshaking = false;
@@ -276,7 +306,13 @@ export class OnlyneAgent {
276
306
  this.rejectAll("connection lost");
277
307
  this.stopHeartbeat();
278
308
  this.activity.set({ connection: this.closed ? "detached" : "reconnecting" });
279
- this.notice("state", this.closed ? "detached" : "reconnecting");
309
+ if (!this.closed && (!this.resident || wasConnected)) {
310
+ this.notice("state", "reconnecting");
311
+ } else if (this.closed) {
312
+ this.notice("state", "detached");
313
+ } else {
314
+ this.redraw();
315
+ }
280
316
  if (socket && !socket.destroyed) socket.destroy();
281
317
  this.scheduleReconnect();
282
318
  }
@@ -319,6 +355,11 @@ export class OnlyneAgent {
319
355
  }
320
356
  this.welcome = welcome;
321
357
  this.connected = true;
358
+ const hadConnectFailure = this.lastError !== null;
359
+ this.lastError = null;
360
+ if (this.resident && hadConnectFailure) {
361
+ this.log(`connected role=${welcome.role} session=${this.sessionId ?? "resident"} socket=${this.socketPath}`);
362
+ }
322
363
  // The client names every delivery it already handed to this session, so a
323
364
  // process that restarts under a live client does not re-inject work its
324
365
  // predecessor read: the delivery guard is process memory, and this is how it
@@ -465,7 +506,15 @@ export class OnlyneAgent {
465
506
  }
466
507
 
467
508
  noteFailure(error) {
509
+ const prevCode = this.lastError?.code ?? null;
468
510
  this.lastError = error;
511
+ if (
512
+ this.resident &&
513
+ (error?.code === "ENOENT" || error?.code === "ECONNREFUSED") &&
514
+ prevCode === error.code
515
+ ) {
516
+ return;
517
+ }
469
518
  this.log(`socket error: ${error?.message ?? error}`);
470
519
  }
471
520
 
@@ -477,14 +526,20 @@ export class OnlyneAgent {
477
526
  * @param {"in" | "dup" | "out" | "warn" | "state"} kind
478
527
  * @param {string} text
479
528
  */
529
+ redraw() {
530
+ this.surface.status?.(this.activity.lines()[0]);
531
+ if (this.surface.available?.widget) {
532
+ this.surface.widget?.(this.activity.lines(), this.activity);
533
+ }
534
+ }
535
+
480
536
  notice(kind, text) {
481
537
  this.activity.note(kind, text);
538
+ this.redraw();
482
539
  if (this.surface.available?.widget) {
483
- this.surface.widget?.(this.activity.lines());
484
540
  return;
485
541
  }
486
- // No panel: the footer carries the steady header line, stderr the event.
487
- this.surface.status?.(this.activity.lines()[0]);
542
+ // No panel: stderr carries the event.
488
543
  this.log(`${kind}: ${text}`);
489
544
  }
490
545
 
@@ -710,10 +765,6 @@ export class OnlyneAgent {
710
765
  this.timer.clear(this.reconnectHandle);
711
766
  this.reconnectHandle = null;
712
767
  }
713
- if (this.settleHandle) {
714
- this.timer.clear(this.settleHandle);
715
- this.settleHandle = null;
716
- }
717
768
  this.rejectAll("agent stopped");
718
769
  }
719
770
 
@@ -732,11 +783,14 @@ export class OnlyneAgent {
732
783
  * that is what every such frame meant.
733
784
  */
734
785
  keepsSession() {
735
- return this.scope === "task" || this.scope === "role";
786
+ return this.resident || this.scope === "task" || this.scope === "role";
736
787
  }
737
788
 
738
789
  async onAssign(args) {
739
790
  if (typeof args.scope === "string" && args.scope) this.scope = args.scope;
791
+ if (this.resident && typeof args.session_id === "string" && args.session_id) {
792
+ this.sessionId = args.session_id;
793
+ }
740
794
  const envelope = args.envelope ?? {};
741
795
  const taskId = args.task_id ?? envelope.causality?.task ?? null;
742
796
  if (!taskId) {
@@ -769,15 +823,42 @@ export class OnlyneAgent {
769
823
  this.surface.roleProse?.(prose);
770
824
  }
771
825
 
826
+ const kind = envelope.kind ?? "task";
772
827
  const held = this.tasks.get(taskId);
828
+ const fromLabel = describePrincipal(envelope.from);
829
+ const wakeMeta = {
830
+ role: this.role,
831
+ taskId,
832
+ kind,
833
+ from: fromLabel,
834
+ envelopeId: envelope.id ?? null,
835
+ bodyText: envelope.body?.text ?? text,
836
+ };
837
+ // A note is a message, not work: it is injected and acknowledged, and it
838
+ // opens no task record, so nothing beats for it, no turn end owes it an
839
+ // outcome and `onlyne_complete` is never required of it. A note naming a
840
+ // task this session is still working on lands mid-task, below.
841
+ if (kind === "note" && (!held || held.completed)) {
842
+ if (this.tasks.size > 0) this.agentState = "running";
843
+ this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
844
+ this.surface.customEntry?.("onlyne-assign", {
845
+ taskId,
846
+ envelopeId: envelope.id ?? null,
847
+ kind: "note",
848
+ proseInjected: proseIsNew,
849
+ prose,
850
+ attachments: attachmentPaths,
851
+ });
852
+ this.notice("in", `note ${taskId.slice(0, 8)} from ${fromLabel}: ${headOf(envelope.body?.text)}`);
853
+ await this.ack(taskId, true, null);
854
+ return;
855
+ }
773
856
  // A completed record is not a live one: a new envelope for a task that
774
857
  // already settled is fresh work under an old id, so it gets a fresh record.
775
858
  if (held && !held.completed) {
776
859
  // A new envelope for a task this session already holds — a follow-up, a
777
860
  // redirect, a bounce back through a handoff. The work record stays where
778
- // it is, and the failure an errored turn proved belonged to the
779
- // instruction being replaced, so it is cleared with that instruction.
780
- held.errored = false;
861
+ // it is; the newest instruction re-points it at the newest envelope.
781
862
  held.envelopeId = envelope.id ?? held.envelopeId;
782
863
  // `lastSeq` deliberately does not reset here. It is the floor the next
783
864
  // allocation for this task must clear, and the host's row for the task
@@ -800,22 +881,21 @@ export class OnlyneAgent {
800
881
  * share the plugin's counter. Null until the first report.
801
882
  */
802
883
  lastSeq: null,
803
- errored: false,
804
884
  head: "",
805
885
  });
806
886
  }
807
887
  this.agentState = "running";
808
- this.surface.wakeUser?.(text, this.imageParts(envelope));
888
+ this.surface.wakeUser?.(text, this.imageParts(envelope), wakeMeta);
809
889
  this.surface.customEntry?.("onlyne-assign", {
810
890
  taskId,
811
891
  envelopeId: envelope.id ?? null,
812
- kind: envelope.kind ?? "task",
892
+ kind,
813
893
  proseInjected: proseIsNew,
814
894
  prose,
815
895
  attachments: attachmentPaths,
816
896
  });
817
897
  this.activity.set({ taskId, phase: "running" });
818
- this.notice("in", `task ${taskId.slice(0, 8)} from ${describePrincipal(envelope.from)} (${envelope.kind ?? "task"}): ${headOf(envelope.body?.text)}`);
898
+ this.notice("in", `task ${taskId.slice(0, 8)} from ${fromLabel} (${kind}): ${headOf(envelope.body?.text)}`);
819
899
  this.startHeartbeat();
820
900
  await this.ack(taskId, true, null);
821
901
  }
@@ -848,7 +928,13 @@ export class OnlyneAgent {
848
928
  onNudge(frame, args) {
849
929
  const text = typeof args.text === "string" ? args.text : "";
850
930
  const taskId = typeof args.task_id === "string" && args.task_id ? args.task_id.slice(0, 8) : "?";
851
- const handedOver = text.length > 0 && this.surface.wakeUser?.(text, []) === true;
931
+ const handedOver = text.length > 0 && this.surface.wakeUser?.(text, [], {
932
+ role: this.role,
933
+ taskId: args.task_id ?? null,
934
+ kind: "nudge",
935
+ from: "host",
936
+ bodyText: text,
937
+ }) === true;
852
938
  if (handedOver) this.notice("in", `nudge for task ${taskId}`);
853
939
  else this.notice("warn", `nudge for task ${taskId} was not handed to the model`);
854
940
  if (frame?.id === undefined || frame?.id === null) return;
@@ -906,8 +992,10 @@ export class OnlyneAgent {
906
992
  *
907
993
  * How a turn end settles the work is the client's rule
908
994
  * (`docs/v2-CONTRACT.md` §3c): the plugin counts nothing, bounds nothing, and
909
- * reports only the facts it alone can see — its own phase, and an errored
910
- * turn.
995
+ * reports only its own phase and the completion the model asked for. A turn
996
+ * that ended without a completion is the client's case — its own one-nudge
997
+ * rule, and its own sweeps for a session whose agent died — so nothing here
998
+ * decides an outcome.
911
999
  */
912
1000
  onTurnStart() {
913
1001
  void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
@@ -925,40 +1013,29 @@ export class OnlyneAgent {
925
1013
  }
926
1014
 
927
1015
  /**
928
- * pi has settled: the session is waiting for input, which is the moment an
929
- * errored turn is reported from.
1016
+ * pi has settled: the session is waiting for input. The phase is the whole of
1017
+ * what this hook says — a settle is not a verdict, because the client's own
1018
+ * nudge can restart a session a moment later and pi can still be inside a
1019
+ * retry that settles it again.
930
1020
  */
931
1021
  onSettled() {
932
- this.clearSettleFallback();
933
1022
  void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
934
- void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
935
1023
  }
936
1024
 
937
1025
  /**
938
- * Report a turn that failed, once pi is quiet. pi keeps `isIdle()` false while
939
- * it is running, retrying, compacting, or holding a queued continuation, and
940
- * a background task keeps work running after pi itself has settled, so a
941
- * signal that arrives during any of those waits re-arms instead of reporting
942
- * from a session that is still busy. The fallback timer and `agent_settled`
943
- * both land here, and a task is reported once because the report completes it.
1026
+ * A turn ended with an error (`stopReason: "error"`). The plugin sends
1027
+ * nothing: an error is not proof of a terminal outcome, because pi retries a
1028
+ * retryable provider error and stays inside the same run, and the client's
1029
+ * nudge can restart a session a moment after it settles — so a report sent
1030
+ * here would race work that is still going. What the error does supply is the
1031
+ * head fallback for a later `onlyne_complete` whose `summary` carries nothing.
944
1032
  */
945
- async trySettle() {
946
- if (this.closed || this.activeTasks().length === 0) return;
947
- if (await this.derivedPhase() !== "idle") {
948
- this.armSettleFallback();
949
- return;
950
- }
951
- await this.reportFailedTurns().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
952
- }
953
-
954
- /** A failed turn: the task's outcome is `failed`, with the error as its head. */
955
1033
  onTurnError(text) {
956
1034
  for (const task of this.tasks.values()) {
957
1035
  if (task.completed) continue;
958
- task.errored = true;
959
1036
  if (text) task.head = headOf(text);
960
1037
  }
961
- void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
1038
+ this.notice("warn", `turn ended with an error: ${headOf(text) || "no reason given"}`);
962
1039
  }
963
1040
 
964
1041
  /**
@@ -979,38 +1056,6 @@ export class OnlyneAgent {
979
1056
  }
980
1057
  }
981
1058
 
982
- armSettleFallback() {
983
- this.clearSettleFallback();
984
- if (this.closed || this.activeTasks().length === 0) return;
985
- this.settleHandle = this.timer.set(() => {
986
- this.settleHandle = null;
987
- void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
988
- }, this.settleFallbackMs);
989
- }
990
-
991
- clearSettleFallback() {
992
- if (!this.settleHandle) return;
993
- this.timer.clear(this.settleHandle);
994
- this.settleHandle = null;
995
- }
996
-
997
- /**
998
- * Report the failures this session witnessed itself, for every task it still
999
- * holds.
1000
- *
1001
- * An errored turn is proof on its own — pi may skip the clean turn end — so
1002
- * its task is reported `failed` at once, with the error as its head. Nothing
1003
- * else is decided here: a turn that ends without a completion is the client's
1004
- * case, and it reaches the session as the host's nudge (`onNudge`), so a
1005
- * plugin that also decided it would be the second owner of the rule.
1006
- */
1007
- async reportFailedTurns() {
1008
- for (const task of [...this.tasks.values()]) {
1009
- if (task.completed || !task.errored) continue;
1010
- await this.complete(task.taskId, "failed", task.head);
1011
- }
1012
- }
1013
-
1014
1059
  /**
1015
1060
  * `onlyne_complete`: the model's explicit outcome, and the only path to
1016
1061
  * `done`.
@@ -1100,8 +1145,9 @@ export class OnlyneAgent {
1100
1145
  // flight. A refused or broken request handed nothing over, so the task
1101
1146
  // goes back to open and the caller sees why: a retry reports again, and
1102
1147
  // `completeFromTool` still finds the task.
1148
+ let answer = null;
1103
1149
  try {
1104
- await this.request("report", report);
1150
+ answer = await this.request("report", report);
1105
1151
  } catch (error) {
1106
1152
  if (task) task.completed = false;
1107
1153
  throw error;
@@ -1114,6 +1160,14 @@ export class OnlyneAgent {
1114
1160
  this.stopHeartbeat();
1115
1161
  if (exitProcess) this.exitSession(normalized);
1116
1162
  }
1163
+ // A client that had already settled this task says so with its own record
1164
+ // of the terminal fact, and the caller hands that on rather than claiming
1165
+ // the completion it just sent. The task is still left settled here: the
1166
+ // client's row is the authority, and re-opening it would only ask the
1167
+ // model to report an outcome nobody is waiting for.
1168
+ if (isAlreadySettled(answer)) {
1169
+ return { taskId, outcome: normalized, head: summary, alreadySettled: answer.already_settled };
1170
+ }
1117
1171
  return { taskId, outcome: normalized, head: summary };
1118
1172
  }
1119
1173
 
@@ -1143,7 +1197,15 @@ export class OnlyneAgent {
1143
1197
  if (!this.connected) return;
1144
1198
  const pending = this.pendingCompletions.shift();
1145
1199
  try {
1146
- await this.request("report", pending.report);
1200
+ const answer = await this.request("report", pending.report);
1201
+ // A queued completion has no tool result to carry the client's record
1202
+ // back through, so the one thing worth saying is that the client had
1203
+ // already settled the task and this report changed nothing.
1204
+ if (isAlreadySettled(answer)) {
1205
+ this.log(
1206
+ `queued completion for ${pending.taskId.slice(0, 8)} was already settled by the client as ${answer.already_settled.state}`
1207
+ );
1208
+ }
1147
1209
  this.stats.completions += 1;
1148
1210
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
1149
1211
  this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
@@ -1233,6 +1295,17 @@ export class OnlyneAgent {
1233
1295
  }
1234
1296
  }
1235
1297
 
1298
+ /**
1299
+ * Whether a client's answer to a `report.complete` says the task had already
1300
+ * settled. The shape is the client's (`{"already_settled": {state, settled_at,
1301
+ * head}}`); a normal accepted completion answers `data = null`, which is not an
1302
+ * object and reads as no.
1303
+ */
1304
+ function isAlreadySettled(answer) {
1305
+ return Boolean(answer) && typeof answer === "object" && answer.already_settled !== null
1306
+ && typeof answer.already_settled === "object";
1307
+ }
1308
+
1236
1309
  /** Mime type for one attachment path the model asked to send. */
1237
1310
  export function mimeForPath(path) {
1238
1311
  const lower = String(path).toLowerCase();