@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 +14 -0
- package/dist/extension/build-info.js +1 -1
- package/dist/history/queries.js +24 -4
- package/dist/history/worker.js +27 -6
- package/dist/pi-intercom/bridge.js +1 -1
- package/dist/tui/agent-view.js +35 -8
- package/docs/intercom-bridge.md +1 -1
- package/docs/performance.md +3 -3
- package/package.json +1 -1
- package/src/history/queries.ts +22 -4
- package/src/history/worker.ts +26 -6
- package/src/pi-intercom/bridge.ts +1 -1
- package/src/tui/agent-view.ts +31 -8
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.
|
|
1
|
+
export const EXTENSION_BUILD = Object.freeze({"version":"0.44.7","sha256":"86c62961d20949874e872f49d9044371c4ae3d0b1b18b9d3b1186c29d1522bb0"});
|
package/dist/history/queries.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 })),
|
package/dist/history/worker.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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)
|
package/dist/tui/agent-view.js
CHANGED
|
@@ -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 ||
|
|
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 (
|
|
1182
|
-
|
|
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)
|
package/docs/intercom-bridge.md
CHANGED
|
@@ -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
|
|
package/docs/performance.md
CHANGED
|
@@ -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
|
|
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
package/src/history/queries.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 })),
|
package/src/history/worker.ts
CHANGED
|
@@ -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 {
|
|
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)
|
|
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"))
|
|
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 {
|
package/src/tui/agent-view.ts
CHANGED
|
@@ -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 ||
|
|
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) {
|
|
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 (
|
|
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); }
|