@astralform/js 9.4.1 → 9.6.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/dist/index.d.cts CHANGED
@@ -506,10 +506,23 @@ type ChatEvent = {
506
506
  type: "subagent_start";
507
507
  agent: AgentIdentity;
508
508
  taskCallId?: string | null;
509
+ /** The run's id, when the frame carries one (`agent_start`). */
510
+ runId?: string;
509
511
  } | {
510
512
  type: "subagent_stop";
511
513
  agent: AgentIdentity;
512
514
  taskCallId?: string | null;
515
+ /** The run's id, when the frame carries one (`agent_stop`). */
516
+ runId?: string;
517
+ /**
518
+ * The child's OWN outcome and spend, surfaced separately from the turn
519
+ * totals (`message_stop.usage` still folds them in). Absent on the older
520
+ * `subagent_stop` frames, which carried only the identity.
521
+ */
522
+ stopReason?: string;
523
+ inputTokens?: number;
524
+ outputTokens?: number;
525
+ durationMs?: number;
513
526
  } | {
514
527
  type: "context_warning";
515
528
  /** Known values: "info" | "warning" | "critical". Typed as string for forward compat. */
@@ -644,6 +657,19 @@ interface Conversation {
644
657
  * null) from an Astralform older than 0.69.46.
645
658
  */
646
659
  repository?: string | null;
660
+ /**
661
+ * The live job for this conversation, or `null`/absent when nothing is
662
+ * running. The sidebar's per-row liveness signal: without it a client cannot
663
+ * know that a conversation it has never opened is running, because its only
664
+ * other liveness signals are the per-conversation streams it is attached to.
665
+ *
666
+ * A JOB ID rather than a state string, deliberately: the backend can cheaply
667
+ * say "a job is live here", while "waiting for the user" lives in the
668
+ * worker's in-memory state and reaches only the open conversation's stream.
669
+ * `null` from a server that projects the field; `undefined` from one older
670
+ * than it (same convention as `repository`), so treat both as "not running".
671
+ */
672
+ activeJobId?: string | null;
647
673
  /**
648
674
  * The code group this task is filed under, or `null`/absent when it is not in
649
675
  * one. MUTABLE — unlike `repository`, which is written once on the first turn:
@@ -1970,12 +1996,6 @@ type ChatEventHandler = (event: ChatEvent) => void;
1970
1996
  * would leave the second page's offset pointing at the wrong row.
1971
1997
  */
1972
1998
  declare const CONVERSATION_PAGE_SIZE = 50;
1973
- /**
1974
- * ChatSession — translates the backend wire protocol into typed ChatEvents
1975
- * for consumers. Owns HTTP + SSE plumbing, conversation state, and the
1976
- * client-tool round-trip. Does NOT own block construction — consumers
1977
- * build their own block state from the typed events.
1978
- */
1979
1999
  declare class ChatSession {
1980
2000
  readonly client: AstralformClient;
1981
2001
  readonly toolRegistry: ToolRegistry;
@@ -2115,6 +2135,33 @@ declare class ChatSession {
2115
2135
  private processStream;
2116
2136
  /** Last received sequence number for resumable reconnection */
2117
2137
  private lastSeq;
2138
+ /**
2139
+ * Advance the resume cursor from a frame's `seq` — the position the next
2140
+ * reconnect asks the backend to replay after (`?after=`).
2141
+ *
2142
+ * Two guards, because getting this wrong is silent and expensive: the whole
2143
+ * turn replays over blocks the consumer is already rendering.
2144
+ *
2145
+ * - **A keepalive is not an event.** The backend sends it as an ordinary
2146
+ * `data:` frame with `seq: 0` BY DESIGN (``WireKeepalive(seq=0, ts=0.0)``
2147
+ * — an idle tick, not a position), and it arrives exactly when a turn has
2148
+ * been quiet for 45 s: the long-tool-run case. Harvesting it rewound the
2149
+ * cursor to 0, so a reconnect after a blip replayed the turn from its
2150
+ * start — deltas and all.
2151
+ * - **The cursor only advances.** `send` and `reconnectToJob` reset it to
2152
+ * -1 per job, so a new job's `seq: 0` still moves it forward; a frame
2153
+ * carrying an older (or zero) position cannot move it back.
2154
+ *
2155
+ * The one other frame on the wire with a non-position `seq` is the
2156
+ * subscriber's SYNTHETIC terminal — a back-filled `error` for a job whose
2157
+ * worker died, built without a `seq` so Pydantic's default `0` reaches the
2158
+ * wire. Deliberately not named here: the advance-only rule already ignores it
2159
+ * whenever the client has drained anything (0 never exceeds a real cursor),
2160
+ * and when it is the FIRST frame the pump ends on `sawTerminal` before any
2161
+ * reconnect could use the cursor. Naming it would add a rule that no
2162
+ * reachable input exercises.
2163
+ */
2164
+ private harvestResumeCursor;
2118
2165
  /**
2119
2166
  * Client-tool call_ids whose result was already submitted this turn. On a
2120
2167
  * reconnect the resumed stream can replay a tool request we already handled;
@@ -2182,7 +2229,15 @@ declare class ChatSession {
2182
2229
  * Reconnect to a running job's SSE stream (e.g. after page reload).
2183
2230
  * Replays all events from the beginning and continues live.
2184
2231
  */
2185
- reconnectToJob(jobId: string): Promise<void>;
2232
+ /**
2233
+ * @param afterSeq Resume the job's event stream AFTER this sequence number.
2234
+ * Omitted (the reconnect default) replays the job from the beginning. A
2235
+ * caller that holds the job's events up to `afterSeq` — the transcript
2236
+ * cache — passes it so the backend replays only the delta and then goes
2237
+ * live; the backend's `?after=` handles a job that already ended by
2238
+ * back-filling its terminal event.
2239
+ */
2240
+ reconnectToJob(jobId: string, afterSeq?: number): Promise<void>;
2186
2241
  /** Detach from the SSE stream without cancelling the job. */
2187
2242
  detach(): void;
2188
2243
  /**
@@ -2279,6 +2334,31 @@ declare class ChatSession {
2279
2334
  * pages, and a doubled prompt is more visible than a missing one.
2280
2335
  */
2281
2336
  prependMessages(older: Message[]): void;
2337
+ /**
2338
+ * Re-fetch the NEWEST page of the conversation list and merge it in.
2339
+ *
2340
+ * The list the sidebar renders is seeded once at `connect()` and then only
2341
+ * changed by this client's own actions (create, delete, rename) — so a row's
2342
+ * `activeJobId` is as fresh as that seed, and a job started in ANOTHER tab or
2343
+ * on another device never appears, while one that ended elsewhere stays lit
2344
+ * forever. A caller that refreshes on a trigger (the tab becoming visible, or
2345
+ * a bounded interval while any job is live) keeps the rows honest without a
2346
+ * socket per conversation.
2347
+ *
2348
+ * Merge rules, and why each is what it is:
2349
+ *
2350
+ * - the server's ORDER wins for the rows it returns: an updated conversation
2351
+ * moves to its new place, which is the whole point of the call;
2352
+ * - rows paged in EARLIER (`loadMoreConversations`) are kept — this reads page
2353
+ * 1 only, and dropping the tail would make a refresh silently truncate a
2354
+ * list the reader has already scrolled;
2355
+ * - a row the caller MINTED locally and the server does not know yet is kept
2356
+ * too (it has no server row to be refreshed by);
2357
+ * - `conversationsGeneration` is bumped, for the same reason `connect()`
2358
+ * bumps it: a page request already in flight was cut against the old head
2359
+ * and its offset no longer means what it did.
2360
+ */
2361
+ refreshConversations(): Promise<Conversation[]>;
2282
2362
  loadMoreConversations(): Promise<Conversation[]>;
2283
2363
  /**
2284
2364
  * Rename a conversation, server first.
@@ -2605,6 +2685,35 @@ declare class StreamManager {
2605
2685
  */
2606
2686
  switchTo(conversationId: string, opts?: {
2607
2687
  skipHistoryReplay?: boolean;
2688
+ /**
2689
+ * Jobs whose events the caller ALREADY holds — the transcript-cache
2690
+ * resume. A COMPLETED job named here is neither fetched nor replayed: its
2691
+ * blocks are in the caller's store, and replaying them would draw a
2692
+ * second copy.
2693
+ *
2694
+ * Two limits, both deliberate and both stating what a review proved the
2695
+ * wider contract could not:
2696
+ *
2697
+ * - **The newest turn page only.** The marks are matched against the page
2698
+ * of jobs this restore walks, so a mark for a turn OLDER than that page
2699
+ * is not consulted. The message window (40 rows) and the turn page (10)
2700
+ * are independent spans, so a cache deeper than one page would need its
2701
+ * own cursor design — a prompt whose turn is not on the page cannot be
2702
+ * recognized as held, and would come back as a steer bubble.
2703
+ * - **Completed jobs only.** A mark for the RUNNING job is ignored (its
2704
+ * stream starts from the beginning, as it always did). Resuming a live
2705
+ * turn's stream from a mark would deliver the delta but NOT the prompt
2706
+ * — a prompt is a message-table row, not a stream event — so the plan
2707
+ * would still draw a bubble the caller's cache already renders.
2708
+ *
2709
+ * Absent → today's behaviour exactly. A job NOT named is fetched whole, as
2710
+ * now, so a stale mark loses at most one completed turn's skip, never a
2711
+ * turn.
2712
+ */
2713
+ have?: ReadonlyArray<{
2714
+ jobId: string;
2715
+ lastSeq: number;
2716
+ }>;
2608
2717
  }): Promise<void>;
2609
2718
  /**
2610
2719
  * Re-attach to whatever the server says is live for the ACTIVE conversation.
package/dist/index.d.ts CHANGED
@@ -506,10 +506,23 @@ type ChatEvent = {
506
506
  type: "subagent_start";
507
507
  agent: AgentIdentity;
508
508
  taskCallId?: string | null;
509
+ /** The run's id, when the frame carries one (`agent_start`). */
510
+ runId?: string;
509
511
  } | {
510
512
  type: "subagent_stop";
511
513
  agent: AgentIdentity;
512
514
  taskCallId?: string | null;
515
+ /** The run's id, when the frame carries one (`agent_stop`). */
516
+ runId?: string;
517
+ /**
518
+ * The child's OWN outcome and spend, surfaced separately from the turn
519
+ * totals (`message_stop.usage` still folds them in). Absent on the older
520
+ * `subagent_stop` frames, which carried only the identity.
521
+ */
522
+ stopReason?: string;
523
+ inputTokens?: number;
524
+ outputTokens?: number;
525
+ durationMs?: number;
513
526
  } | {
514
527
  type: "context_warning";
515
528
  /** Known values: "info" | "warning" | "critical". Typed as string for forward compat. */
@@ -644,6 +657,19 @@ interface Conversation {
644
657
  * null) from an Astralform older than 0.69.46.
645
658
  */
646
659
  repository?: string | null;
660
+ /**
661
+ * The live job for this conversation, or `null`/absent when nothing is
662
+ * running. The sidebar's per-row liveness signal: without it a client cannot
663
+ * know that a conversation it has never opened is running, because its only
664
+ * other liveness signals are the per-conversation streams it is attached to.
665
+ *
666
+ * A JOB ID rather than a state string, deliberately: the backend can cheaply
667
+ * say "a job is live here", while "waiting for the user" lives in the
668
+ * worker's in-memory state and reaches only the open conversation's stream.
669
+ * `null` from a server that projects the field; `undefined` from one older
670
+ * than it (same convention as `repository`), so treat both as "not running".
671
+ */
672
+ activeJobId?: string | null;
647
673
  /**
648
674
  * The code group this task is filed under, or `null`/absent when it is not in
649
675
  * one. MUTABLE — unlike `repository`, which is written once on the first turn:
@@ -1970,12 +1996,6 @@ type ChatEventHandler = (event: ChatEvent) => void;
1970
1996
  * would leave the second page's offset pointing at the wrong row.
1971
1997
  */
1972
1998
  declare const CONVERSATION_PAGE_SIZE = 50;
1973
- /**
1974
- * ChatSession — translates the backend wire protocol into typed ChatEvents
1975
- * for consumers. Owns HTTP + SSE plumbing, conversation state, and the
1976
- * client-tool round-trip. Does NOT own block construction — consumers
1977
- * build their own block state from the typed events.
1978
- */
1979
1999
  declare class ChatSession {
1980
2000
  readonly client: AstralformClient;
1981
2001
  readonly toolRegistry: ToolRegistry;
@@ -2115,6 +2135,33 @@ declare class ChatSession {
2115
2135
  private processStream;
2116
2136
  /** Last received sequence number for resumable reconnection */
2117
2137
  private lastSeq;
2138
+ /**
2139
+ * Advance the resume cursor from a frame's `seq` — the position the next
2140
+ * reconnect asks the backend to replay after (`?after=`).
2141
+ *
2142
+ * Two guards, because getting this wrong is silent and expensive: the whole
2143
+ * turn replays over blocks the consumer is already rendering.
2144
+ *
2145
+ * - **A keepalive is not an event.** The backend sends it as an ordinary
2146
+ * `data:` frame with `seq: 0` BY DESIGN (``WireKeepalive(seq=0, ts=0.0)``
2147
+ * — an idle tick, not a position), and it arrives exactly when a turn has
2148
+ * been quiet for 45 s: the long-tool-run case. Harvesting it rewound the
2149
+ * cursor to 0, so a reconnect after a blip replayed the turn from its
2150
+ * start — deltas and all.
2151
+ * - **The cursor only advances.** `send` and `reconnectToJob` reset it to
2152
+ * -1 per job, so a new job's `seq: 0` still moves it forward; a frame
2153
+ * carrying an older (or zero) position cannot move it back.
2154
+ *
2155
+ * The one other frame on the wire with a non-position `seq` is the
2156
+ * subscriber's SYNTHETIC terminal — a back-filled `error` for a job whose
2157
+ * worker died, built without a `seq` so Pydantic's default `0` reaches the
2158
+ * wire. Deliberately not named here: the advance-only rule already ignores it
2159
+ * whenever the client has drained anything (0 never exceeds a real cursor),
2160
+ * and when it is the FIRST frame the pump ends on `sawTerminal` before any
2161
+ * reconnect could use the cursor. Naming it would add a rule that no
2162
+ * reachable input exercises.
2163
+ */
2164
+ private harvestResumeCursor;
2118
2165
  /**
2119
2166
  * Client-tool call_ids whose result was already submitted this turn. On a
2120
2167
  * reconnect the resumed stream can replay a tool request we already handled;
@@ -2182,7 +2229,15 @@ declare class ChatSession {
2182
2229
  * Reconnect to a running job's SSE stream (e.g. after page reload).
2183
2230
  * Replays all events from the beginning and continues live.
2184
2231
  */
2185
- reconnectToJob(jobId: string): Promise<void>;
2232
+ /**
2233
+ * @param afterSeq Resume the job's event stream AFTER this sequence number.
2234
+ * Omitted (the reconnect default) replays the job from the beginning. A
2235
+ * caller that holds the job's events up to `afterSeq` — the transcript
2236
+ * cache — passes it so the backend replays only the delta and then goes
2237
+ * live; the backend's `?after=` handles a job that already ended by
2238
+ * back-filling its terminal event.
2239
+ */
2240
+ reconnectToJob(jobId: string, afterSeq?: number): Promise<void>;
2186
2241
  /** Detach from the SSE stream without cancelling the job. */
2187
2242
  detach(): void;
2188
2243
  /**
@@ -2279,6 +2334,31 @@ declare class ChatSession {
2279
2334
  * pages, and a doubled prompt is more visible than a missing one.
2280
2335
  */
2281
2336
  prependMessages(older: Message[]): void;
2337
+ /**
2338
+ * Re-fetch the NEWEST page of the conversation list and merge it in.
2339
+ *
2340
+ * The list the sidebar renders is seeded once at `connect()` and then only
2341
+ * changed by this client's own actions (create, delete, rename) — so a row's
2342
+ * `activeJobId` is as fresh as that seed, and a job started in ANOTHER tab or
2343
+ * on another device never appears, while one that ended elsewhere stays lit
2344
+ * forever. A caller that refreshes on a trigger (the tab becoming visible, or
2345
+ * a bounded interval while any job is live) keeps the rows honest without a
2346
+ * socket per conversation.
2347
+ *
2348
+ * Merge rules, and why each is what it is:
2349
+ *
2350
+ * - the server's ORDER wins for the rows it returns: an updated conversation
2351
+ * moves to its new place, which is the whole point of the call;
2352
+ * - rows paged in EARLIER (`loadMoreConversations`) are kept — this reads page
2353
+ * 1 only, and dropping the tail would make a refresh silently truncate a
2354
+ * list the reader has already scrolled;
2355
+ * - a row the caller MINTED locally and the server does not know yet is kept
2356
+ * too (it has no server row to be refreshed by);
2357
+ * - `conversationsGeneration` is bumped, for the same reason `connect()`
2358
+ * bumps it: a page request already in flight was cut against the old head
2359
+ * and its offset no longer means what it did.
2360
+ */
2361
+ refreshConversations(): Promise<Conversation[]>;
2282
2362
  loadMoreConversations(): Promise<Conversation[]>;
2283
2363
  /**
2284
2364
  * Rename a conversation, server first.
@@ -2605,6 +2685,35 @@ declare class StreamManager {
2605
2685
  */
2606
2686
  switchTo(conversationId: string, opts?: {
2607
2687
  skipHistoryReplay?: boolean;
2688
+ /**
2689
+ * Jobs whose events the caller ALREADY holds — the transcript-cache
2690
+ * resume. A COMPLETED job named here is neither fetched nor replayed: its
2691
+ * blocks are in the caller's store, and replaying them would draw a
2692
+ * second copy.
2693
+ *
2694
+ * Two limits, both deliberate and both stating what a review proved the
2695
+ * wider contract could not:
2696
+ *
2697
+ * - **The newest turn page only.** The marks are matched against the page
2698
+ * of jobs this restore walks, so a mark for a turn OLDER than that page
2699
+ * is not consulted. The message window (40 rows) and the turn page (10)
2700
+ * are independent spans, so a cache deeper than one page would need its
2701
+ * own cursor design — a prompt whose turn is not on the page cannot be
2702
+ * recognized as held, and would come back as a steer bubble.
2703
+ * - **Completed jobs only.** A mark for the RUNNING job is ignored (its
2704
+ * stream starts from the beginning, as it always did). Resuming a live
2705
+ * turn's stream from a mark would deliver the delta but NOT the prompt
2706
+ * — a prompt is a message-table row, not a stream event — so the plan
2707
+ * would still draw a bubble the caller's cache already renders.
2708
+ *
2709
+ * Absent → today's behaviour exactly. A job NOT named is fetched whole, as
2710
+ * now, so a stale mark loses at most one completed turn's skip, never a
2711
+ * turn.
2712
+ */
2713
+ have?: ReadonlyArray<{
2714
+ jobId: string;
2715
+ lastSeq: number;
2716
+ }>;
2608
2717
  }): Promise<void>;
2609
2718
  /**
2610
2719
  * Re-attach to whatever the server says is live for the ACTIVE conversation.
package/dist/index.js CHANGED
@@ -1492,13 +1492,29 @@ function translateDelta(wire) {
1492
1492
  }
1493
1493
  }
1494
1494
  function translateAgentIdentity(raw) {
1495
+ const source = raw !== null && typeof raw === "object" ? { ...raw } : {};
1496
+ const read = (key) => source[key];
1495
1497
  return {
1496
- name: raw.name ?? "",
1497
- displayName: raw.display_name ?? null,
1498
- avatarUrl: raw.avatar_url ?? null,
1499
- description: raw.description ?? null
1498
+ name: read("name") ?? "",
1499
+ displayName: read("display_name") ?? null,
1500
+ avatarUrl: read("avatar_url") ?? null,
1501
+ description: read("description") ?? null
1500
1502
  };
1501
1503
  }
1504
+ function agentIdentityFromEvent(data) {
1505
+ if ("agent" in data) return translateAgentIdentity(data.agent);
1506
+ return translateAgentIdentity({
1507
+ name: data.agent_name,
1508
+ display_name: data.agent_display_name,
1509
+ avatar_url: data.agent_avatar_url
1510
+ });
1511
+ }
1512
+ function optionalString(value) {
1513
+ return typeof value === "string" ? value : void 0;
1514
+ }
1515
+ function optionalNumber(value) {
1516
+ return typeof value === "number" ? value : void 0;
1517
+ }
1502
1518
  function translateTodoItem(raw) {
1503
1519
  return {
1504
1520
  id: raw.id ?? 0,
@@ -1549,21 +1565,33 @@ function translateCustomEvent(name, data) {
1549
1565
  phase: data.phase ?? null,
1550
1566
  updatedAt: data.updated_at ?? null
1551
1567
  };
1568
+ // Two names for one event: the backend renamed these to `agent_*` (a nested
1569
+ // run is not necessarily a "subagent" in the product's vocabulary) while
1570
+ // older servers still emit `subagent_*`. Both are translated, so a client
1571
+ // works across the rename.
1572
+ case "agent_start":
1552
1573
  case "subagent_start":
1553
1574
  return {
1554
1575
  type: "subagent_start",
1555
- agent: translateAgentIdentity(
1556
- data.agent ?? {}
1557
- ),
1558
- taskCallId: data.task_call_id ?? null
1576
+ runId: optionalString(data.run_id),
1577
+ agent: agentIdentityFromEvent(data),
1578
+ // `parent_call_id` is the spawn block's call_id — the same key a client
1579
+ // uses to find the tool block whose subtree is this child's work.
1580
+ taskCallId: optionalString(data.parent_call_id) ?? optionalString(data.task_call_id) ?? null
1559
1581
  };
1582
+ case "agent_stop":
1560
1583
  case "subagent_stop":
1561
1584
  return {
1562
1585
  type: "subagent_stop",
1563
- agent: translateAgentIdentity(
1564
- data.agent ?? {}
1565
- ),
1566
- taskCallId: data.task_call_id ?? null
1586
+ runId: optionalString(data.run_id),
1587
+ agent: agentIdentityFromEvent(data),
1588
+ taskCallId: optionalString(data.parent_call_id) ?? optionalString(data.task_call_id) ?? null,
1589
+ // The child's OWN spend and outcome, which the wire surfaces separately
1590
+ // from the turn totals (`message_stop.usage` folds them in too).
1591
+ stopReason: optionalString(data.stop_reason),
1592
+ inputTokens: optionalNumber(data.input_tokens),
1593
+ outputTokens: optionalNumber(data.output_tokens),
1594
+ durationMs: optionalNumber(data.duration_ms)
1567
1595
  };
1568
1596
  case "context_warning":
1569
1597
  return {
@@ -1813,6 +1841,11 @@ function pathEquals(a, b) {
1813
1841
  }
1814
1842
  return true;
1815
1843
  }
1844
+ function frameSeq(frame) {
1845
+ if (typeof frame !== "object" || frame === null) return void 0;
1846
+ if (!("seq" in frame)) return void 0;
1847
+ return frame.seq;
1848
+ }
1816
1849
  var ChatSession = class {
1817
1850
  constructor(config, storage) {
1818
1851
  /**
@@ -2123,6 +2156,37 @@ var ChatSession = class {
2123
2156
  }
2124
2157
  }
2125
2158
  }
2159
+ /**
2160
+ * Advance the resume cursor from a frame's `seq` — the position the next
2161
+ * reconnect asks the backend to replay after (`?after=`).
2162
+ *
2163
+ * Two guards, because getting this wrong is silent and expensive: the whole
2164
+ * turn replays over blocks the consumer is already rendering.
2165
+ *
2166
+ * - **A keepalive is not an event.** The backend sends it as an ordinary
2167
+ * `data:` frame with `seq: 0` BY DESIGN (``WireKeepalive(seq=0, ts=0.0)``
2168
+ * — an idle tick, not a position), and it arrives exactly when a turn has
2169
+ * been quiet for 45 s: the long-tool-run case. Harvesting it rewound the
2170
+ * cursor to 0, so a reconnect after a blip replayed the turn from its
2171
+ * start — deltas and all.
2172
+ * - **The cursor only advances.** `send` and `reconnectToJob` reset it to
2173
+ * -1 per job, so a new job's `seq: 0` still moves it forward; a frame
2174
+ * carrying an older (or zero) position cannot move it back.
2175
+ *
2176
+ * The one other frame on the wire with a non-position `seq` is the
2177
+ * subscriber's SYNTHETIC terminal — a back-filled `error` for a job whose
2178
+ * worker died, built without a `seq` so Pydantic's default `0` reaches the
2179
+ * wire. Deliberately not named here: the advance-only rule already ignores it
2180
+ * whenever the client has drained anything (0 never exceeds a real cursor),
2181
+ * and when it is the FIRST frame the pump ends on `sawTerminal` before any
2182
+ * reconnect could use the cursor. Naming it would add a rule that no
2183
+ * reachable input exercises.
2184
+ */
2185
+ harvestResumeCursor(seq, type) {
2186
+ if (type === "keepalive") return;
2187
+ if (typeof seq !== "number") return;
2188
+ if (seq > this.lastSeq) this.lastSeq = seq;
2189
+ }
2126
2190
  async consumeJobStream(request, wire) {
2127
2191
  const job = await this.client.createJob(request);
2128
2192
  if (wire) wire.reached = true;
@@ -2260,18 +2324,14 @@ var ChatSession = class {
2260
2324
  try {
2261
2325
  const data = JSON.parse(raw.data);
2262
2326
  if (typeof data !== "object" || data === null || typeof data.type !== "string") {
2263
- if (typeof data?.seq === "number") {
2264
- this.lastSeq = data.seq;
2265
- }
2327
+ this.harvestResumeCursor(frameSeq(data), void 0);
2266
2328
  if (typeof data === "object" && data !== null && "data" in data && data.data === "[DONE]") {
2267
2329
  sawTerminal = true;
2268
2330
  }
2269
2331
  continue;
2270
2332
  }
2271
2333
  parsed = data;
2272
- if (typeof data.seq === "number") {
2273
- this.lastSeq = data.seq;
2274
- }
2334
+ this.harvestResumeCursor(frameSeq(data), parsed.type);
2275
2335
  } catch {
2276
2336
  continue;
2277
2337
  }
@@ -2500,11 +2560,19 @@ var ChatSession = class {
2500
2560
  * Reconnect to a running job's SSE stream (e.g. after page reload).
2501
2561
  * Replays all events from the beginning and continues live.
2502
2562
  */
2503
- async reconnectToJob(jobId) {
2563
+ /**
2564
+ * @param afterSeq Resume the job's event stream AFTER this sequence number.
2565
+ * Omitted (the reconnect default) replays the job from the beginning. A
2566
+ * caller that holds the job's events up to `afterSeq` — the transcript
2567
+ * cache — passes it so the backend replays only the delta and then goes
2568
+ * live; the backend's `?after=` handles a job that already ended by
2569
+ * back-filling its terminal event.
2570
+ */
2571
+ async reconnectToJob(jobId, afterSeq) {
2504
2572
  if (this.isStreaming) return;
2505
2573
  this.isStreaming = true;
2506
2574
  this.currentJobId = jobId;
2507
- this.lastSeq = -1;
2575
+ this.lastSeq = afterSeq ?? -1;
2508
2576
  this.submittedToolCallIds.clear();
2509
2577
  this.resetStreamingState();
2510
2578
  const controller = new AbortController();
@@ -2704,6 +2772,48 @@ var ChatSession = class {
2704
2772
  if (!fresh.length) return;
2705
2773
  this.setMessages([...fresh, ...this.messages]);
2706
2774
  }
2775
+ /**
2776
+ * Re-fetch the NEWEST page of the conversation list and merge it in.
2777
+ *
2778
+ * The list the sidebar renders is seeded once at `connect()` and then only
2779
+ * changed by this client's own actions (create, delete, rename) — so a row's
2780
+ * `activeJobId` is as fresh as that seed, and a job started in ANOTHER tab or
2781
+ * on another device never appears, while one that ended elsewhere stays lit
2782
+ * forever. A caller that refreshes on a trigger (the tab becoming visible, or
2783
+ * a bounded interval while any job is live) keeps the rows honest without a
2784
+ * socket per conversation.
2785
+ *
2786
+ * Merge rules, and why each is what it is:
2787
+ *
2788
+ * - the server's ORDER wins for the rows it returns: an updated conversation
2789
+ * moves to its new place, which is the whole point of the call;
2790
+ * - rows paged in EARLIER (`loadMoreConversations`) are kept — this reads page
2791
+ * 1 only, and dropping the tail would make a refresh silently truncate a
2792
+ * list the reader has already scrolled;
2793
+ * - a row the caller MINTED locally and the server does not know yet is kept
2794
+ * too (it has no server row to be refreshed by);
2795
+ * - `conversationsGeneration` is bumped, for the same reason `connect()`
2796
+ * bumps it: a page request already in flight was cut against the old head
2797
+ * and its offset no longer means what it did.
2798
+ */
2799
+ async refreshConversations() {
2800
+ const page = await this.client.getConversations(CONVERSATION_PAGE_SIZE, 0);
2801
+ const pageIds = new Set(page.map((c) => c.id));
2802
+ const known = new Set(this.conversations.map((c) => c.id));
2803
+ this.conversationsGeneration++;
2804
+ this.conversations = [
2805
+ ...page,
2806
+ // Everything the page did not mention: older pages, and rows that exist
2807
+ // only on this client (a minted conversation the server has not been told
2808
+ // about yet).
2809
+ ...this.conversations.filter((c) => !pageIds.has(c.id))
2810
+ ];
2811
+ for (const c of page) {
2812
+ this.serverConversationIds.add(c.id);
2813
+ known.delete(c.id);
2814
+ }
2815
+ return page;
2816
+ }
2707
2817
  async loadMoreConversations() {
2708
2818
  if (this.isLoadingConversations || !this.hasMoreConversations) return [];
2709
2819
  this.isLoadingConversations = true;
@@ -2795,7 +2905,10 @@ function planRestore(args) {
2795
2905
  messageId: msgs[i]?.id
2796
2906
  }));
2797
2907
  if (jobs.length === 0) {
2798
- return userMessages.map((m) => ({
2908
+ const held = new Set(
2909
+ (args.heldMessageIds ?? []).filter((id) => !!id)
2910
+ );
2911
+ return userMessages.filter((m) => !(m.id && held.has(m.id))).map((m) => ({
2799
2912
  kind: "steer",
2800
2913
  content: m.content,
2801
2914
  messageId: m.id
@@ -3074,7 +3187,7 @@ var StreamManager = class _StreamManager {
3074
3187
  }
3075
3188
  let tookOver = false;
3076
3189
  try {
3077
- await this.restore(conversationId, gen);
3190
+ await this.restore(conversationId, gen, opts?.have);
3078
3191
  tookOver = gen === this.generation;
3079
3192
  } finally {
3080
3193
  const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
@@ -3292,7 +3405,10 @@ var StreamManager = class _StreamManager {
3292
3405
  ...before ? { before } : {}
3293
3406
  });
3294
3407
  }
3295
- async restore(conversationId, gen) {
3408
+ async restore(conversationId, gen, have) {
3409
+ const haveByJobId = new Map(
3410
+ (have ?? []).map((h) => [h.jobId, h.lastSeq])
3411
+ );
3296
3412
  const superseded = () => gen !== this.generation;
3297
3413
  if (superseded()) return;
3298
3414
  const turn = this.turnCounter;
@@ -3319,7 +3435,8 @@ var StreamManager = class _StreamManager {
3319
3435
  gen,
3320
3436
  activeJobId,
3321
3437
  turn,
3322
- jobsRequest
3438
+ jobsRequest,
3439
+ haveByJobId
3323
3440
  ))
3324
3441
  return;
3325
3442
  if (activeJobId) {
@@ -3516,7 +3633,7 @@ var StreamManager = class _StreamManager {
3516
3633
  session.isLoadingEarlierTurns = false;
3517
3634
  }
3518
3635
  }
3519
- async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
3636
+ async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest, haveByJobId) {
3520
3637
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3521
3638
  let complete = false;
3522
3639
  try {
@@ -3524,7 +3641,7 @@ var StreamManager = class _StreamManager {
3524
3641
  const jobs = page.jobs;
3525
3642
  if (stopReplay()) return false;
3526
3643
  const replayableJobs = jobs.filter(
3527
- (j) => j.job_id !== activeJobId
3644
+ (j) => j.job_id !== activeJobId && !haveByJobId.has(j.job_id)
3528
3645
  );
3529
3646
  const userMessages = this.session.messages.filter(
3530
3647
  (m) => m.role === "user"
@@ -3558,6 +3675,12 @@ var StreamManager = class _StreamManager {
3558
3675
  // them, silently. Deriving both from `jobs` makes that impossible to
3559
3676
  // get half-right.
3560
3677
  claimedMessageIds: jobs.map((j) => j.message_id),
3678
+ // The prompts whose turn the CALLER holds (see `switchTo`'s `have`).
3679
+ // The walk below is empty for exactly those jobs — which is the
3680
+ // primary cache scenario, a SETTLED conversation whose turns are all
3681
+ // held — and the empty-walk rescue would otherwise re-emit every one of
3682
+ // them as a steer bubble.
3683
+ heldMessageIds: jobs.filter((j) => haveByJobId.has(j.job_id)).map((j) => j.message_id),
3561
3684
  userMessages: userMessages.map((m) => ({
3562
3685
  id: m.id,
3563
3686
  content: m.content