@fitchmultz/pi-subagents 0.44.5 → 0.44.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.44.7] - 2026-10-09
4
+
5
+ ### Fixed
6
+ - Keep newer Agents history, including consumed human answers, from being overwritten by an older in-flight page. Recheck the owning session, index, selected attempt, source and published page before applying async history or saved-result reads; preserve requested paging and reading anchors when reloading an invalidated snapshot.
7
+ - Consume refresh notifications received while the latest conversation page is loading, without pulling scrolled readers forward or automatically retrying history errors.
8
+ - Emit the Intercom bridge `session_closed` audit record after broker disconnect completes and the closing session is removed; cancellation still rejects pending and new requests for that identity immediately.
9
+
10
+ ## [0.44.6] - 2026-10-09
11
+
12
+ ### Fixed
13
+ - Keep full Agents pages available for long native histories by sharing bounded configuration results across rows and repeated queries. Committed publication, source generations and exact terminal/time/leaf boundaries invalidate reuse; strict errors and source integrity checks remain unchanged.
14
+ - Restrict scoped saved-text search to eligible records before aggregating matches, retaining the complete corpus for global relevance scores, cross-field word matching, phrases, attempt boundaries and pagination.
15
+ - Publish one settled-freshness notification for unchanged history census work instead of one per run/source. Real commits, canonical failures and source error/recovery observations still notify; polling, census and watches remain active.
16
+
3
17
  ## [0.44.5] - 2026-10-08
4
18
 
5
19
  ### Added
@@ -1 +1 @@
1
- export const EXTENSION_BUILD = Object.freeze({"version":"0.44.5","sha256":"53f1d8cd3c71b05fdfc903bfcecd06eb5d80bb367a16a09d235199d3f93266f2"});
1
+ export const EXTENSION_BUILD = Object.freeze({"version":"0.44.7","sha256":"86c62961d20949874e872f49d9044371c4ae3d0b1b18b9d3b1186c29d1522bb0"});
@@ -7,7 +7,9 @@ import { hash, HistoryStore, integer, pageLimit } from "./store.js";
7
7
  export class HistoryQueries {
8
8
  store;
9
9
  info;
10
+ configurations = new Map();
10
11
  constructor(store, info) { this.store = store; this.info = info; }
12
+ sourcesRemoved() { this.configurations.clear(); }
11
13
  cursor(query, keys, offset) {
12
14
  return Buffer.from(JSON.stringify({ generation: this.store.generation, owner: this.store.owner, version: this.info().version, query, keys, offset })).toString("base64url");
13
15
  }
@@ -120,6 +122,13 @@ export class HistoryQueries {
120
122
  return {};
121
123
  throw error;
122
124
  }
125
+ // Only committed source changes invalidate ancestry, not unrelated run projections.
126
+ // Validate boundaries before reuse; explicit null leaves returned above remain empty.
127
+ const leafKey = input.leaf === undefined ? null : [typeof input.leaf, String(input.leaf)];
128
+ const key = JSON.stringify([source.id, source.generation, source.cursor, source.stamp, input.terminalEntryId || null, input.terminalEntryId ? null : input.endedAt ?? null, leafKey, strict]);
129
+ const cached = this.configurations.get(key);
130
+ if (cached)
131
+ return { ...cached };
123
132
  const leaf = input.leaf ?? this.store.get(`SELECT id FROM entries WHERE ${boundary.clauses.join(" AND ")} ORDER BY start DESC LIMIT 1`, ...boundary.params)?.id;
124
133
  if (!leaf)
125
134
  return {};
@@ -139,7 +148,12 @@ export class HistoryQueries {
139
148
  return {};
140
149
  throw new HistoryIndexError("MALFORMED_ANCESTRY", "Selected native configuration has cyclic ancestry.");
141
150
  }
142
- return { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
151
+ const configuration = { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
152
+ // Bound memory across arbitrary sources, boundaries and pages. Eviction only recomputes.
153
+ if (this.configurations.size >= 128)
154
+ this.configurations.delete(this.configurations.keys().next().value);
155
+ this.configurations.set(key, configuration);
156
+ return { ...configuration };
143
157
  }
144
158
  historyPage(input) {
145
159
  const { source } = this.child(input.runId, input.index);
@@ -224,6 +238,12 @@ export class HistoryQueries {
224
238
  clauses.push("c.agent=?");
225
239
  params.push(input.agent);
226
240
  }
241
+ const joins = "JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1";
242
+ // Scope records before materializing/grouping hits, while bm25 still sees the
243
+ // complete corpus. Scan each linked source once, not once per continuation.
244
+ // Attempt boundaries, attribution and seek filtering remain in the final join.
245
+ const eligible = input.runId || input.agent ? `eligible AS MATERIALIZED (SELECT e.rowid FROM entries e JOIN sources s ON s.id=e.source_id WHERE e.published=1 AND e.generation=s.generation AND e.source_id IN (SELECT c.source_id FROM children c WHERE ${clauses.slice(4).join(" AND ")})),` : "";
246
+ const eligibleParams = eligible ? params.slice(expression.length) : [];
227
247
  // Ordering/filters apply globally before limit. Common terms may still require an FTS
228
248
  // scan/sort; the parent enforces a hard deadline by killing this separate process.
229
249
  const score = sort === "relevance" ? "matched.score" : "-COALESCE(e.timestamp,0)";
@@ -235,8 +255,8 @@ export class HistoryQueries {
235
255
  }
236
256
  // Match words across all visible fields/windows of one native record. A quoted
237
257
  // phrase remains a single FTS expression, so it cannot span unrelated fields.
238
- const rows = this.store.all(`WITH hits AS MATERIALIZED (
239
- ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?`).join(" UNION ALL ")}
258
+ const rows = this.store.all(`WITH ${eligible} hits AS MATERIALIZED (
259
+ ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?${eligible ? " AND d.entry_rowid IN (SELECT rowid FROM eligible)" : ""}`).join(" UNION ALL ")}
240
260
  ), terms AS (
241
261
  SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term
242
262
  ), matched AS (
@@ -244,7 +264,7 @@ export class HistoryQueries {
244
264
  ), excerpts AS (
245
265
  SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits
246
266
  )
247
- SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...params, limit + 1);
267
+ SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid ${joins} WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...eligibleParams, ...params, limit + 1);
248
268
  const more = rows.length > limit, page = rows.slice(0, limit);
249
269
  return { ...this.info(), matches: page.map((row) => ({ id: row.document_id, runId: row.run_id, index: row.child_index, agent: row.agent, entryId: row.id, nativeId: row.native_id, sessionId: row.session_id, sessionFile: row.path, timestamp: row.timestamp,
250
270
  ref: { sourceId: row.source_id, generation: row.generation, start: row.start, end: row.end, digest: row.digest }, preview: row.document_preview, field: row.field, textStart: row.text_start, textEnd: row.text_end, score: row.score })),
@@ -18,6 +18,8 @@ const sourceWatchers = new Map();
18
18
  const watchIdentities = new WeakMap();
19
19
  const watchedSources = new Map();
20
20
  let job, scheduled = false, closed = false;
21
+ let sourceObservation;
22
+ let notifiedVersion;
21
23
  let retryTimer;
22
24
  let runErrors = new Set();
23
25
  const barriers = [];
@@ -31,7 +33,15 @@ function send(response) {
31
33
  process.send(response, undefined, undefined, (error) => { if (error)
32
34
  shutdown(); });
33
35
  }
34
- function changed() { send({ changed: true }); }
36
+ function changed(observationChanged = false) {
37
+ const version = store?.get("SELECT value FROM meta WHERE key='version'").value;
38
+ // Unchanged census projections need only one final freshness notification.
39
+ // Non-versioned errors/recovery and real commits still publish immediately.
40
+ if (observationChanged || version !== notifiedVersion || !hasPending()) {
41
+ notifiedVersion = version;
42
+ send({ changed: true });
43
+ }
44
+ }
35
45
  function errorReply(id, error) {
36
46
  const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
37
47
  // Parser errors can include fragments from the malformed body. IPC/logs must never carry them.
@@ -191,8 +201,11 @@ function prune(previousSources) {
191
201
  store.run("DELETE FROM sources WHERE id=?", source.id);
192
202
  removed = true;
193
203
  }
194
- if (removed)
204
+ if (removed) {
195
205
  store.bump();
206
+ // A later admission of the same path can recreate source generation 1.
207
+ queries?.sourcesRemoved();
208
+ }
196
209
  });
197
210
  }
198
211
  function admit(run) {
@@ -247,6 +260,7 @@ function pump() {
247
260
  if (dirtyRuns.size) {
248
261
  const id = dirtyRuns.values().next().value;
249
262
  dirtyRuns.delete(id);
263
+ const failed = runErrors.has(id);
250
264
  installWatches(id);
251
265
  const previousSources = store.all("SELECT source_id FROM children WHERE run_id=? AND source_id IS NOT NULL", id).map((child) => child.source_id);
252
266
  try {
@@ -265,7 +279,7 @@ function pump() {
265
279
  if (child.source_id !== job?.id)
266
280
  dirtySources.set(child.source_id, dirtySources.get(child.source_id) ?? false);
267
281
  installWatches(id);
268
- changed();
282
+ changed(failed !== runErrors.has(id));
269
283
  }
270
284
  else if (job) {
271
285
  if (job.step()) {
@@ -273,7 +287,8 @@ function pump() {
273
287
  if (source)
274
288
  watchSources(path.dirname(source.path));
275
289
  job = undefined;
276
- changed();
290
+ changed(source?.state !== sourceObservation?.state || source?.error !== sourceObservation?.error);
291
+ sourceObservation = undefined;
277
292
  }
278
293
  }
279
294
  else if (dirtySources.size) {
@@ -282,6 +297,7 @@ function pump() {
282
297
  const source = store.source(id);
283
298
  if (source) {
284
299
  activeSource = id;
300
+ sourceObservation = source;
285
301
  job = new SourceIngest(store, source, force);
286
302
  }
287
303
  }
@@ -302,8 +318,11 @@ function pump() {
302
318
  else
303
319
  store.run("UPDATE sources SET state='error',error=?,checked_at=? WHERE id=?", "Linked native conversation could not be indexed.", Date.now(), id);
304
320
  }
305
- if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY"))
306
- changed();
321
+ if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY")) {
322
+ const source = id ? store.source(id) : undefined;
323
+ changed(Boolean(source && (source.state !== sourceObservation?.state || source.error !== sourceObservation?.error)));
324
+ }
325
+ sourceObservation = undefined;
307
326
  }
308
327
  activeSource = undefined;
309
328
  finishBarriers();
@@ -328,6 +347,8 @@ function clear() {
328
347
  retryTimer = undefined;
329
348
  job?.close();
330
349
  job = undefined;
350
+ sourceObservation = undefined;
351
+ notifiedVersion = undefined;
331
352
  for (const watcher of watchers.values())
332
353
  watcher.close();
333
354
  watchers.clear();
@@ -295,8 +295,8 @@ class Session {
295
295
  await this.disconnect();
296
296
  if (this.bridge.sessions.get(this.identity.fingerprint256) === this)
297
297
  this.bridge.sessions.delete(this.identity.fingerprint256);
298
+ log("session_closed", { ...this.fields(), result: error.code });
298
299
  })();
299
- log("session_closed", { ...this.fields(), result: error.code });
300
300
  }
301
301
  receive(from, message) {
302
302
  if (this.closed)
@@ -315,7 +315,7 @@ export class AgentViewController {
315
315
  activity: { ...prior.child.activity, ...child.activity } } : child;
316
316
  return { key, label: child.identityUnavailable ? "Saved assignment unavailable" : sameAttempt ? agentTaskLabel(child) : prior?.label ?? agentTaskLabel(child), run: view, child: displayChild,
317
317
  model: agentModel(displayChild, child.nativeConfiguration ?? (canonical && sameAttempt && child.sessionFile === prior.child.sessionFile ? prior.page?.configuration : undefined)), history: sameAttempt ? prior.history : [], historyIds: sameAttempt ? prior.historyIds : [],
318
- page: sameAttempt ? prior.page : undefined, metadataAt: observed ? prior.metadataAt : undefined, historyLoading: sameAttempt ? prior.historyLoading : false, finalId: sameAttempt ? prior.finalId : undefined, unavailable: child.missingSession && child.state !== "live" ? "Saved conversation unavailable." : view.diagnosis,
318
+ page: sameAttempt ? prior.page : undefined, historySource: sameAttempt ? prior.historySource : undefined, metadataAt: observed ? prior.metadataAt : undefined, historyLoading: sameAttempt ? prior.historyLoading : false, finalId: sameAttempt ? prior.finalId : undefined, unavailable: child.missingSession && child.state !== "live" ? "Saved conversation unavailable." : view.diagnosis,
319
319
  question: child.identityUnavailable ? undefined : questions.findLast((question) => question.index === child.index && ["awaiting_input", "answer_pending"].includes(question.state)),
320
320
  unread: observed ? prior.unread : Boolean(visit && (child.activity?.lastActivityAt ?? view.updatedAt) > (visit.seenActivityAt ?? 0)), replied: sameAttempt ? prior.replied : false };
321
321
  });
@@ -485,7 +485,19 @@ export class AgentViewController {
485
485
  const task = this.task(key), generation = this.generation;
486
486
  if (!task || !this.live() || task.child.identityUnavailable)
487
487
  return;
488
+ const published = task.page, source = task.historySource, sessionFile = task.child.sessionFile, childState = task.child.state;
488
489
  const input = this.historyInput(task), index = await runHistoryIndex(this.state);
490
+ // Versions can reset on index reopen. Fence the actual owner/read baseline,
491
+ // including metadata-only source transitions, rather than ordering unrelated versions.
492
+ const isCurrent = () => {
493
+ const current = this.task(key);
494
+ if (!this.live(generation) || this.state.historyIndex !== index || index.failure || !current || current.child.identityUnavailable
495
+ || current.page !== published || current.historySource !== source || current.child.sessionFile !== sessionFile || current.child.state !== childState)
496
+ return false;
497
+ const boundary = this.historyInput(current);
498
+ return boundary.runId === input.runId && boundary.index === input.index && boundary.leaf === input.leaf
499
+ && boundary.terminalEntryId === input.terminalEntryId && boundary.endedAt === input.endedAt;
500
+ };
489
501
  const boundary = { runId: task.run.runId, index: task.child.index, terminalEntryId: input.terminalEntryId, endedAt: input.endedAt };
490
502
  const marker = typeof input.readThrough === "string" && !input.readThrough.startsWith("result:")
491
503
  ? await index.entry({ ...boundary, entryId: input.readThrough.replace(/:(?:\d+|error)$/, "") }) : undefined;
@@ -499,7 +511,7 @@ export class AgentViewController {
499
511
  const result = await indexedHistory(index, { ...input, limit: 100, ...paging });
500
512
  if (!this.live(generation) || this.task(key)?.run.runId !== task.run.runId)
501
513
  return;
502
- return { ...result, latestPage: paging.after !== undefined ? !result.page.hasMore : paging.before === undefined && paging.cursor === undefined,
514
+ return { ...result, isCurrent, latestPage: paging.after !== undefined ? !result.page.hasMore : paging.before === undefined && paging.cursor === undefined,
503
515
  readThroughSequence: marker?.sequence ?? (input.readThrough == null ? -1 : undefined) };
504
516
  }
505
517
  async savedResult(key, id) {
@@ -522,6 +534,7 @@ export class AgentViewController {
522
534
  }
523
535
  applyMetadata(task, page) {
524
536
  const visit = this.visits.get(task.key), delivered = new Map(page.deliveredMessages);
537
+ task.historySource = JSON.stringify([page.sourceId, page.generation, page.sessionId]);
525
538
  if (page.freshness.state !== "catching-up")
526
539
  task.metadataAt = task.run.updatedAt;
527
540
  task.model = agentModel(task.child, page.configuration);
@@ -1009,6 +1022,7 @@ export class AgentConversation extends Container {
1009
1022
  pageRequest = 0;
1010
1023
  detailRequest = 0;
1011
1024
  loadingPage = false;
1025
+ refreshPending = false;
1012
1026
  pageError;
1013
1027
  latestPage = true;
1014
1028
  readSequence = -1;
@@ -1108,6 +1122,7 @@ export class AgentConversation extends Container {
1108
1122
  if (this.closed)
1109
1123
  return;
1110
1124
  const request = ++this.pageRequest;
1125
+ this.refreshPending = false;
1111
1126
  this.loadingPage = true;
1112
1127
  this.pageError = undefined;
1113
1128
  if (this.task)
@@ -1126,11 +1141,8 @@ export class AgentConversation extends Container {
1126
1141
  result = reportPage;
1127
1142
  }
1128
1143
  let task = this.task;
1129
- if (result.readThroughSequence !== undefined)
1130
- this.readSequence = result.readThroughSequence;
1131
- this.latestPage = result.latestPage;
1132
1144
  let history = result.history;
1133
- if ((this.initialPosition || this.latestPage || history.finalId && history.entryIds.includes(history.finalId)) && task.child.state !== "live" && task.child.result && getSingleResultOutput(task.child.result)) {
1145
+ if ((this.initialPosition || result.latestPage || history.finalId && history.entryIds.includes(history.finalId)) && task.child.state !== "live" && task.child.result && getSingleResultOutput(task.child.result)) {
1134
1146
  const report = await this.controller.savedResult(this.key, `result:${task.run.runId}`);
1135
1147
  const currentTask = this.task;
1136
1148
  if (this.closed || request !== this.pageRequest || !currentTask || currentTask.run.runId !== task.run.runId)
@@ -1138,6 +1150,13 @@ export class AgentConversation extends Container {
1138
1150
  task = currentTask;
1139
1151
  history = withFinalResult(history, report.text, task.run.runId, task.run.updatedAt);
1140
1152
  }
1153
+ if (!result.isCurrent()) {
1154
+ void this.loadPage(paging, anchor);
1155
+ return;
1156
+ }
1157
+ if (result.readThroughSequence !== undefined)
1158
+ this.readSequence = result.readThroughSequence;
1159
+ this.latestPage = result.latestPage;
1141
1160
  task.history = history.items;
1142
1161
  task.historyIds = history.entryIds;
1143
1162
  task.finalId = history.finalId;
@@ -1159,6 +1178,10 @@ export class AgentConversation extends Container {
1159
1178
  this.loadingPage = false;
1160
1179
  if (this.task)
1161
1180
  this.task.historyLoading = false;
1181
+ if (this.refreshPending && !this.pageError) {
1182
+ this.refreshPending = false;
1183
+ this.refresh();
1184
+ }
1162
1185
  this.tui.requestRender();
1163
1186
  }
1164
1187
  }
@@ -1178,8 +1201,12 @@ export class AgentConversation extends Container {
1178
1201
  this.detail = this.assignment();
1179
1202
  if (!this.initialPosition && !this.scroll.isFollowingEnd)
1180
1203
  this.restoreAnchor = this.anchor();
1181
- if (!this.loadingPage && this.latestPage && this.scroll.isFollowingEnd && !this.detail && !this.pageError)
1182
- void this.loadPage();
1204
+ if (this.latestPage && this.scroll.isFollowingEnd && !this.detail && !this.pageError) {
1205
+ if (this.loadingPage)
1206
+ this.refreshPending = true;
1207
+ else
1208
+ void this.loadPage();
1209
+ }
1183
1210
  this.tui.requestRender();
1184
1211
  }
1185
1212
  syncDraft() { if (!this.closed && this.editor.getExpandedText() !== this.visit.draft)
@@ -14,7 +14,7 @@ Optional HTTPS messaging proxy for a remote agent such as **Grok Bot**. Pi-inter
14
14
 
15
15
  **Delivery/lifecycle:** one broker peer per authorized certificate, registered lazily on the first API call, with a five-minute inactivity lease. Poll inbox to maintain presence. Send returns broker acceptance, not proof a model consumed it. Ask steers the recipient and waits at most two minutes for both the exact recipient ID and exact `replyTo`. One outgoing ask per identity. Revocation/expiry/shutdown/client disconnect terminate waits. Inbox is bounded and volatile; overflow is reported, not silently described as successful receipt. Restarting the helper loses inbox contents and pending replies. Upgrade to a durable inbox journal if restart recovery is required; existing Pi recipients still save their own messages normally.
16
16
 
17
- **Audit:** JSONL metadata on stdout: request/connection identity, certificate fingerprint, operation, peer/message IDs and outcome. Message/attachment bodies and keys are not logged. Protect logs (0700 directory, 0600 files); rotate them with your normal log tooling. Never publish private config/keys or raw inbox/list output as smoke evidence.
17
+ **Audit:** JSONL metadata on stdout: request/connection identity, certificate fingerprint, operation, peer/message IDs and outcome. `session_closed` confirms broker disconnection and removal of the retiring session; cancellation still rejects requests immediately. Message/attachment bodies and keys are not logged. Protect logs (0700 directory, 0600 files); rotate them with your normal log tooling. Never publish private config/keys or raw inbox/list output as smoke evidence.
18
18
 
19
19
  ## Mac setup
20
20
 
@@ -9,10 +9,10 @@ Startup recovers saved ownership, unanswered questions and Intercom receipts in
9
9
  ### Indexed browsing and lazy Agents work
10
10
 
11
11
  - A separate Node process incrementally indexes only the current saved parent's admitted runs and linked native sources. A fresh session with no owned work starts no history process or database until it browses. No directory-wide transcript backfill runs at startup.
12
- - List filters and attention/newest/oldest ordering apply globally before a bounded page. Ordinary tool lists return 20 runs by default, at most 100; the Agents picker loads 50 runs and searches full assignments, not just shortened labels. Latest-assignment queries retain unsuperseded siblings while hiding older continuation attempts.
12
+ - List filters and attention/newest/oldest ordering apply globally before a bounded page. Ordinary tool lists return 20 runs by default, at most 100; the Agents picker loads 50 runs and searches full assignments, not just shortened labels. Latest-assignment queries retain unsuperseded siblings while hiding older continuation attempts. Native branch configuration reuses at most 128 compact results across rows and queries, keyed by physical source generation, committed cursor, observed stamp and exact terminal/time/leaf boundary. Publication (including backdated records), replacement and reset invalidate reuse; null leaves and strict missing/cyclic-boundary errors retain their original meaning.
13
13
  - The parent UI does not scan child journals on startup, ticks, filtering or pagination. Only an opened conversation loads a 100-physical-record page; components are retained for that page. Full details and contextual Reply validate selected native byte references off-thread, including paired tool results outside the current page. Explicit navigation supersedes older requests; background refreshes are coalesced and retain the settled display rather than briefly adding loading rows. Reopening the picker restores its visible filter and page.
14
14
  - Catch-up, degraded sources, loading, no matches and unavailable history are distinct states. F5 explicitly retries. Drafts, reading anchors, unread boundaries and pins survive same-parent reopen; a changed owner or abandoned overlay cannot accept late results.
15
- - Search indexes saved visible text, not thinking, tool arguments, image payloads, hidden custom data or provider fields. Terminal sequences are removed incrementally before tokenization, including sequences split across read/parser chunks. Its grammar is lexical words or one quoted phrase. Unquoted words must all occur in the same native record, across any of its visible fields/windows; quoted phrases must remain contiguous in one field. Each record contributes one excerpt per matching child attempt. Filters and attempt terminal/time boundaries apply before ranking/paging. Common terms may still require broad matching and sorting: a small result page is not a constant-work guarantee. Deadlines and cancellation stop the owned history process, which can reopen and replay committed index progress.
15
+ - Search indexes saved visible text, not thinking, tool arguments, image payloads, hidden custom data or provider fields. Terminal sequences are removed incrementally before tokenization, including sequences split across read/parser chunks. Its grammar is lexical words or one quoted phrase. Unquoted words must all occur in the same native record, across any of its visible fields/windows; quoted phrases must remain contiguous in one field. Each record contributes one excerpt per matching child attempt. Filters and attempt terminal/time boundaries apply before ranking/paging. Run/child/agent scopes restrict linked physical sources before match aggregation without changing global relevance scores. Each scoped source is scanned once, even when many continuation attempts share it; terminal/time boundaries and attempt attribution still apply before ranking/paging in the final join. Common terms still scan global FTS postings, and global searches may require broad matching and sorting: a small result page is not a constant-work guarantee. Deadlines and cancellation stop the owned history process, which can reopen and replay committed index progress.
16
16
  - Browse freshness is never authoritative. Selected controls recheck canonical owner facts before acting. Human-message delivery and a later nonempty assistant reply are separate observations. Receipt integrity and native accounting safeguards remain unchanged.
17
17
 
18
18
  ### Storage, budgets and recovery
@@ -34,7 +34,7 @@ The changed boundaries are exercised through real extension functions and regist
34
34
  - Native Intercom reload restores all 1,024 historical receipts while servicing input. Cleared-queue recovery also services input before its 100 followers finish appending, retaining each message once and one final wake-up across an in-flight reload or a cleared busy handoff. New results and explicit steers are staged before acknowledgement and handed to native steering for the next tool boundary; ordinary queued and passive arrivals wait.
35
35
  - 125 owned assignments: global filtering finds an item beyond the first two picker pages; all later pages remain reachable without UI-thread source access.
36
36
  - 65 retained runs: off-page reads/stats and migration scans are zero while displayed controls, questions, review and continuation identities remain fresh.
37
- - Warm indexed list/search queries perform no source checks, opens or canonical projections. Cold ingestion and explicit refresh still do necessary verification off-thread.
37
+ - Warm indexed list/search queries perform no source checks, opens or canonical projections. Cold ingestion and explicit refresh still do necessary verification off-thread. Fifty genuinely admitted live rows sharing an 80,004-entry native journal retain correct state, model, thinking and bounded history through the real worker's unchanged three-second deadline under a 96 MiB JavaScript heap; completed terminal branches remain correct. An unchanged 1,000-run native census keeps projecting all handles but emits only settled freshness, while canonical changes and source failure/recovery still notify. This removes redundant notifications, not the census or UI polling.
38
38
  - A 512 MiB discarded native field preserves exact eligible usage/IDs/configuration/history, with unchanged source bytes, under a **96 MiB** heap limit. Structural over-budget image/deep/key records degrade explicitly while later visible text remains searchable.
39
39
  - Completed-attempt search cannot return a successor's later text from a shared physical source. Full selected details reject missing/replaced/truncated sources; canonical output reads reject concurrent growth beyond their snapshot.
40
40
  - Competing history processes preserve staged configuration/text on normal completion and recover after a writer is killed. Schema-4 indexes with intact cursors but missing entries rebuild from unchanged journals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-subagents",
3
- "version": "0.44.5",
3
+ "version": "0.44.7",
4
4
  "description": "Pi extension for subagent orchestration and local session coordination",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,9 @@ interface Cursor { generation: string; owner: string; version: number; query: st
11
11
  export class HistoryQueries {
12
12
  private store: HistoryStore;
13
13
  private info: () => HistoryVersion;
14
+ private configurations = new Map<string, HistoryConfiguration>();
14
15
  constructor(store: HistoryStore, info: () => HistoryVersion) { this.store = store; this.info = info; }
16
+ sourcesRemoved(): void { this.configurations.clear(); }
15
17
  private cursor(query: string, keys: (string | number)[], offset: number): string {
16
18
  return Buffer.from(JSON.stringify({ generation: this.store.generation, owner: this.store.owner, version: this.info().version, query, keys, offset } satisfies Cursor)).toString("base64url");
17
19
  }
@@ -80,6 +82,12 @@ export class HistoryQueries {
80
82
  if (input.leaf === null) return {};
81
83
  let boundary: ReturnType<HistoryQueries["boundary"]>;
82
84
  try { boundary = this.boundary(source, input); } catch (error) { if (!strict && error instanceof HistoryIndexError && error.code === "BOUNDARY_UNAVAILABLE") return {}; throw error; }
85
+ // Only committed source changes invalidate ancestry, not unrelated run projections.
86
+ // Validate boundaries before reuse; explicit null leaves returned above remain empty.
87
+ const leafKey = input.leaf === undefined ? null : [typeof input.leaf, String(input.leaf)];
88
+ const key = JSON.stringify([source.id, source.generation, source.cursor, source.stamp, input.terminalEntryId || null, input.terminalEntryId ? null : input.endedAt ?? null, leafKey, strict]);
89
+ const cached = this.configurations.get(key);
90
+ if (cached) return { ...cached };
83
91
  const leaf = input.leaf ?? this.store.get(`SELECT id FROM entries WHERE ${boundary.clauses.join(" AND ")} ORDER BY start DESC LIMIT 1`, ...boundary.params)?.id;
84
92
  if (!leaf) return {};
85
93
  // Fix recursive loop order: look up each child's parent by ID instead of
@@ -93,7 +101,11 @@ export class HistoryQueries {
93
101
  (SELECT configuration_thinking FROM branch WHERE configuration_thinking IS NOT NULL ORDER BY start DESC LIMIT 1) AS thinking`, ...boundary.params, leaf);
94
102
  if (!facts.count && input.leaf && strict) throw new HistoryIndexError("BOUNDARY_UNAVAILABLE", "Selected configuration leaf is not available in the indexed generation.");
95
103
  if (facts.count && !facts.rooted) { if (!strict) return {}; throw new HistoryIndexError("MALFORMED_ANCESTRY", "Selected native configuration has cyclic ancestry."); }
96
- return { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
104
+ const configuration = { ...(facts.model ? { model: facts.model, modelRecordedAt: facts.recorded_at ?? undefined } : {}), ...(facts.thinking ? { thinking: facts.thinking } : {}) };
105
+ // Bound memory across arbitrary sources, boundaries and pages. Eviction only recomputes.
106
+ if (this.configurations.size >= 128) this.configurations.delete(this.configurations.keys().next().value!);
107
+ this.configurations.set(key, configuration);
108
+ return { ...configuration };
97
109
  }
98
110
  historyPage(input: HistoryPageInput): HistoryPage {
99
111
  const { source } = this.child(input.runId, input.index);
@@ -149,6 +161,12 @@ export class HistoryQueries {
149
161
  if (input.runId) { clauses.push("c.run_id=?"); params.push(input.runId); }
150
162
  if (input.index !== undefined) { clauses.push("c.child_index=?"); params.push(input.index); }
151
163
  if (input.agent) { if (input.agent.length > 256) throw new HistoryIndexError("INVALID", "Agent filter is too long."); clauses.push("c.agent=?"); params.push(input.agent); }
164
+ const joins = "JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1";
165
+ // Scope records before materializing/grouping hits, while bm25 still sees the
166
+ // complete corpus. Scan each linked source once, not once per continuation.
167
+ // Attempt boundaries, attribution and seek filtering remain in the final join.
168
+ const eligible = input.runId || input.agent ? `eligible AS MATERIALIZED (SELECT e.rowid FROM entries e JOIN sources s ON s.id=e.source_id WHERE e.published=1 AND e.generation=s.generation AND e.source_id IN (SELECT c.source_id FROM children c WHERE ${clauses.slice(4).join(" AND ")})),` : "";
169
+ const eligibleParams = eligible ? params.slice(expression.length) : [];
152
170
  // Ordering/filters apply globally before limit. Common terms may still require an FTS
153
171
  // scan/sort; the parent enforces a hard deadline by killing this separate process.
154
172
  const score = sort === "relevance" ? "matched.score" : "-COALESCE(e.timestamp,0)";
@@ -158,8 +176,8 @@ export class HistoryQueries {
158
176
  }
159
177
  // Match words across all visible fields/windows of one native record. A quoted
160
178
  // phrase remains a single FTS expression, so it cannot span unrelated fields.
161
- const rows = this.store.all(`WITH hits AS MATERIALIZED (
162
- ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?`).join(" UNION ALL ")}
179
+ const rows = this.store.all(`WITH ${eligible} hits AS MATERIALIZED (
180
+ ${expression.map((_term, index) => `SELECT d.id,d.entry_rowid,bm25(corpus) AS score,${index} AS term FROM corpus JOIN documents d ON d.id=corpus.rowid WHERE corpus MATCH ?${eligible ? " AND d.entry_rowid IN (SELECT rowid FROM eligible)" : ""}`).join(" UNION ALL ")}
163
181
  ), terms AS (
164
182
  SELECT entry_rowid,term,MIN(score) AS score FROM hits GROUP BY entry_rowid,term
165
183
  ), matched AS (
@@ -167,7 +185,7 @@ export class HistoryQueries {
167
185
  ), excerpts AS (
168
186
  SELECT id,entry_rowid,ROW_NUMBER() OVER (PARTITION BY entry_rowid ORDER BY score,id) AS choice FROM hits
169
187
  )
170
- SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid JOIN sources s ON s.id=e.source_id JOIN children c ON c.source_id=s.id LEFT JOIN entries terminal ON terminal.source_id=s.id AND terminal.generation=s.generation AND terminal.id=c.terminal_entry_id AND terminal.published=1 WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...params, limit + 1);
188
+ SELECT d.*,${score} AS score,e.*,d.id AS document_id,s.path,s.session_id,c.run_id,c.child_index,c.agent,d.preview AS document_preview FROM matched JOIN excerpts ON excerpts.entry_rowid=matched.entry_rowid AND excerpts.choice=1 JOIN documents d ON d.id=excerpts.id JOIN entries e ON e.rowid=matched.entry_rowid ${joins} WHERE ${clauses.join(" AND ")} ORDER BY score,d.id,c.run_id,c.child_index LIMIT ?`, ...eligibleParams, ...params, limit + 1);
171
189
  const more = rows.length > limit, page = rows.slice(0, limit);
172
190
  return { ...this.info(), matches: page.map((row) => ({ id: row.document_id, runId: row.run_id, index: row.child_index, agent: row.agent, entryId: row.id, nativeId: row.native_id, sessionId: row.session_id, sessionFile: row.path, timestamp: row.timestamp,
173
191
  ref: { sourceId: row.source_id, generation: row.generation, start: row.start, end: row.end, digest: row.digest }, preview: row.document_preview, field: row.field, textStart: row.text_start, textEnd: row.text_end, score: row.score })),
@@ -21,6 +21,8 @@ const sourceWatchers = new Map<string, fs.FSWatcher>();
21
21
  const watchIdentities = new WeakMap<fs.FSWatcher, string>();
22
22
  const watchedSources = new Map<string, Map<string, string>>();
23
23
  let job: SourceIngest | undefined, scheduled = false, closed = false;
24
+ let sourceObservation: { state: string; error: string | null } | undefined;
25
+ let notifiedVersion: string | undefined;
24
26
  let retryTimer: ReturnType<typeof setTimeout> | undefined;
25
27
  let runErrors = new Set<string>();
26
28
  const barriers: Array<{ id: number; runId?: string }> = [];
@@ -32,7 +34,14 @@ function send(response: Response): void {
32
34
  // The parent can disconnect after process.connected was read; then this and later replies are undeliverable, so stop as on disconnect.
33
35
  if (process.connected) process.send!(response, undefined, undefined, (error) => { if (error) shutdown(); });
34
36
  }
35
- function changed(): void { send({ changed: true }); }
37
+ function changed(observationChanged = false): void {
38
+ const version = store?.get("SELECT value FROM meta WHERE key='version'").value;
39
+ // Unchanged census projections need only one final freshness notification.
40
+ // Non-versioned errors/recovery and real commits still publish immediately.
41
+ if (observationChanged || version !== notifiedVersion || !hasPending()) {
42
+ notifiedVersion = version; send({ changed: true });
43
+ }
44
+ }
36
45
  function errorReply(id: number, error: unknown): void {
37
46
  const code = error instanceof HistoryIndexError ? error.code : "UNAVAILABLE";
38
47
  // Parser errors can include fragments from the malformed body. IPC/logs must never carry them.
@@ -164,7 +173,11 @@ function prune(previousSources?: string[]): void {
164
173
  store!.deleteDocuments(source.id);
165
174
  store!.run("DELETE FROM entries WHERE source_id=?", source.id); store!.run("DELETE FROM sources WHERE id=?", source.id); removed = true;
166
175
  }
167
- if (removed) store!.bump();
176
+ if (removed) {
177
+ store!.bump();
178
+ // A later admission of the same path can recreate source generation 1.
179
+ queries?.sourcesRemoved();
180
+ }
168
181
  });
169
182
  }
170
183
  function admit(run: OwnedRun): void {
@@ -201,23 +214,25 @@ function pump(): void {
201
214
  try {
202
215
  if (dirtyRuns.size) {
203
216
  const id = dirtyRuns.values().next().value!; dirtyRuns.delete(id);
217
+ const failed = runErrors.has(id);
204
218
  installWatches(id);
205
219
  const previousSources = store.all("SELECT source_id FROM children WHERE run_id=? AND source_id IS NOT NULL", id).map((child) => child.source_id);
206
220
  try { store.operations.runProjections++; const view = projection(runs.get(id)!); store.putView(compactView(view), view); runErrors.delete(id); }
207
221
  catch { const view = unknown(runs.get(id)!, "Canonical owner summary is unavailable. Completion remains unconfirmed."); store.putView(compactView(view), view); runErrors.add(id); }
208
222
  prune(previousSources);
209
223
  for (const child of store.all("SELECT source_id FROM children WHERE run_id=? AND source_id IS NOT NULL", id)) if (child.source_id !== job?.id) dirtySources.set(child.source_id, dirtySources.get(child.source_id) ?? false);
210
- installWatches(id); changed();
224
+ installWatches(id); changed(failed !== runErrors.has(id));
211
225
  } else if (job) {
212
226
  if (job.step()) {
213
227
  const source = store.source(job.id);
214
228
  if (source) watchSources(path.dirname(source.path));
215
- job = undefined; changed();
229
+ job = undefined; changed(source?.state !== sourceObservation?.state || source?.error !== sourceObservation?.error);
230
+ sourceObservation = undefined;
216
231
  }
217
232
  } else if (dirtySources.size) {
218
233
  const [id, force] = dirtySources.entries().next().value!; dirtySources.delete(id);
219
234
  const source = store.source(id);
220
- if (source) { activeSource = id; job = new SourceIngest(store, source, force); }
235
+ if (source) { activeSource = id; sourceObservation = source; job = new SourceIngest(store, source, force); }
221
236
  }
222
237
  } catch (error) {
223
238
  const id = job?.id ?? activeSource;
@@ -230,7 +245,11 @@ function pump(): void {
230
245
  else if ((error as NodeJS.ErrnoException).code === "ENOENT") store.resetSource(id, "missing", "Linked native conversation is missing.");
231
246
  else store.run("UPDATE sources SET state='error',error=?,checked_at=? WHERE id=?", "Linked native conversation could not be indexed.", Date.now(), id);
232
247
  }
233
- if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY")) changed();
248
+ if (!(error instanceof HistoryIndexError && error.code === "INDEX_BUSY")) {
249
+ const source = id ? store.source(id) : undefined;
250
+ changed(Boolean(source && (source.state !== sourceObservation?.state || source.error !== sourceObservation?.error)));
251
+ }
252
+ sourceObservation = undefined;
234
253
  }
235
254
  activeSource = undefined;
236
255
  finishBarriers();
@@ -247,6 +266,7 @@ const timer = setInterval(census, 30_000); timer.unref();
247
266
  function clear(): void {
248
267
  clearTimeout(retryTimer); retryTimer = undefined;
249
268
  job?.close(); job = undefined;
269
+ sourceObservation = undefined; notifiedVersion = undefined;
250
270
  for (const watcher of watchers.values()) watcher.close(); watchers.clear(); watchedSources.clear();
251
271
  for (const watcher of sourceWatchers.values()) watcher.close(); sourceWatchers.clear();
252
272
  dirtyRuns.clear(); dirtySources.clear(); runs.clear(); foreground.clear(); runErrors = new Set();
@@ -287,8 +287,8 @@ class Session {
287
287
  try { await this.connecting; } catch { /* The original request receives the connect failure. */ }
288
288
  await this.disconnect();
289
289
  if (this.bridge.sessions.get(this.identity.fingerprint256) === this) this.bridge.sessions.delete(this.identity.fingerprint256);
290
+ log("session_closed", { ...this.fields(), result: error.code });
290
291
  })();
291
- log("session_closed", { ...this.fields(), result: error.code });
292
292
  }
293
293
 
294
294
  private receive(from: SessionInfo, message: Message): void {
@@ -70,6 +70,7 @@ export interface AgentTask {
70
70
  replied: boolean;
71
71
  page?: HistoryPage;
72
72
  historyLoading?: boolean;
73
+ historySource?: string;
73
74
  metadataAt?: number;
74
75
  }
75
76
 
@@ -332,7 +333,7 @@ export class AgentViewController {
332
333
  activity: { ...prior.child.activity, ...child.activity } } : child;
333
334
  return { key, label: child.identityUnavailable ? "Saved assignment unavailable" : sameAttempt ? agentTaskLabel(child) : prior?.label ?? agentTaskLabel(child), run: view, child: displayChild,
334
335
  model: agentModel(displayChild, child.nativeConfiguration ?? (canonical && sameAttempt && child.sessionFile === prior.child.sessionFile ? prior.page?.configuration : undefined)), history: sameAttempt ? prior.history : [], historyIds: sameAttempt ? prior.historyIds : [],
335
- page: sameAttempt ? prior.page : undefined, metadataAt: observed ? prior.metadataAt : undefined, historyLoading: sameAttempt ? prior.historyLoading : false, finalId: sameAttempt ? prior.finalId : undefined, unavailable: child.missingSession && child.state !== "live" ? "Saved conversation unavailable." : view.diagnosis,
336
+ page: sameAttempt ? prior.page : undefined, historySource: sameAttempt ? prior.historySource : undefined, metadataAt: observed ? prior.metadataAt : undefined, historyLoading: sameAttempt ? prior.historyLoading : false, finalId: sameAttempt ? prior.finalId : undefined, unavailable: child.missingSession && child.state !== "live" ? "Saved conversation unavailable." : view.diagnosis,
336
337
  question: child.identityUnavailable ? undefined : questions.findLast((question) => question.index === child.index && ["awaiting_input", "answer_pending"].includes(question.state)),
337
338
  unread: observed ? prior.unread : Boolean(visit && (child.activity?.lastActivityAt ?? view.updatedAt) > (visit.seenActivityAt ?? 0)), replied: sameAttempt ? prior.replied : false };
338
339
  });
@@ -457,10 +458,21 @@ export class AgentViewController {
457
458
  if (this.live(generation)) await this.refresh();
458
459
  }
459
460
 
460
- async historyPage(key: string, paging: Pick<HistoryPageInput, "before" | "after" | "cursor"> = {}, anchor?: string | null): Promise<{ history: AgentHistory; page: HistoryPage; latestPage: boolean; readThroughSequence?: number } | undefined> {
461
+ async historyPage(key: string, paging: Pick<HistoryPageInput, "before" | "after" | "cursor"> = {}, anchor?: string | null): Promise<{ history: AgentHistory; page: HistoryPage; latestPage: boolean; readThroughSequence?: number; isCurrent: () => boolean } | undefined> {
461
462
  const task = this.task(key), generation = this.generation;
462
463
  if (!task || !this.live() || task.child.identityUnavailable) return;
464
+ const published = task.page, source = task.historySource, sessionFile = task.child.sessionFile, childState = task.child.state;
463
465
  const input = this.historyInput(task), index = await runHistoryIndex(this.state);
466
+ // Versions can reset on index reopen. Fence the actual owner/read baseline,
467
+ // including metadata-only source transitions, rather than ordering unrelated versions.
468
+ const isCurrent = () => {
469
+ const current = this.task(key);
470
+ if (!this.live(generation) || this.state.historyIndex !== index || index.failure || !current || current.child.identityUnavailable
471
+ || current.page !== published || current.historySource !== source || current.child.sessionFile !== sessionFile || current.child.state !== childState) return false;
472
+ const boundary = this.historyInput(current);
473
+ return boundary.runId === input.runId && boundary.index === input.index && boundary.leaf === input.leaf
474
+ && boundary.terminalEntryId === input.terminalEntryId && boundary.endedAt === input.endedAt;
475
+ };
464
476
  const boundary = { runId: task.run.runId, index: task.child.index, terminalEntryId: input.terminalEntryId, endedAt: input.endedAt };
465
477
  const marker = typeof input.readThrough === "string" && !input.readThrough.startsWith("result:")
466
478
  ? await index.entry({ ...boundary, entryId: input.readThrough.replace(/:(?:\d+|error)$/, "") }) : undefined;
@@ -471,7 +483,7 @@ export class AgentViewController {
471
483
  }
472
484
  const result = await indexedHistory(index, { ...input, limit: 100, ...paging });
473
485
  if (!this.live(generation) || this.task(key)?.run.runId !== task.run.runId) return;
474
- return { ...result, latestPage: paging.after !== undefined ? !result.page.hasMore : paging.before === undefined && paging.cursor === undefined,
486
+ return { ...result, isCurrent, latestPage: paging.after !== undefined ? !result.page.hasMore : paging.before === undefined && paging.cursor === undefined,
475
487
  readThroughSequence: marker?.sequence ?? (input.readThrough == null ? -1 : undefined) };
476
488
  }
477
489
 
@@ -494,6 +506,7 @@ export class AgentViewController {
494
506
 
495
507
  applyMetadata(task: AgentTask, page: HistoryPage): void {
496
508
  const visit = this.visits.get(task.key), delivered = new Map(page.deliveredMessages);
509
+ task.historySource = JSON.stringify([page.sourceId, page.generation, page.sessionId]);
497
510
  if (page.freshness.state !== "catching-up") task.metadataAt = task.run.updatedAt;
498
511
  task.model = agentModel(task.child, page.configuration);
499
512
  task.unread = Boolean(page.unreadAfter || visit && (task.child.activity?.lastActivityAt ?? 0) > (visit.seenActivityAt ?? 0));
@@ -865,6 +878,7 @@ export class AgentConversation extends Container {
865
878
  private pageRequest = 0;
866
879
  private detailRequest = 0;
867
880
  private loadingPage = false;
881
+ private refreshPending = false;
868
882
  private pageError?: string;
869
883
  private latestPage = true;
870
884
  private readSequence = -1;
@@ -953,6 +967,7 @@ export class AgentConversation extends Container {
953
967
  private async loadPage(paging: Pick<HistoryPageInput, "before" | "after" | "cursor"> = {}, anchor?: string | null): Promise<void> {
954
968
  if (this.closed) return;
955
969
  const request = ++this.pageRequest;
970
+ this.refreshPending = false;
956
971
  this.loadingPage = true; this.pageError = undefined;
957
972
  if (this.task) this.task.historyLoading = true;
958
973
  this.latestPage = paging.before === undefined && paging.after === undefined && paging.cursor === undefined;
@@ -966,16 +981,17 @@ export class AgentConversation extends Container {
966
981
  if (reportPage) result = reportPage;
967
982
  }
968
983
  let task = this.task;
969
- if (result.readThroughSequence !== undefined) this.readSequence = result.readThroughSequence;
970
- this.latestPage = result.latestPage;
971
984
  let history = result.history;
972
- if ((this.initialPosition || this.latestPage || history.finalId && history.entryIds.includes(history.finalId)) && task.child.state !== "live" && task.child.result && getSingleResultOutput(task.child.result)) {
985
+ if ((this.initialPosition || result.latestPage || history.finalId && history.entryIds.includes(history.finalId)) && task.child.state !== "live" && task.child.result && getSingleResultOutput(task.child.result)) {
973
986
  const report = await this.controller.savedResult(this.key, `result:${task.run.runId}`);
974
987
  const currentTask = this.task;
975
988
  if (this.closed || request !== this.pageRequest || !currentTask || currentTask.run.runId !== task.run.runId) return;
976
989
  task = currentTask;
977
990
  history = withFinalResult(history, report.text, task.run.runId, task.run.updatedAt);
978
991
  }
992
+ if (!result.isCurrent()) { void this.loadPage(paging, anchor); return; }
993
+ if (result.readThroughSequence !== undefined) this.readSequence = result.readThroughSequence;
994
+ this.latestPage = result.latestPage;
979
995
  task.history = history.items; task.historyIds = history.entryIds; task.finalId = history.finalId; task.page = result.page;
980
996
  for (const item of task.history) if (item.id === `result:${task.run.runId}`) item.load = () => this.controller.savedResult(this.key, item.id);
981
997
  this.seenSource ||= result.page.count > 0;
@@ -985,7 +1001,11 @@ export class AgentConversation extends Container {
985
1001
  } catch (error) {
986
1002
  if (!this.closed && request === this.pageRequest) this.pageError = `History unavailable: ${error instanceof Error ? error.message : String(error)}. F5 retries the selected run.`;
987
1003
  } finally {
988
- if (!this.closed && request === this.pageRequest) { this.loadingPage = false; if (this.task) this.task.historyLoading = false; this.tui.requestRender(); }
1004
+ if (!this.closed && request === this.pageRequest) {
1005
+ this.loadingPage = false; if (this.task) this.task.historyLoading = false;
1006
+ if (this.refreshPending && !this.pageError) { this.refreshPending = false; this.refresh(); }
1007
+ this.tui.requestRender();
1008
+ }
989
1009
  }
990
1010
  }
991
1011
 
@@ -1001,7 +1021,10 @@ export class AgentConversation extends Container {
1001
1021
  if (this.closed) return;
1002
1022
  if (this.detail?.id === "assignment" && this.task) this.detail = this.assignment();
1003
1023
  if (!this.initialPosition && !this.scroll.isFollowingEnd) this.restoreAnchor = this.anchor();
1004
- if (!this.loadingPage && this.latestPage && this.scroll.isFollowingEnd && !this.detail && !this.pageError) void this.loadPage();
1024
+ if (this.latestPage && this.scroll.isFollowingEnd && !this.detail && !this.pageError) {
1025
+ if (this.loadingPage) this.refreshPending = true;
1026
+ else void this.loadPage();
1027
+ }
1005
1028
  this.tui.requestRender();
1006
1029
  }
1007
1030
  syncDraft(): void { if (!this.closed && this.editor.getExpandedText() !== this.visit.draft) this.editor.setText(this.visit.draft); }