pi-onlyne 1.2.1 → 2.0.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,8 +1,9 @@
1
- // The agent side of the onlyne adapter protocol: one connection to
2
- // `<role workspace>/.onlyne/run/s`, the hello/welcome handshake, assign
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.
1
+ // The agent side of the onlyne adapter protocol: one connection to the role
2
+ // workspace's client socket — v2 binds it in the machine-level runtime
3
+ // directory as `<digest>.sock`, not inside the tree (`socket.mjs`) — the
4
+ // hello/welcome handshake, assign delivery, turn-state reports, the host's
5
+ // nudge, the completion exit, probe, recycle and detach — plus reconnect when
6
+ // the client restarts under it.
6
7
  //
7
8
  // Everything pi-specific lives behind `surface` (see pi-surface.mjs): this
8
9
  // module decides *what* the protocol says and hands the *effects* to the
@@ -15,12 +16,10 @@
15
16
  // refused report, a socket error, a timeout, a framing fault — because those
16
17
  // matter to a host with no panel at all.
17
18
 
18
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { readFileSync } from "node:fs";
19
20
  import { createConnection } from "node:net";
20
- import { basename, join } from "node:path";
21
21
  import { createFrameDecoder, encodeFrame } from "./frame.mjs";
22
22
  import { createActivity } from "./activity.mjs";
23
- import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
24
23
  import {
25
24
  DEFAULT_HEARTBEAT_MS,
26
25
  assignAckArgs,
@@ -32,7 +31,6 @@ import {
32
31
  headOf,
33
32
  hostBinding,
34
33
  imagePart,
35
- injectionText,
36
34
  normalizeOutcome,
37
35
  readyReport,
38
36
  sendEnvelope,
@@ -41,7 +39,6 @@ import {
41
39
  stdinTaskText,
42
40
  welcomeFrom,
43
41
  } from "./protocol.mjs";
44
- import { DEFAULT_RELAY, FORCED_PREFIX, relayEnabled, relayRefusal } from "./relay.mjs";
45
42
 
46
43
  /** Reconnect ladder in milliseconds, capped like the client's own. */
47
44
  export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
@@ -49,33 +46,15 @@ export const RECONNECT_LADDER_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
49
46
  export const HELLO_TIMEOUT_MS = 5_000;
50
47
  /** Default bound on one request round trip. */
51
48
  export const REQUEST_TIMEOUT_MS = 30_000;
52
- /** How long after a turn end the plugin waits for `agent_settled` before it acts. */
49
+ /**
50
+ * How long after an errored turn the plugin waits for `agent_settled` before it
51
+ * reports the failure it witnessed itself.
52
+ */
53
53
  export const SETTLE_FALLBACK_MS = 2_000;
54
54
 
55
55
  /** Capabilities this plugin implements on the wire. */
56
56
  export const CAPABILITIES = ["register", "report", "inject", "recycle"];
57
57
 
58
- /** Mime guess for an attachment the host did not name. */
59
- function extensionForMime(mime) {
60
- switch (mime) {
61
- case "image/png":
62
- return "png";
63
- case "image/jpeg":
64
- return "jpg";
65
- case "image/gif":
66
- return "gif";
67
- case "image/webp":
68
- return "webp";
69
- default:
70
- return "bin";
71
- }
72
- }
73
-
74
- /** Ids the host mints are uuids; anything else is flattened before it names a file. */
75
- function safeSegment(value) {
76
- return String(value ?? "unknown").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
77
- }
78
-
79
58
  export class OnlyneAgent {
80
59
  /**
81
60
  * @param {{
@@ -87,14 +66,12 @@ export class OnlyneAgent {
87
66
  * surface: any,
88
67
  * log?: (line: string, data?: unknown) => void,
89
68
  * activity?: { note: (kind: string, text: string) => any, set: (patch: any) => any, lines: () => string[], events: any[] },
90
- * relay?: { required?: string[], count?: number | null },
91
69
  * capabilities?: string[],
92
70
  * heartbeatMs?: number,
93
71
  * ladder?: number[],
94
72
  * requestTimeoutMs?: number,
95
73
  * helloTimeoutMs?: number,
96
74
  * settleFallbackMs?: number,
97
- * idleReminders?: number,
98
75
  * createConnection?: (path: string) => any,
99
76
  * timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
100
77
  * }} options
@@ -110,20 +87,12 @@ export class OnlyneAgent {
110
87
  this.activity = options.activity ?? createActivity();
111
88
  this.activity.set({ role: options.role });
112
89
  this.log = options.log ?? (() => {});
113
- /** The relay guard's policy; the default guards nothing (`relay.mjs`). */
114
- this.relay = options.relay ?? DEFAULT_RELAY;
115
90
  this.capabilities = options.capabilities ?? CAPABILITIES;
116
91
  this.heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
117
92
  this.ladder = options.ladder ?? RECONNECT_LADDER_MS;
118
93
  this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
119
94
  this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
120
95
  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;
127
96
  this.createConnection = options.createConnection ?? ((path) => createConnection(path));
128
97
  // The pane this process was spawned in, reported on every heartbeat so the
129
98
  // supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
@@ -139,6 +108,12 @@ export class OnlyneAgent {
139
108
  this.connected = false;
140
109
  this.socket = null;
141
110
  this.welcome = null;
111
+ /**
112
+ * The generation for a beat whose task carries no record of its own (the
113
+ * session's spawn task, before any assign wrote one). The generation the host
114
+ * names in an `assign` lives on that task's record instead, where a second
115
+ * concurrent assign cannot rewrite the first one's.
116
+ */
142
117
  this.generation = 1;
143
118
  this.seq = SEQ_BASE;
144
119
  this.nextId = 1;
@@ -148,18 +123,27 @@ export class OnlyneAgent {
148
123
  this.reconnectHandle = null;
149
124
  this.heartbeatHandle = null;
150
125
  this.settleHandle = null;
126
+ /**
127
+ * The heartbeat round in flight, and what the next pass of it should say.
128
+ * `heartbeat` never lets two rounds overlap, so `beatRound` doubles as the
129
+ * lock; `beatAgain` records that a beat was asked for while the round was
130
+ * writing, and `beatPhase` what the newest of those asks wanted said — an
131
+ * explicit phase, or null for "ask pi again". See `heartbeat`.
132
+ */
133
+ this.beatRound = null;
134
+ this.beatAgain = false;
135
+ this.beatPhase = null;
151
136
  /** @type {Map<string, any>} */
152
137
  this.tasks = new Map();
153
138
  /**
154
- * Every role this session handed something to through a `send` the client
155
- * accepted: the relay guard's only evidence (`relay.mjs`).
156
- *
157
- * Process memory, scoped to this session because the agent is: a reconnect
158
- * keeps it (the same process re-dials the same client), and a session that
159
- * starts fresh starts empty rather than guessing at what an earlier process
160
- * sent — the guard judges this session's own deliveries, not history.
139
+ * Completions the socket could not carry: one entry per `complete` call
140
+ * taken while the connection was down, kept in the order they were called
141
+ * and flushed on the next hello. A single slot would let a second
142
+ * completion overwrite the first, so an outcome nobody had received would
143
+ * vanish while a reconnect was already in flight.
144
+ * @type {{ taskId: string, report: any, outcome: string, exitProcess: boolean }[]}
161
145
  */
162
- this.deliveredTo = new Set();
146
+ this.pendingCompletions = [];
163
147
  /**
164
148
  * The deliveries this process has already handed to the model, keyed by
165
149
  * envelope id (a delivery's own identity). Keying it by task id would swallow
@@ -175,6 +159,11 @@ export class OnlyneAgent {
175
159
  this.lastError = null;
176
160
  /** Whether pi has already been asked to end this process (`exitSession`). */
177
161
  this.exitRequested = false;
162
+ /**
163
+ * The role's `[client.session] scope` as the last assignment carried it.
164
+ * @type {string | null}
165
+ */
166
+ this.scope = null;
178
167
  this.stats = { assigns: 0, duplicates: 0, injections: 0, completions: 0, reports: 0, reconnects: 0, recycles: 0 };
179
168
  }
180
169
 
@@ -218,8 +207,15 @@ export class OnlyneAgent {
218
207
  sessionId: this.sessionId,
219
208
  generation: this.generation,
220
209
  agentState: this.agentState,
210
+ // The plugin's own report counter, and the seq each open task last
211
+ // carried. A supervisor chasing a beat the host dropped as stale reads
212
+ // these against the row's watermark instead of guessing from the log.
213
+ seq: this.seq,
214
+ taskSeqs: Object.fromEntries(
215
+ [...this.tasks.values()].map((task) => [task.taskId, task.lastSeq ?? null]),
216
+ ),
221
217
  tasks: this.activeTasks().map((task) => task.taskId),
222
- pendingCompletion: this.pendingCompletion?.taskId ?? null,
218
+ pendingCompletions: this.pendingCompletions.map((pending) => pending.taskId),
223
219
  lastError: this.lastError ? String(this.lastError.message ?? this.lastError) : null,
224
220
  activity: this.activity.events.slice(0, 5),
225
221
  stats: { ...this.stats },
@@ -323,6 +319,18 @@ export class OnlyneAgent {
323
319
  }
324
320
  this.welcome = welcome;
325
321
  this.connected = true;
322
+ // The client names every delivery it already handed to this session, so a
323
+ // process that restarts under a live client does not re-inject work its
324
+ // predecessor read: the delivery guard is process memory, and this is how it
325
+ // survives one. Seeded before any push drains, so the first `assign` the new
326
+ // connection sees is already deduped against it. Both spellings go in,
327
+ // because the guard reads `envelope.id ?? task:<task_id>` (`onAssign`) and a
328
+ // host may name either the delivery's own id or the task it belongs to here.
329
+ const alreadyDelivered = Array.isArray(welcome.deliveredTasks) ? welcome.deliveredTasks : [];
330
+ for (const taskId of alreadyDelivered) {
331
+ this.injectedDeliveries.add(taskId);
332
+ this.injectedDeliveries.add(`task:${taskId}`);
333
+ }
326
334
  // A fresh connection has reported nothing, so the phase is a question, not
327
335
  // an answer: the first beat below re-derives it from pi. A taskless session
328
336
  // is the ready pool, and ready is its own state.
@@ -340,7 +348,7 @@ export class OnlyneAgent {
340
348
  const prose = welcome.prose.trim();
341
349
  if (prose && !this.deliveredProse.has(prose)) {
342
350
  this.deliveredProse.add(prose);
343
- this.surface.proseContext?.(prose, welcome);
351
+ this.surface.roleProse?.(prose);
344
352
  }
345
353
 
346
354
  if (this.envTaskId) {
@@ -353,9 +361,10 @@ export class OnlyneAgent {
353
361
  })).catch((error) => this.log(`session_register refused: ${error.message}`));
354
362
  }
355
363
  await this.reportReady();
356
- if (this.pendingCompletion) await this.flushPendingCompletion();
364
+ await this.flushPendingCompletions();
357
365
  // The frames that waited for the handshake: the welcome is adopted by now,
358
- // so the role prose is context before any assignment opens a turn.
366
+ // so the role prose is in the instruction layer before any assignment opens
367
+ // a turn.
359
368
  this.handshaking = false;
360
369
  this.drainDeferred();
361
370
  if (this.tasks.size > 0) await this.heartbeat().catch(() => {});
@@ -437,6 +446,7 @@ export class OnlyneAgent {
437
446
  const op = frame.op;
438
447
  const args = frame.args ?? {};
439
448
  if (op === "assign") void this.onAssign(args);
449
+ else if (op === "nudge") this.onNudge(frame, args);
440
450
  else if (op === "probe") void this.probe();
441
451
  else if (op === "recycle") void this.onRecycle(args);
442
452
  else if (op === "config_get") void this.onConfigGet(args);
@@ -483,13 +493,17 @@ export class OnlyneAgent {
483
493
  async reportReady() {
484
494
  const taskId = this.envTaskId;
485
495
  if (!taskId || !this.connected) return;
486
- this.seq += 1;
496
+ // The ready travels the one counter and moves the same row's watermark a
497
+ // beat reads, so the task's record takes the clamp where one exists: a
498
+ // ready landing between two of that task's beats cannot hand the row a seq
499
+ // it has already seen.
500
+ const seq = this.nextSeq(this.tasks.get(taskId) ?? null);
487
501
  try {
488
502
  await this.request("report", readyReport({
489
503
  taskId,
490
504
  sessionId: this.sessionId,
491
505
  generation: this.generation,
492
- seq: this.seq,
506
+ seq,
493
507
  }));
494
508
  this.stats.reports += 1;
495
509
  this.notice("state", `ready ${taskId.slice(0, 8)}`);
@@ -499,10 +513,114 @@ export class OnlyneAgent {
499
513
  }
500
514
 
501
515
  /**
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.
516
+ * Allocate the next report sequence, for one task when the report names one.
517
+ *
518
+ * One writer gets one counter: the protocol asks for a sequence strictly
519
+ * increasing for the life of a generation (`crates/onlyne-adapter/PROTOCOL.md`,
520
+ * "Report sequencing and the ready barrier"), and the host's own events for a
521
+ * row take `row.seq + 1` beside it (`onlyne-session`'s `reconcile/feed.rs`,
522
+ * `reconcile/bridge.rs`). A per-task counter was the other option, and it is
523
+ * the worse one: a task's row would then move exactly one per round, tying
524
+ * with the host's interleaved writes to that row, and the reducer reads a tie
525
+ * as `StaleOrDuplicateSeq` and throws the liveness fact away. Beating every
526
+ * task off the one counter is what keeps each row ahead of the host.
527
+ *
528
+ * The gate itself is per task row, so the invariant a *task* owes is "my next
529
+ * beat carries a higher seq than my last one". `task.lastSeq` makes that a
530
+ * checked property rather than a side effect of write order: an allocation for
531
+ * a task that has reported before is clamped above its own last report, so no
532
+ * path — a ready between two beats, a second round folded into the first, a
533
+ * follow-up envelope arriving mid-round — can hand a task back a seq its row
534
+ * has already accepted.
535
+ *
536
+ * @param {any} [task] the task record the report speaks for. A report naming
537
+ * no record (the task this process was spawned with, before any `assign` wrote
538
+ * one) takes the bare counter, which is above everything it has ever sent.
539
+ */
540
+ nextSeq(task = null) {
541
+ this.seq += 1;
542
+ if (!task) return this.seq;
543
+ if (typeof task.lastSeq === "number" && task.lastSeq >= this.seq) {
544
+ this.seq = task.lastSeq + 1;
545
+ }
546
+ task.lastSeq = this.seq;
547
+ return this.seq;
548
+ }
549
+
550
+ /**
551
+ * One heartbeat round, or the round already running if one is in flight.
552
+ *
553
+ * Rounds never overlap. A beat is a snapshot of the session, so a second round
554
+ * begun while the first is still writing holds no newer evidence than the one
555
+ * in flight — it only splits one tick's facts across two sequences and doubles
556
+ * the frames every task's row has to clear. The timer, a turn hook and a
557
+ * `probe` can each ask for a beat in the same instant: the first starts the
558
+ * round, the rest fold into it, and the round takes one more pass so the
559
+ * newest ask is answered. A folded caller shares the running round's outcome,
560
+ * rejection included, because a plugin reads a failed report as a link that
561
+ * died, and the reconnect ladder — not a queue of beats behind a socket that
562
+ * cannot carry them — is the answer to that.
563
+ *
564
+ * The phase is the one the phase rule names: `idle` only while the session
565
+ * waits for user input, `running` for everything else, re-derived from pi once
566
+ * per round so a phase that went stale when a run started again cannot survive
567
+ * a tick — the answer is about the session, so a task does not get its own
568
+ * view of it.
569
+ *
570
+ * @param {"idle" | "running" | null} [agent] the phase to state; absent means
571
+ * ask pi.
572
+ */
573
+ heartbeat(agent = null) {
574
+ if (this.beatRound) {
575
+ // The newest ask wins the next pass, whatever it is. A bare `heartbeat()`
576
+ // means "ask pi again" and so clears an earlier explicit phase: a
577
+ // turn-start beat that says `running` must not pin the turn-end beat that
578
+ // folded into it, or the idle a waiting session reached never gets
579
+ // reported, and the client's own rule reads a working agent forever.
580
+ this.beatAgain = true;
581
+ this.beatPhase = agent;
582
+ return this.beatRound;
583
+ }
584
+ this.beatPhase = agent ?? null;
585
+ this.beatAgain = false;
586
+ this.beatRound = this.runBeatRound();
587
+ return this.beatRound;
588
+ }
589
+
590
+ /** The serialized driver behind `heartbeat`; see there for the why. */
591
+ async runBeatRound() {
592
+ let failure = null;
593
+ try {
594
+ for (;;) {
595
+ this.beatAgain = false;
596
+ try {
597
+ await this.beatOnce(this.beatPhase);
598
+ } catch (error) {
599
+ // The link stopped answering mid-round. Later beats would only pile
600
+ // behind frames this socket cannot carry; `dropSocket` rejects them
601
+ // all and the reconnect path re-derives the phase anyway.
602
+ failure = error;
603
+ break;
604
+ }
605
+ if (!this.beatAgain) break;
606
+ }
607
+ } finally {
608
+ this.beatRound = null;
609
+ this.beatAgain = false;
610
+ }
611
+ if (failure) throw failure;
612
+ }
613
+
614
+ /**
615
+ * One pass of the round: one beat for every task this session still holds.
616
+ *
617
+ * A session can hold two open tasks at once (the one it is working and the
618
+ * next one already handed over), and a beat is the only liveness evidence
619
+ * either row has: beating the head of the list alone let a second task sit
620
+ * until the client's heartbeat timeout took it. Each beat carries its own
621
+ * task's generation and a sequence strictly above the last one that task
622
+ * sent, so an alternating pair of tasks can never be told stale by its own
623
+ * earlier frame.
506
624
  *
507
625
  * A task this plugin already completed gets none: the beat is a full
508
626
  * snapshot of what the plugin can see, and after the completion the agent's
@@ -514,22 +632,31 @@ export class OnlyneAgent {
514
632
  * heartbeat left over from the finishing turn landed three milliseconds
515
633
  * behind the completion.
516
634
  */
517
- async heartbeat(agent = null) {
635
+ async beatOnce(agent) {
518
636
  if (!this.connected) return;
519
- const taskId = this.activeTaskId();
520
- if (!taskId) return;
521
- if (this.tasks.get(taskId)?.completed) return;
637
+ let tasks = this.activeTasks();
638
+ if (tasks.length === 0) {
639
+ // Nothing is open: the beat still belongs to the task this process was
640
+ // spawned with, unless that task is one this plugin already completed.
641
+ const taskId = this.envTaskId;
642
+ if (!taskId || this.tasks.get(taskId)?.completed) return;
643
+ tasks = [{ taskId }];
644
+ }
522
645
  const phase = agent ?? (await this.derivedPhase());
523
646
  this.agentState = phase;
524
- this.seq += 1;
525
- await this.request("report", heartbeatReport({
526
- taskId,
527
- generation: this.generation,
528
- seq: this.seq,
529
- agent: phase,
530
- host: this.host,
531
- }));
532
- this.stats.reports += 1;
647
+ for (const task of tasks) {
648
+ // The clamp is stamped on the record, where the next round can read it;
649
+ // the synthetic entry above has none, and the bare counter covers it.
650
+ const seq = this.nextSeq(this.tasks.get(task.taskId) ?? null);
651
+ await this.request("report", heartbeatReport({
652
+ taskId: task.taskId,
653
+ generation: task.generation ?? this.generation,
654
+ seq,
655
+ agent: phase,
656
+ host: this.host,
657
+ }));
658
+ this.stats.reports += 1;
659
+ }
533
660
  }
534
661
 
535
662
  /**
@@ -592,7 +719,24 @@ export class OnlyneAgent {
592
719
 
593
720
  // ------------------------------------------------------------ host → plugin
594
721
 
722
+ /**
723
+ * Whether this role's scope keeps a session past the delivery it served.
724
+ *
725
+ * `oneshot` is finished and this process should end — which is also the only
726
+ * way this runtime's own store gets flushed, because the teardown that writes
727
+ * it runs at `agent_settled`. `task` and `role` want the opposite: the session
728
+ * outlives the delivery, the process stays, and the conversation is what the
729
+ * next delivery lands in.
730
+ *
731
+ * A frame from before the field carries no scope, and is read as `oneshot`:
732
+ * that is what every such frame meant.
733
+ */
734
+ keepsSession() {
735
+ return this.scope === "task" || this.scope === "role";
736
+ }
737
+
595
738
  async onAssign(args) {
739
+ if (typeof args.scope === "string" && args.scope) this.scope = args.scope;
596
740
  const envelope = args.envelope ?? {};
597
741
  const taskId = args.task_id ?? envelope.causality?.task ?? null;
598
742
  if (!taskId) {
@@ -610,70 +754,65 @@ export class OnlyneAgent {
610
754
  }
611
755
  this.injectedDeliveries.add(deliveryId);
612
756
  this.stats.assigns += 1;
613
- if (typeof args.generation === "number") this.generation = args.generation;
614
757
 
615
- const attachments = this.writeAttachments(taskId, envelope);
616
- const attachmentPaths = attachments.map((item) => item.path);
758
+ // The delivery text arrives already rendered: the client's one template
759
+ // built it from the sender, the body and the files it wrote itself. This
760
+ // plugin injects those bytes and nothing else — it composes no wording of
761
+ // its own around a delivery, and its other addition to what the model reads
762
+ // is the role prose, which is a system-prompt section rather than a message.
763
+ const text = typeof args.text === "string" ? args.text : "";
764
+ const attachmentPaths = Array.isArray(args.attachments) ? args.attachments : [];
617
765
  const prose = typeof args.prose === "string" ? args.prose.trim() : "";
618
766
  const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
619
- if (proseIsNew) this.deliveredProse.add(prose);
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 });
767
+ if (proseIsNew) {
768
+ this.deliveredProse.add(prose);
769
+ this.surface.roleProse?.(prose);
770
+ }
626
771
 
627
772
  const held = this.tasks.get(taskId);
628
773
  // A completed record is not a live one: a new envelope for a task that
629
774
  // already settled is fresh work under an old id, so it gets a fresh record.
630
775
  if (held && !held.completed) {
631
776
  // A new envelope for a task this session already holds — a follow-up, a
632
- // redirect, a bounce back through a relay. The work record stays where it
633
- // is: its delivered set keeps the relay guard's count, and its completion
634
- // state still settles the task. Only the "since this instruction" counter
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.
639
- held.turnsSinceAssign = 0;
640
- held.reminders = 0;
641
- held.remindedAt = 0;
642
- held.reminderWokeTurn = false;
777
+ // 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.
643
780
  held.errored = false;
644
781
  held.envelopeId = envelope.id ?? held.envelopeId;
645
- held.assignment = assignment;
646
- held.attachmentPaths = attachmentPaths;
782
+ // `lastSeq` deliberately does not reset here. It is the floor the next
783
+ // allocation for this task must clear, and the host's row for the task
784
+ // still holds the seq of the last beat it accepted; a follow-up envelope
785
+ // opens no new row, so forgetting the floor would let a later round
786
+ // re-issue a seq the reducer has already seen.
787
+ // The host's newest word on this task's generation, which its next beat
788
+ // must carry; a follow-up can move it.
789
+ if (typeof args.generation === "number") held.generation = args.generation;
647
790
  } else {
648
791
  this.tasks.set(taskId, {
649
792
  taskId,
650
793
  envelopeId: envelope.id ?? null,
651
- // Who handed this task over: the relay guard's count mode does not count
652
- // a send straight back to it (`relay.mjs`).
653
- upstream: envelope.from?.role?.role ?? null,
654
- turnsSinceAssign: 0,
655
- turns: 0,
794
+ /** The generation the host names for this task; the beat reports it. */
795
+ generation: typeof args.generation === "number" ? args.generation : 1,
796
+ /**
797
+ * The seq of the last report this plugin sent for this task, stamped by
798
+ * `nextSeq`. The host gates one task's row on its own watermark, so this
799
+ * is what keeps a beat ahead of the frame before it however many tasks
800
+ * share the plugin's counter. Null until the first report.
801
+ */
802
+ lastSeq: null,
656
803
  errored: false,
657
804
  head: "",
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,
666
805
  });
667
806
  }
668
807
  this.agentState = "running";
669
- this.surface.wakeUser?.(text, attachments.map((item) => item.part));
808
+ this.surface.wakeUser?.(text, this.imageParts(envelope));
670
809
  this.surface.customEntry?.("onlyne-assign", {
671
810
  taskId,
672
811
  envelopeId: envelope.id ?? null,
673
812
  kind: envelope.kind ?? "task",
674
813
  proseInjected: proseIsNew,
675
814
  prose,
676
- attachments: attachments.map((item) => item.path),
815
+ attachments: attachmentPaths,
677
816
  });
678
817
  this.activity.set({ taskId, phase: "running" });
679
818
  this.notice("in", `task ${taskId.slice(0, 8)} from ${describePrincipal(envelope.from)} (${envelope.kind ?? "task"}): ${headOf(envelope.body?.text)}`);
@@ -689,6 +828,41 @@ export class OnlyneAgent {
689
828
  }
690
829
  }
691
830
 
831
+ /**
832
+ * The host's turn-end nudge (`HostOp::Nudge`): the client owns the rule
833
+ * (`docs/v2-CONTRACT.md` §3c) and the sentence it sends is the whole of what
834
+ * the model gets.
835
+ *
836
+ * Handed over exactly as it arrived — no prefix, no count, no task id, no role
837
+ * — which is what makes this a nudge rather than v1's ladder, whose reminder
838
+ * re-injected the assignment and counted its rungs. Nothing in the session's
839
+ * turn state is reset by it, and the plugin keeps no copy of the text.
840
+ *
841
+ * The answer claims only that the sentence was handed over, because the client
842
+ * settles a delivery on it: `ok` when pi took the text, and a refusal when pi
843
+ * would not. A frame carrying no `id` is a notification and gets no answer.
844
+ *
845
+ * @param {{ id?: number | null }} frame
846
+ * @param {{ task_id?: string, text?: string }} args
847
+ */
848
+ onNudge(frame, args) {
849
+ const text = typeof args.text === "string" ? args.text : "";
850
+ 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;
852
+ if (handedOver) this.notice("in", `nudge for task ${taskId}`);
853
+ else this.notice("warn", `nudge for task ${taskId} was not handed to the model`);
854
+ if (frame?.id === undefined || frame?.id === null) return;
855
+ if (handedOver) {
856
+ this.write({ reply_to: frame.id, ok: true });
857
+ return;
858
+ }
859
+ this.write({
860
+ reply_to: frame.id,
861
+ ok: false,
862
+ error: { code: "internal", message: "pi did not take the nudge text" },
863
+ });
864
+ }
865
+
692
866
  /** The host asked for a fresh observation: one heartbeat is the answer. */
693
867
  async probe() {
694
868
  try {
@@ -703,8 +877,10 @@ export class OnlyneAgent {
703
877
  const task = stdinTaskText(args);
704
878
  if (task) {
705
879
  // The no-`inject` route: the host hands the payload over as a config key.
880
+ // The key carries the same rendered delivery text an `assign` would, so
881
+ // it is injected as it stands — this route composes no wording either.
706
882
  this.notice("in", `stdin task: ${headOf(task.text)}`);
707
- this.surface.wakeUser?.(`[onlyne] task body (delivered as stdin):\n\n${task.text}`, []);
883
+ this.surface.wakeUser?.(task.text, []);
708
884
  return;
709
885
  }
710
886
  this.log(`config_get ${args?.key ?? "?"} is not implemented by this plugin`);
@@ -726,56 +902,45 @@ export class OnlyneAgent {
726
902
  // ------------------------------------------------------------ pi → plugin
727
903
 
728
904
  /**
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.
905
+ * A turn started: the plugin's own agent fact is `running`.
906
+ *
907
+ * How a turn end settles the work is the client's rule
908
+ * (`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.
733
911
  */
734
912
  onTurnStart() {
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
- }
746
913
  void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
747
914
  }
748
915
 
749
916
  /**
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.
917
+ * A turn ended: one turn of a run that may still have more. The phase is
918
+ * re-derived, so the beat says `running` while pi keeps working and says
919
+ * `idle` only once the session waits for input. A clean turn end settles
920
+ * nothing: the client owns that decision, and it reaches the session as its
921
+ * own frame.
753
922
  */
754
923
  onTurnEnd() {
755
- for (const task of this.tasks.values()) {
756
- if (!task.completed) task.turnsSinceAssign += 1;
757
- }
758
924
  void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
759
- this.armSettleFallback();
760
925
  }
761
926
 
762
927
  /**
763
- * pi has settled: this is the moment the rule names as waiting for input, and
764
- * the settle decision belongs to it.
928
+ * pi has settled: the session is waiting for input, which is the moment an
929
+ * errored turn is reported from.
765
930
  */
766
931
  onSettled() {
767
932
  this.clearSettleFallback();
768
933
  void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
769
- void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
934
+ void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
770
935
  }
771
936
 
772
937
  /**
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.
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.
779
944
  */
780
945
  async trySettle() {
781
946
  if (this.closed || this.activeTasks().length === 0) return;
@@ -783,7 +948,7 @@ export class OnlyneAgent {
783
948
  this.armSettleFallback();
784
949
  return;
785
950
  }
786
- await this.settleNow().catch((error) => this.log(`settle decision failed: ${error.message}`));
951
+ await this.reportFailedTurns().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
787
952
  }
788
953
 
789
954
  /** A failed turn: the task's outcome is `failed`, with the error as its head. */
@@ -793,19 +958,18 @@ export class OnlyneAgent {
793
958
  task.errored = true;
794
959
  if (text) task.head = headOf(text);
795
960
  }
796
- void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
961
+ void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
797
962
  }
798
963
 
799
964
  /**
800
- * The last assistant text seen, kept as the completion summary for a task
801
- * whose `onlyne_complete` call hands no argument over.
965
+ * The last assistant text seen, kept as the ledger head for a task whose
966
+ * `onlyne_complete` call carries an empty `summary`.
802
967
  *
803
- * This is the fallback, never the deliverable: `onlyne_complete`'s `text` is
804
- * reported byte for byte by `completeFromTool` and is never written back
805
- * here, so the sentence a turn happened to end on cannot stand in for a
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.
968
+ * This is the fallback, never the deliverable: `onlyne_complete`'s `summary`
969
+ * is reported byte for byte by `completeFromTool` and is never written back
970
+ * here, so the sentence a turn happened to end on cannot stand in for the
971
+ * summary the tool call carried. Nothing else reads it — and the full result
972
+ * travels in `details`, which this never touches.
809
973
  */
810
974
  noteAssistantText(text) {
811
975
  const flat = headOf(text);
@@ -820,7 +984,7 @@ export class OnlyneAgent {
820
984
  if (this.closed || this.activeTasks().length === 0) return;
821
985
  this.settleHandle = this.timer.set(() => {
822
986
  this.settleHandle = null;
823
- void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
987
+ void this.trySettle().catch((error) => this.log(`failed-turn report refused: ${error.message}`));
824
988
  }, this.settleFallbackMs);
825
989
  }
826
990
 
@@ -831,121 +995,52 @@ export class OnlyneAgent {
831
995
  }
832
996
 
833
997
  /**
834
- * The idle ladder's decision, taken for every task this session still holds
835
- * when a settle signal finds the agent idle.
998
+ * Report the failures this session witnessed itself, for every task it still
999
+ * holds.
836
1000
  *
837
1001
  * 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.
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.
856
1006
  */
857
- async settleNow() {
1007
+ async reportFailedTurns() {
858
1008
  for (const task of [...this.tasks.values()]) {
859
- if (task.completed) continue;
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}`);
1009
+ if (task.completed || !task.errored) continue;
1010
+ await this.complete(task.taskId, "failed", task.head);
872
1011
  }
873
1012
  }
874
1013
 
875
- /**
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
1014
  /**
907
1015
  * `onlyne_complete`: the model's explicit outcome, and the only path to
908
1016
  * `done`.
909
1017
  *
910
- * A non-empty `text` is the completion body: it is what the model handed
911
- * over, reported verbatim as the ledger's one-line `head`, so the last
912
- * assistant text never stands in for it. An absent or blank `text` carries no
913
- * deliverable at all and falls back to that assistant text.
1018
+ * `summary` is the display line the ledger keeps: it is what the model handed
1019
+ * over, flattened to one line and reported as the `head`, so the last
1020
+ * assistant text never stands in for it. An empty `summary` carries no
1021
+ * display line at all and falls back to that assistant text.
1022
+ *
1023
+ * `details` is the full result and `files` the absolute paths it names. Both
1024
+ * travel on unchanged, and both are what the next hop and the originator
1025
+ * receive: the client owns the shape and the ceiling, and refuses an oversize
1026
+ * body with its own sentence (`docs/v2-CONTRACT.md` §3c).
914
1027
  *
915
- * The relay guard runs first, for every outcome, when the workspace's
916
- * `relay.toml` names a minimum downstream handoff: a session that still owes
917
- * one cannot report a terminal fact. A refusal throws before anything is
918
- * written, queued or detached — no completion report, no exit, no state on
919
- * the task — so the session stays live and the same call lands once the
920
- * handoff has gone out. `force: true` with a non-empty `reason` waives the
921
- * guard and stamps the head with `relay-guard-forced: <reason>`, which is the
922
- * ledger's audit trail for a completion that skipped the guard.
1028
+ * No policy of this plugin's runs before them. The completion's shape, the
1029
+ * details ceiling and the relay requirement are the client's checks, so its
1030
+ * refusal is raised out of here exactly as it arrived.
923
1031
  *
924
- * @param {{ outcome?: string, text?: string, force?: boolean, reason?: string }} input
1032
+ * @param {{ outcome?: string, summary?: string, details?: string, files?: string[] }} input
925
1033
  */
926
1034
  async completeFromTool(input = {}) {
927
1035
  const task = [...this.tasks.values()].find((item) => !item.completed);
928
1036
  const taskId = task?.taskId ?? this.envTaskId;
929
1037
  if (!taskId) throw new Error("onlyne: no task is assigned to this session");
930
- const explicit = headOf(input.text);
931
- let head = explicit || task?.head || "";
932
- if (relayEnabled(this.relay)) {
933
- const refusal = relayRefusal(this.relay, this.deliveredTo, {
934
- role: this.role,
935
- upstream: task?.upstream ?? null,
936
- });
937
- if (refusal) {
938
- const reason = headOf(input.reason);
939
- if (input.force !== true || !reason) {
940
- this.log(refusal);
941
- this.notice("warn", refusal);
942
- throw new Error(`onlyne: ${refusal}`);
943
- }
944
- head = headOf(`${FORCED_PREFIX}${reason}${explicit ? ` | ${explicit}` : ""}`);
945
- this.notice("warn", `relay guard waived: ${reason}`);
946
- }
947
- }
948
- return this.complete(taskId, normalizeOutcome(input.outcome), head);
1038
+ const explicit = headOf(input.summary);
1039
+ const head = explicit || task?.head || "";
1040
+ return this.complete(taskId, normalizeOutcome(input.outcome), head, {
1041
+ details: typeof input.details === "string" ? input.details : null,
1042
+ files: Array.isArray(input.files) ? input.files : [],
1043
+ });
949
1044
  }
950
1045
 
951
1046
  /**
@@ -961,24 +1056,56 @@ export class OnlyneAgent {
961
1056
  * therefore means this process can leave without losing the outcome, and a
962
1057
  * rejected or queued report must never exit.
963
1058
  *
964
- * @param {{ exitProcess?: boolean }} [options] `false` for the recycle path,
965
- * which ends the process after its own detach frame instead.
1059
+ * `details` and `files` ride on the report itself; `head` stays the display
1060
+ * line the ledger keeps.
1061
+ *
1062
+ * @param {{ exitProcess?: boolean, details?: string | null, files?: string[] }} [options]
1063
+ * `exitProcess: true` is the recycle path, which ends the process after its
1064
+ * own detach frame instead.
966
1065
  */
967
1066
  async complete(taskId, outcome, head, options = {}) {
968
- const exitProcess = options.exitProcess ?? true;
1067
+ // A delivery ends this process when the role's scope says the session is
1068
+ // finished, and not otherwise. Both halves of that are load-bearing:
1069
+ //
1070
+ // Leaving when the scope keeps the session empties the pool — a `role` role
1071
+ // then opens one session per delivery, which is what `oneshot` means and the
1072
+ // opposite of what the operator asked for.
1073
+ //
1074
+ // Staying when the scope does not keep it leaks the host resource. The
1075
+ // client retires a session while its agent is still reachable — that is how
1076
+ // a pool member survives — so a resident runtime holds its pane open
1077
+ // forever. Its own comment says the exemption belongs to a kept session, and
1078
+ // the scope is what says which one this is.
1079
+ //
1080
+ // And the leave has to be this process's own. The client's teardown is a pane
1081
+ // kill, and a runtime killed mid-teardown loses whatever it had not yet
1082
+ // written: pi flushes its session file as it shuts down, so the graceful exit
1083
+ // is the only one that keeps the completion entry.
1084
+ const exitProcess = options.exitProcess ?? !this.keepsSession();
1085
+ const details = typeof options.details === "string" && options.details.length > 0 ? options.details : null;
1086
+ const files = Array.isArray(options.files) ? options.files : [];
969
1087
  const normalized = normalizeOutcome(outcome);
970
1088
  const summary = headOf(head);
971
1089
  const task = this.tasks.get(taskId);
972
1090
  if (task?.completed) return { taskId, outcome: normalized, head: summary, duplicate: true };
973
1091
  if (task) task.completed = true;
974
- const report = completeReport({ taskId, outcome: normalized, head: summary });
1092
+ const report = completeReport({ taskId, outcome: normalized, head: summary, details, files });
975
1093
  if (!this.connected) {
976
- this.pendingCompletion = { taskId, report, outcome: normalized, exitProcess };
1094
+ this.pendingCompletions.push({ taskId, report, outcome: normalized, exitProcess });
977
1095
  this.activity.set({ taskId, phase: `${normalized} queued` });
978
1096
  this.notice("warn", `complete ${taskId.slice(0, 8)} ${normalized} queued: socket down`);
979
1097
  return { taskId, outcome: normalized, head: summary, queued: true };
980
1098
  }
981
- await this.request("report", report);
1099
+ // The claim above only fences a second call while this report is in
1100
+ // flight. A refused or broken request handed nothing over, so the task
1101
+ // goes back to open and the caller sees why: a retry reports again, and
1102
+ // `completeFromTool` still finds the task.
1103
+ try {
1104
+ await this.request("report", report);
1105
+ } catch (error) {
1106
+ if (task) task.completed = false;
1107
+ throw error;
1108
+ }
982
1109
  this.stats.completions += 1;
983
1110
  this.surface.customEntry?.("onlyne-complete", { taskId, outcome: normalized, head: summary });
984
1111
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: normalized });
@@ -1005,21 +1132,29 @@ export class OnlyneAgent {
1005
1132
  this.surface.exit?.(reason);
1006
1133
  }
1007
1134
 
1008
- async flushPendingCompletion() {
1009
- const pending = this.pendingCompletion;
1010
- if (!pending || !this.connected) return;
1011
- this.pendingCompletion = null;
1012
- try {
1013
- await this.request("report", pending.report);
1014
- this.stats.completions += 1;
1015
- this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
1016
- this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
1017
- if (pending.exitProcess && this.activeTasks().length === 0) {
1018
- this.exitSession(pending.outcome);
1135
+ /**
1136
+ * Hand over every completion the dead socket could not carry, in the order the
1137
+ * calls came. The drain stops at the first report the new connection refuses:
1138
+ * that one goes back on the head of the queue, and the rest wait for the next
1139
+ * hello with it, so a client that dies mid-flush loses nothing.
1140
+ */
1141
+ async flushPendingCompletions() {
1142
+ while (this.pendingCompletions.length > 0) {
1143
+ if (!this.connected) return;
1144
+ const pending = this.pendingCompletions.shift();
1145
+ try {
1146
+ await this.request("report", pending.report);
1147
+ this.stats.completions += 1;
1148
+ this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
1149
+ this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
1150
+ if (pending.exitProcess && this.activeTasks().length === 0) {
1151
+ this.exitSession(pending.outcome);
1152
+ }
1153
+ } catch (error) {
1154
+ this.pendingCompletions.unshift(pending);
1155
+ this.log(`queued completion still refused: ${error.message}`);
1156
+ break;
1019
1157
  }
1020
- } catch (error) {
1021
- this.pendingCompletion = pending;
1022
- this.log(`queued completion still refused: ${error.message}`);
1023
1158
  }
1024
1159
  }
1025
1160
 
@@ -1048,10 +1183,6 @@ export class OnlyneAgent {
1048
1183
  const image = this.imageFromPath(input.imagePath);
1049
1184
  const envelope = sendEnvelope({ from: this.role, to: input.to, kind, text: input.text ?? "", image });
1050
1185
  const data = await this.request("send", envelope);
1051
- // Recorded only after the client answered the `send`: a refused envelope was
1052
- // never a handoff, and the relay guard must not read one as delivered. Any
1053
- // kind counts — `note` and `task` are both the session reaching that role.
1054
- this.deliveredTo.add(String(input.to));
1055
1186
  return { queued: true, op_id: envelope.op_id ?? null, kind, to: input.to, data };
1056
1187
  }
1057
1188
 
@@ -1088,26 +1219,17 @@ export class OnlyneAgent {
1088
1219
  // ------------------------------------------------------------ attachments
1089
1220
 
1090
1221
  /**
1091
- * Write one inbound image to the workspace and return the path plus the pi
1092
- * image part, so the model both sees the picture and can address the file.
1222
+ * The pi image parts for one delivery, from the envelope's inline image. The
1223
+ * client writes the files itself and names every path in `assign.args
1224
+ * .attachments`, so this plugin only hands pi the bytes it was given; the
1225
+ * model addresses the files through the paths the delivery text names.
1093
1226
  */
1094
- writeAttachments(taskId, envelope) {
1227
+ imageParts(envelope) {
1095
1228
  const image = envelope?.body?.image;
1096
1229
  if (!image || typeof image.data_base64 !== "string") return [];
1097
1230
  const mime = typeof image.mime === "string" && image.mime ? image.mime : "image/png";
1098
- const name = safeSegment(image.name ?? `image.${extensionForMime(mime)}`);
1099
- const dir = join(this.cwd, ".onlyne", "tmp", "attachments");
1100
- const path = join(dir, `${safeSegment(taskId)}-${safeSegment(envelope.id)}-${name}`);
1101
- try {
1102
- const bytes = Buffer.from(image.data_base64, "base64");
1103
- mkdirSync(dir, { recursive: true, mode: 0o700 });
1104
- writeFileSync(path, bytes, { mode: 0o600 });
1105
- this.notice("state", `attachment ${basename(path)}`);
1106
- return [{ path, part: { type: "image", mime, data: image.data_base64, name } }];
1107
- } catch (error) {
1108
- this.log(`attachment write failed: ${error.message}`);
1109
- return [];
1110
- }
1231
+ const name = typeof image.name === "string" ? image.name : null;
1232
+ return [{ type: "image", mime, data: image.data_base64, name }];
1111
1233
  }
1112
1234
  }
1113
1235