@itookit/dsht 0.3.8 → 0.5.2
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/README.i18n.yaml +2 -2
- package/README.md +33 -12
- package/README.zh.md +35 -14
- package/dist/catalog/controller.d.ts +26 -6
- package/dist/catalog/controller.js +73 -45
- package/dist/catalog/index.d.ts +1 -0
- package/dist/cli/dsht.js +206 -18
- package/dist/cli/startup.d.ts +40 -0
- package/dist/cli/startup.js +314 -0
- package/dist/cli/trace-summary.d.ts +78 -0
- package/dist/cli/trace-summary.js +241 -0
- package/dist/cli/verifier.d.ts +64 -0
- package/dist/cli/verifier.js +265 -0
- package/dist/contracts.d.ts +359 -0
- package/dist/contracts.js +1 -0
- package/dist/controller/commands.d.ts +47 -0
- package/dist/controller/commands.js +322 -0
- package/dist/controller/connection-streams.d.ts +22 -0
- package/dist/controller/connection-streams.js +105 -0
- package/dist/controller/connection.d.ts +24 -31
- package/dist/controller/connection.js +48 -111
- package/dist/controller/controller.d.ts +412 -178
- package/dist/controller/controller.js +713 -167
- package/dist/controller/foreground.d.ts +44 -0
- package/dist/controller/foreground.js +79 -0
- package/dist/controller/index.d.ts +8 -1
- package/dist/controller/index.js +5 -0
- package/dist/controller/loop-contract.d.ts +136 -0
- package/dist/controller/loop-contract.js +308 -0
- package/dist/controller/loop-coordinator.d.ts +48 -0
- package/dist/controller/loop-coordinator.js +647 -0
- package/dist/controller/loop-prompts-schema.d.ts +56 -0
- package/dist/controller/loop-prompts-schema.js +144 -0
- package/dist/controller/loop-prompts.d.ts +55 -0
- package/dist/controller/loop-prompts.generated.d.ts +104 -0
- package/dist/controller/loop-prompts.generated.js +185 -0
- package/dist/controller/loop-prompts.js +104 -0
- package/dist/controller/loop-protocols.d.ts +39 -0
- package/dist/controller/loop-protocols.js +115 -0
- package/dist/controller/loop.d.ts +275 -0
- package/dist/controller/loop.js +378 -0
- package/dist/controller/prompts.d.ts +54 -0
- package/dist/controller/prompts.js +162 -0
- package/dist/controller/trace-log.d.ts +45 -0
- package/dist/controller/trace-log.js +144 -0
- package/dist/controller/verifier.d.ts +130 -0
- package/dist/controller/verifier.js +75 -0
- package/dist/cost/controller.d.ts +1 -1
- package/dist/cost/controller.js +12 -5
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger.d.ts +0 -1
- package/dist/cost/ledger.js +0 -1
- package/dist/cost/scanner.js +1 -0
- package/dist/json.d.ts +18 -0
- package/dist/json.js +19 -0
- package/dist/references.d.ts +25 -0
- package/dist/references.js +26 -0
- package/dist/session/connection-view.d.ts +2 -11
- package/dist/session/controller.d.ts +94 -105
- package/dist/session/controller.js +262 -536
- package/dist/session/history-reader.d.ts +32 -0
- package/dist/session/history-reader.js +170 -0
- package/dist/session/history.d.ts +6 -18
- package/dist/session/history.js +1 -24
- package/dist/session/index.d.ts +9 -4
- package/dist/session/index.js +7 -3
- package/dist/session/info.d.ts +20 -82
- package/dist/session/info.js +52 -25
- package/dist/session/interactions.d.ts +26 -0
- package/dist/session/interactions.js +75 -0
- package/dist/session/markdown.js +1 -1
- package/dist/session/math.js +1 -1
- package/dist/session/mutation-gate.d.ts +51 -0
- package/dist/session/mutation-gate.js +73 -0
- package/dist/session/navigation.d.ts +2 -89
- package/dist/session/navigation.js +2 -129
- package/dist/session/navigator.d.ts +47 -0
- package/dist/session/navigator.js +158 -0
- package/dist/session/peek.d.ts +38 -0
- package/dist/session/peek.js +103 -0
- package/dist/session/prompt-backfill.d.ts +23 -0
- package/dist/session/prompt-backfill.js +88 -0
- package/dist/session/references.d.ts +2 -20
- package/dist/session/references.js +1 -26
- package/dist/session/runtime.d.ts +26 -0
- package/dist/session/runtime.js +28 -0
- package/dist/session/state.d.ts +20 -0
- package/dist/session/state.js +1 -0
- package/dist/session/telemetry.d.ts +25 -17
- package/dist/session/telemetry.js +66 -60
- package/dist/session/transcript.d.ts +5 -7
- package/dist/session/transcript.js +2 -15
- package/dist/session/types.d.ts +25 -0
- package/dist/session/types.js +0 -1
- package/dist/session-title.d.ts +9 -0
- package/dist/session-title.js +21 -0
- package/dist/shell/controller.d.ts +31 -1
- package/dist/shell/controller.js +34 -2
- package/dist/shell/index.d.ts +3 -3
- package/dist/shell/index.js +2 -2
- package/dist/shell/runner.d.ts +10 -0
- package/dist/shell/runner.js +48 -9
- package/dist/slash/index.d.ts +10 -0
- package/dist/slash/index.js +7 -0
- package/dist/slash/parse.d.ts +42 -0
- package/dist/slash/parse.js +259 -0
- package/dist/slash/pipeline.d.ts +140 -0
- package/dist/slash/pipeline.js +115 -0
- package/dist/slash/registry.d.ts +88 -0
- package/dist/slash/registry.js +177 -0
- package/dist/slash/types.d.ts +126 -0
- package/dist/slash/types.js +1 -0
- package/dist/state.d.ts +16 -18
- package/dist/state.js +4 -3
- package/dist/text.d.ts +28 -0
- package/dist/text.js +55 -0
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/transport/events.d.ts +104 -0
- package/dist/transport/events.js +149 -0
- package/dist/transport/wire.d.ts +9 -17
- package/dist/transport/wire.js +2 -27
- package/dist/ui/app.js +750 -550
- package/dist/ui/chat/header.js +1 -1
- package/dist/ui/chat/history-view.d.ts +1 -1
- package/dist/ui/chat/loop-status.d.ts +11 -0
- package/dist/ui/chat/loop-status.js +28 -0
- package/dist/ui/chat/navigation-model.d.ts +86 -0
- package/dist/ui/chat/navigation-model.js +107 -0
- package/dist/ui/chat/shell-view.d.ts +17 -2
- package/dist/ui/chat/shell-view.js +45 -3
- package/dist/ui/chat/status.d.ts +47 -3
- package/dist/ui/chat/status.js +65 -50
- package/dist/ui/chat/use-history-view.d.ts +69 -0
- package/dist/ui/chat/use-history-view.js +123 -0
- package/dist/ui/chat/viewport.d.ts +1 -1
- package/dist/ui/dialogs/cost.d.ts +21 -4
- package/dist/ui/dialogs/cost.js +7 -12
- package/dist/ui/dialogs/index.d.ts +22 -5
- package/dist/ui/dialogs/index.js +19 -3
- package/dist/ui/dialogs/loop.d.ts +43 -0
- package/dist/ui/dialogs/loop.js +224 -0
- package/dist/ui/dialogs/peek.d.ts +25 -0
- package/dist/ui/dialogs/peek.js +35 -0
- package/dist/ui/dialogs/picker.d.ts +2 -0
- package/dist/ui/dialogs/picker.js +4 -2
- package/dist/ui/dialogs/use-panels.d.ts +53 -0
- package/dist/ui/dialogs/use-panels.js +51 -0
- package/dist/ui/input/mouse.d.ts +12 -2
- package/dist/ui/input/mouse.js +20 -7
- package/dist/ui/input/references.d.ts +1 -1
- package/dist/ui/input/use-composer.d.ts +35 -0
- package/dist/ui/input/use-composer.js +109 -0
- package/dist/ui/input/use-deferred-lines.d.ts +16 -0
- package/dist/ui/input/use-deferred-lines.js +54 -0
- package/dist/ui/input/use-history-recall.d.ts +20 -0
- package/dist/ui/input/use-history-recall.js +47 -0
- package/dist/ui/status/model.d.ts +7 -0
- package/dist/ui/status/model.js +5 -0
- package/dist/ui/theme/index.d.ts +1 -1
- package/package.json +6 -4
- package/dist/ui/commands/parse.d.ts +0 -104
- package/dist/ui/commands/parse.js +0 -135
- package/dist/ui/commands/registry.d.ts +0 -33
- package/dist/ui/commands/registry.js +0 -73
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
/** Session domain: selection, the follow stream, history, interaction and navigation. */
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
3
|
import { array, errorText, object, string } from "../transport/wire.js";
|
|
5
4
|
import { saveSessionLog } from "./export.js";
|
|
6
5
|
import { saveTranscriptHtml } from "./export-html.js";
|
|
7
|
-
import { releaseHistoryLayout } from "./history.js";
|
|
8
|
-
import { resolveTarget
|
|
6
|
+
import { historyLayout, releaseHistoryLayout } from "./history.js";
|
|
7
|
+
import { resolveTarget } from "./navigation.js";
|
|
8
|
+
import { sessionLabel } from "../session-title.js";
|
|
9
9
|
import { fileReferences } from "./references.js";
|
|
10
|
+
import { SessionRuntime } from "./runtime.js";
|
|
10
11
|
import { PromptCache } from "./info.js";
|
|
11
|
-
import {
|
|
12
|
+
import { SessionMutationGate } from "./mutation-gate.js";
|
|
13
|
+
import { recordPrompts } from "./transcript.js";
|
|
14
|
+
import { projectionSnapshot } from "../transport/events.js";
|
|
15
|
+
import { HistoryReader } from "./history-reader.js";
|
|
16
|
+
import { SessionInteractions } from "./interactions.js";
|
|
17
|
+
import { SessionNavigator } from "./navigator.js";
|
|
18
|
+
import { PromptBackfill } from "./prompt-backfill.js";
|
|
12
19
|
/** Built-in preset identifiers and the labels the web session header shows. */
|
|
13
20
|
const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
14
|
-
/** Bounded match count for one history search. */
|
|
15
|
-
const SEARCH_MATCH_LIMIT = 200;
|
|
16
|
-
/** Bounded page count for the prompt backfill that runs once per opened session. */
|
|
17
|
-
const PROMPT_BACKFILL_PAGES = 200;
|
|
18
21
|
/** Owns the selected session: its follow stream, transcript, history window and interactions. */
|
|
19
22
|
export class SessionController {
|
|
20
23
|
store;
|
|
@@ -22,27 +25,69 @@ export class SessionController {
|
|
|
22
25
|
connection;
|
|
23
26
|
historyLimits;
|
|
24
27
|
follow;
|
|
25
|
-
|
|
28
|
+
history;
|
|
29
|
+
interactions;
|
|
30
|
+
navigation;
|
|
31
|
+
/** Reading protection: reclamation pauses while the reader is away from the live end. */
|
|
32
|
+
historyPinned = false;
|
|
33
|
+
/** Host runtime mirrors for every session this connection has seen. */
|
|
34
|
+
runtime = new SessionRuntime();
|
|
26
35
|
/** Cancels the background prompt backfill of the previous selection. */
|
|
27
36
|
promptBackfill;
|
|
28
37
|
/** Prompts of sessions this process has already read, so re-opening one costs no page request. */
|
|
29
38
|
promptCache = new PromptCache();
|
|
30
|
-
/** Prompt index,
|
|
39
|
+
/** Prompt index, record, history window and interaction state of the selected session. */
|
|
31
40
|
get info() { return this.store.state.session; }
|
|
32
41
|
get prompts() { return this.info.prompts; }
|
|
33
42
|
stoppingSession;
|
|
34
43
|
interruptTask;
|
|
35
44
|
admission;
|
|
36
|
-
|
|
45
|
+
/** Admission order of this session's writes, so two concurrent decisions cannot interleave. */
|
|
46
|
+
mutations;
|
|
47
|
+
constructor(store, host, connection, historyLimits, report) {
|
|
37
48
|
this.store = store;
|
|
38
49
|
this.host = host;
|
|
39
50
|
this.connection = connection;
|
|
40
51
|
this.historyLimits = historyLimits;
|
|
52
|
+
this.mutations = new SessionMutationGate(report);
|
|
53
|
+
this.promptBackfill = new PromptBackfill({
|
|
54
|
+
require: () => this.host.require(), signal: () => this.host.signal(), online: () => this.host.online(),
|
|
55
|
+
current: selection => selection.revision === this.store.selection() && selection.record === this.info.record
|
|
56
|
+
&& selection.sessionId === this.store.state.sessionId,
|
|
57
|
+
changed: () => this.store.update({}),
|
|
58
|
+
}, this.promptCache);
|
|
59
|
+
this.navigation = new SessionNavigator({
|
|
60
|
+
require: () => this.host.require(), client: () => this.host.client(),
|
|
61
|
+
online: () => this.host.online(), signal: () => this.host.signal(),
|
|
62
|
+
read: () => this.store.state, publish: patch => this.store.update(patch),
|
|
63
|
+
selection: () => this.store.selection(),
|
|
64
|
+
leave: () => {
|
|
65
|
+
this.store.bumpSelection();
|
|
66
|
+
this.follow?.cancel();
|
|
67
|
+
this.follow = undefined;
|
|
68
|
+
this.releaseTranscript();
|
|
69
|
+
},
|
|
70
|
+
follow: id => this.followSession(id, true),
|
|
71
|
+
});
|
|
72
|
+
this.interactions = new SessionInteractions({
|
|
73
|
+
focused: () => this.store.state.pending[0],
|
|
74
|
+
admit: async (sessionId, dispatch) => { await this.mutations.admit(sessionId, 'normal', dispatch); },
|
|
75
|
+
reply: (eventId, outcome) => this.connection.reply(eventId, outcome),
|
|
76
|
+
changed: () => this.store.update({}),
|
|
77
|
+
});
|
|
78
|
+
this.history = new HistoryReader({
|
|
79
|
+
require: () => this.host.require(), online: () => this.host.online(), signal: () => this.host.signal(),
|
|
80
|
+
selected: () => ({ sessionId: this.sessionId, revision: this.store.selection(), record: this.info.record }),
|
|
81
|
+
current: selection => selection.revision === this.store.selection() && selection.sessionId === this.store.state.sessionId
|
|
82
|
+
&& selection.record === this.info.record,
|
|
83
|
+
owns: source => source === this.info.record || source === this.info.window,
|
|
84
|
+
changed: () => this.store.update({}),
|
|
85
|
+
});
|
|
41
86
|
}
|
|
42
87
|
/** Host running state covers model generation, tools, and waits between assistant attempts. */
|
|
43
88
|
get running() {
|
|
44
89
|
const id = this.store.state.sessionId;
|
|
45
|
-
return id !== undefined && (this.
|
|
90
|
+
return id !== undefined && (this.runtime.runningFor(id)
|
|
46
91
|
?? this.store.state.sessions.find(row => row.sessionId === id)?.running === true);
|
|
47
92
|
}
|
|
48
93
|
/** Current title projection, falling back to the list title and then the session ID. */
|
|
@@ -50,19 +95,19 @@ export class SessionController {
|
|
|
50
95
|
const id = this.store.state.sessionId;
|
|
51
96
|
if (!id)
|
|
52
97
|
return;
|
|
53
|
-
const title = this.
|
|
98
|
+
const title = this.runtime.telemetry.view(id).values.title;
|
|
54
99
|
const row = this.store.state.sessions.find(item => item.sessionId === id);
|
|
55
100
|
return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
|
|
56
101
|
: row ? sessionLabel(row) : id;
|
|
57
102
|
}
|
|
58
103
|
/** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
|
|
59
|
-
|
|
104
|
+
sessionMode(presets = []) {
|
|
60
105
|
if (!this.store.state.sessionId)
|
|
61
106
|
return undefined;
|
|
62
|
-
const id = this.
|
|
107
|
+
const id = this.runtime.telemetry.view(this.store.state.sessionId).values.agentPreset;
|
|
63
108
|
if (typeof id !== 'string')
|
|
64
109
|
return undefined;
|
|
65
|
-
const preset =
|
|
110
|
+
const preset = presets.find(item => item.id === id);
|
|
66
111
|
return preset?.trust === 'system' && BUILT_IN_MODES.has(id) ? BUILT_IN_MODES.get(id)
|
|
67
112
|
: typeof preset?.name === 'string' ? preset.name : id;
|
|
68
113
|
}
|
|
@@ -70,40 +115,60 @@ export class SessionController {
|
|
|
70
115
|
get workingSince() {
|
|
71
116
|
if (!this.running || !this.store.state.sessionId)
|
|
72
117
|
return undefined;
|
|
73
|
-
return this.info.record.activeTurnStartedAt ?? this.
|
|
118
|
+
return this.info.record.activeTurnStartedAt ?? this.runtime.observedAt(this.store.state.sessionId);
|
|
74
119
|
}
|
|
75
120
|
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
76
|
-
get visibleSessions() {
|
|
77
|
-
const sessions = this.store.state.sessions.filter(item => !this.host.client()?.archivedSessionIds.has(string(item.sessionId)));
|
|
78
|
-
if (this.store.state.showAllSessions || !this.store.state.workspaceId)
|
|
79
|
-
return sessions;
|
|
80
|
-
const workspace = this.store.state.workspaces.find(item => item.workspaceId === this.store.state.workspaceId);
|
|
81
|
-
const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
|
|
82
|
-
return sessions.filter(item => ids.has(string(item.sessionId)));
|
|
83
|
-
}
|
|
121
|
+
get visibleSessions() { return this.navigation.visibleSessions; }
|
|
84
122
|
/** Whether a turn, cancellation or prompt admission is still in flight. */
|
|
85
123
|
get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
|
|
124
|
+
/** @returns The detached history window the reader opened, if any. */
|
|
125
|
+
get window() { return this.info.window; }
|
|
126
|
+
/** Show a detached history window, releasing the one it replaces.
|
|
127
|
+
* @param window - Record to display, or undefined to return to the live transcript.
|
|
128
|
+
*/
|
|
129
|
+
setViewWindow(window) {
|
|
130
|
+
if (this.info.window === window)
|
|
131
|
+
return;
|
|
132
|
+
if (this.info.window)
|
|
133
|
+
this.history.cancel(this.info.window);
|
|
134
|
+
this.info.closeWindow();
|
|
135
|
+
this.info.window = window;
|
|
136
|
+
this.store.update({});
|
|
137
|
+
}
|
|
138
|
+
/** @returns Host projection store of the current generation, owned by this session's runtime. */
|
|
139
|
+
get telemetry() { return this.runtime.telemetry; }
|
|
140
|
+
/** Lay out one record for a terminal width.
|
|
141
|
+
*
|
|
142
|
+
* The projection engine stays in this domain; the UI receives plain rows and offsets instead of
|
|
143
|
+
* importing it, and `Reasoning` for committed messages is always the folded default.
|
|
144
|
+
*/
|
|
145
|
+
render(input) {
|
|
146
|
+
return historyLayout(input.transcript, input.width, 'row', input.folds, input.liveReasoning);
|
|
147
|
+
}
|
|
148
|
+
/** Apply one normalized control frame to this session's runtime. */
|
|
149
|
+
acceptControl(frame) { this.runtime.acceptControl(frame); }
|
|
86
150
|
/** Whether reading protects the loaded window, suspending history reclamation. */
|
|
87
|
-
get pinned() { return this.
|
|
151
|
+
get pinned() { return this.historyPinned; }
|
|
88
152
|
/** Drop generation-scoped state before a new connection generation begins. */
|
|
89
|
-
beginGeneration() { this.stoppingSession = undefined; }
|
|
153
|
+
beginGeneration() { this.stoppingSession = undefined; this.runtime.reset(); }
|
|
90
154
|
/** Invalidate in-flight work and drop transient interactions when a generation ends. */
|
|
91
155
|
endGeneration() {
|
|
156
|
+
this.promptBackfill.cancel();
|
|
157
|
+
this.navigation.reset();
|
|
158
|
+
this.history.cancel();
|
|
92
159
|
this.store.bumpSelection();
|
|
93
160
|
this.info.record.ready = false;
|
|
94
161
|
this.interactions.clear();
|
|
95
162
|
}
|
|
96
|
-
/** Wait for
|
|
97
|
-
async settle() { await this.interruptTask; }
|
|
163
|
+
/** Wait for cancellation and foreground history tasks before releasing their records. */
|
|
164
|
+
async settle() { await this.interruptTask; await this.history.settle(); await this.promptBackfill.settle(); await this.interactions.settle(); await this.navigation.settle(); }
|
|
98
165
|
/** Release the selected transcript and its layout caches. */
|
|
99
|
-
release() { this.releaseTranscript(); }
|
|
166
|
+
release() { this.navigation.reset(); this.releaseTranscript(); }
|
|
100
167
|
/** Pending interactions for the selected chat session, derived independently of frame order.
|
|
101
|
-
* @param
|
|
168
|
+
* @param sessionId - Session whose pending interactions are being published.
|
|
102
169
|
* @returns Retained question and approval frames for that session.
|
|
103
170
|
*/
|
|
104
|
-
pendingFor(
|
|
105
|
-
return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
|
|
106
|
-
}
|
|
171
|
+
pendingFor(sessionId) { return this.interactions.pendingFor(sessionId); }
|
|
107
172
|
/** Unanswered interactions by session, so a list can show who is waiting without opening them.
|
|
108
173
|
*
|
|
109
174
|
* The host delivers approval and question waterfalls for every session on one stream, and this
|
|
@@ -112,35 +177,22 @@ export class SessionController {
|
|
|
112
177
|
* until the host replays the pending waterfalls.
|
|
113
178
|
* @returns One count per session holding at least one unanswered interaction.
|
|
114
179
|
*/
|
|
115
|
-
pendingCounts() {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
continue;
|
|
120
|
-
counts.set(frame.agentId, (counts.get(frame.agentId) ?? 0) + 1);
|
|
121
|
-
}
|
|
122
|
-
return counts;
|
|
123
|
-
}
|
|
124
|
-
/** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
|
|
125
|
-
* @param frame - One decoded waterfall frame.
|
|
126
|
-
* @returns Whether this domain retained the frame for an answer.
|
|
180
|
+
pendingCounts() { return this.interactions.counts(); }
|
|
181
|
+
/** Retain a recognized host interaction; anything else belongs to the connection to delegate.
|
|
182
|
+
* @param event - One normalized host event.
|
|
183
|
+
* @returns Whether this domain retained the event for an answer.
|
|
127
184
|
*/
|
|
128
|
-
|
|
129
|
-
if (!['approval/request', 'user-questions/request'].includes(string(frame.event)))
|
|
130
|
-
return false;
|
|
131
|
-
this.interactions.set(string(frame.eventId), frame);
|
|
132
|
-
this.store.update({});
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
185
|
+
accept(event) { return this.interactions.accept(event); }
|
|
135
186
|
/** Drop a waterfall the host cancelled.
|
|
136
187
|
* @param eventId - Correlation id previously retained.
|
|
137
188
|
*/
|
|
138
|
-
cancelled(eventId) { this.interactions.
|
|
189
|
+
cancelled(eventId) { this.interactions.cancelled(eventId); }
|
|
139
190
|
/** Apply one host running-state notification to the session list and status line.
|
|
140
191
|
* @param sessionId - Session whose state changed.
|
|
141
192
|
* @param running - Whether the host still runs that session.
|
|
142
193
|
*/
|
|
143
194
|
status(sessionId, running) {
|
|
195
|
+
this.runtime.accept(sessionId, running);
|
|
144
196
|
if (!running && this.stoppingSession === sessionId)
|
|
145
197
|
this.stoppingSession = undefined;
|
|
146
198
|
this.store.update({ sessions: this.store.state.sessions.map(row => row.sessionId === sessionId ? { ...row, running } : row),
|
|
@@ -148,11 +200,11 @@ export class SessionController {
|
|
|
148
200
|
}
|
|
149
201
|
/** Surface a host-reported error for the selected session.
|
|
150
202
|
* @param sessionId - Session the host reported on.
|
|
151
|
-
* @param error -
|
|
203
|
+
* @param error - Already-normalized error text.
|
|
152
204
|
*/
|
|
153
205
|
reportError(sessionId, error) {
|
|
154
206
|
if (sessionId === this.store.state.sessionId)
|
|
155
|
-
this.store.update({ status: 'Agent error',
|
|
207
|
+
this.store.update({ status: 'Agent error', lastFailure: error });
|
|
156
208
|
}
|
|
157
209
|
/** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
|
|
158
210
|
* @param force - Send an explicit cancellation even when the cached running flag is idle.
|
|
@@ -162,23 +214,27 @@ export class SessionController {
|
|
|
162
214
|
if (this.interruptTask)
|
|
163
215
|
return this.interruptTask;
|
|
164
216
|
if (!force && !this.running && !this.admission && this.store.state.pending.length === 0) {
|
|
165
|
-
|
|
217
|
+
// An idle press may exit only when no operation owns the client; the store's failure line is
|
|
218
|
+
// not that fact, so the application asks itself.
|
|
219
|
+
return Promise.resolve(!this.store.busy());
|
|
166
220
|
}
|
|
167
221
|
const sessionId = this.sessionId;
|
|
168
222
|
this.stoppingSession = sessionId;
|
|
169
|
-
this.store.update({ status: 'Stopping…',
|
|
223
|
+
this.store.update({ status: 'Stopping…', lastFailure: '' });
|
|
170
224
|
const task = (async () => {
|
|
171
225
|
try {
|
|
172
226
|
// Admission must settle before cancellation can address the newly submitted turn.
|
|
173
227
|
// The prompt caller reports admission failures; an existing turn still needs cancellation.
|
|
228
|
+
// This wait stays outside the gate: a control admission overtakes the *waiting* queue, and
|
|
229
|
+
// holding the gate while waiting here would block the very writes it precedes.
|
|
174
230
|
await this.admission?.catch(() => undefined);
|
|
175
|
-
await this.host.require().call('session/cancel', { request: { sessionId } });
|
|
231
|
+
await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
|
|
176
232
|
if (this.stoppingSession === sessionId && this.store.state.sessionId === sessionId)
|
|
177
233
|
this.store.update({ status: 'Cancellation requested · waiting for host' });
|
|
178
234
|
}
|
|
179
235
|
catch (error) {
|
|
180
236
|
this.stoppingSession = undefined;
|
|
181
|
-
this.store.update({ status: 'Cancellation failed',
|
|
237
|
+
this.store.update({ status: 'Cancellation failed', lastFailure: errorText(error) });
|
|
182
238
|
}
|
|
183
239
|
return false;
|
|
184
240
|
})();
|
|
@@ -190,23 +246,34 @@ export class SessionController {
|
|
|
190
246
|
* @param pinned - Whether the main transcript is actively being read away from its tail.
|
|
191
247
|
*/
|
|
192
248
|
pinHistory(pinned) {
|
|
193
|
-
this.
|
|
249
|
+
if (this.historyPinned === pinned)
|
|
250
|
+
return;
|
|
251
|
+
this.historyPinned = pinned;
|
|
194
252
|
if (!pinned && this.reclaimHistory())
|
|
195
253
|
this.store.update({});
|
|
196
254
|
}
|
|
197
255
|
/** Cancel the active turn; pending queue items remain host-owned. */
|
|
198
256
|
async cancelTurn() {
|
|
199
|
-
|
|
257
|
+
const sessionId = this.sessionId;
|
|
258
|
+
// Cancellation is a control action: it may overtake queued writes, since its whole value is speed.
|
|
259
|
+
await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
|
|
200
260
|
this.store.update({ status: 'Cancellation requested' });
|
|
201
261
|
}
|
|
202
262
|
/** Refresh both lists from the host, then show the requested picker.
|
|
203
263
|
* @param screen - Picker to display after the refresh.
|
|
204
264
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
|
|
208
|
-
this.store.update({ screen, workspaces, sessions });
|
|
265
|
+
showPicker(screen, signal) {
|
|
266
|
+
return this.navigation.showPicker(screen, signal);
|
|
209
267
|
}
|
|
268
|
+
/** Refresh navigation data without taking the reader to another screen. */
|
|
269
|
+
refreshLists() { return this.navigation.refreshLists(); }
|
|
270
|
+
/** Return to the selected conversation without re-selecting it.
|
|
271
|
+
*
|
|
272
|
+
* A picker opened over a conversation is a detour: leaving it must not re-subscribe, reload the
|
|
273
|
+
* transcript or lose the reading position, so this only changes which screen is shown.
|
|
274
|
+
* @returns Whether there was a selected conversation to return to.
|
|
275
|
+
*/
|
|
276
|
+
showChat() { return this.navigation.showChat(); }
|
|
210
277
|
/** Resolve a removal command to one reviewable object without changing the selection.
|
|
211
278
|
* @param kind - Workspace registration removal or session archival.
|
|
212
279
|
* @param query - Exact name, ID, or unambiguous ID prefix.
|
|
@@ -219,9 +286,9 @@ export class SessionController {
|
|
|
219
286
|
return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
|
|
220
287
|
: { kind, id: string(row.sessionId), name: sessionLabel(row),
|
|
221
288
|
empty: row.blank === true && row.running === false
|
|
222
|
-
&& !this.
|
|
223
|
-
&& !(this.
|
|
224
|
-
&& !(this.
|
|
289
|
+
&& !this.runtime.runningFor(string(row.sessionId))
|
|
290
|
+
&& !(this.runtime.telemetry.view(string(row.sessionId)).queued ?? 0)
|
|
291
|
+
&& !(this.runtime.telemetry.view(string(row.sessionId)).jobs ?? 0)
|
|
225
292
|
&& !(this.store.state.sessionId === row.sessionId && this.admission) };
|
|
226
293
|
}
|
|
227
294
|
/** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
|
|
@@ -229,11 +296,14 @@ export class SessionController {
|
|
|
229
296
|
*/
|
|
230
297
|
async removeTarget(target) {
|
|
231
298
|
const client = this.host.require();
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
299
|
+
const intent = this.navigation.intent;
|
|
300
|
+
const receipt = object(target.kind === 'workspace'
|
|
301
|
+
? await client.call('workspace/delete', { request: { workspaceId: target.id } })
|
|
302
|
+
// Archiving a session is a write on that session, so it queues behind that session's admissions.
|
|
303
|
+
: await this.mutations.admit(target.id, 'normal', () => client.call('workspace/archiveSession', { request: { sessionId: target.id } })));
|
|
235
304
|
if (client !== this.host.client())
|
|
236
305
|
return;
|
|
306
|
+
const navigate = intent === this.navigation.intent;
|
|
237
307
|
if (target.kind === 'session') {
|
|
238
308
|
client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
|
|
239
309
|
if (this.store.state.sessionId === target.id)
|
|
@@ -245,58 +315,30 @@ export class SessionController {
|
|
|
245
315
|
this.store.update({ workspaces: this.store.state.workspaces.filter(row => row.workspaceId !== target.id),
|
|
246
316
|
...(this.store.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
|
|
247
317
|
}
|
|
248
|
-
this.store.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
|
|
318
|
+
this.store.update({ ...(navigate ? { screen: target.kind === 'workspace' ? 'workspaces' : 'sessions' } : {}),
|
|
249
319
|
status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
|
|
250
320
|
// A refresh failure must not make a successful mutation look like a rejected deletion.
|
|
251
321
|
try {
|
|
252
|
-
await this.
|
|
322
|
+
await this.refreshLists();
|
|
253
323
|
}
|
|
254
324
|
catch (error) {
|
|
255
|
-
this.store.update({
|
|
325
|
+
this.store.update({ lastFailure: `Removal completed; list refresh failed: ${errorText(error)}` });
|
|
256
326
|
}
|
|
257
327
|
}
|
|
258
328
|
/** Pick a workspace, or use all sessions when the identity is omitted.
|
|
259
329
|
* @param workspaceId - Workspace to select, if any.
|
|
260
330
|
*/
|
|
261
|
-
pickWorkspace(workspaceId) {
|
|
262
|
-
this.store.bumpSelection();
|
|
263
|
-
this.follow?.cancel();
|
|
264
|
-
this.follow = undefined;
|
|
265
|
-
this.releaseTranscript();
|
|
266
|
-
this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
|
|
267
|
-
}
|
|
331
|
+
pickWorkspace(workspaceId) { this.navigation.pickWorkspace(workspaceId); }
|
|
268
332
|
/** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
|
|
269
333
|
* @param query - Workspace target, if any.
|
|
270
334
|
*/
|
|
271
|
-
|
|
272
|
-
if (!query) {
|
|
273
|
-
await this.showPicker('workspaces');
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
const client = this.host.require();
|
|
277
|
-
const workspaces = await client.listWorkspaces();
|
|
278
|
-
const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
|
|
279
|
-
const sessions = await client.listSessions();
|
|
280
|
-
this.store.update({ workspaces, sessions });
|
|
281
|
-
this.pickWorkspace(string(workspace.workspaceId));
|
|
282
|
-
}
|
|
335
|
+
switchWorkspace(query, signal) { return this.navigation.switchWorkspace(query, signal); }
|
|
283
336
|
/** Guide workspace selection, list all sessions with `all`, or resolve an exact session target.
|
|
284
337
|
* @param query - Session target, `all`, or nothing for the guided picker.
|
|
285
338
|
*/
|
|
286
|
-
|
|
287
|
-
if (!query || query === 'all') {
|
|
288
|
-
await this.showPicker(query === 'all' || this.store.state.workspaceId ? 'sessions' : 'workspaces');
|
|
289
|
-
this.store.update({ showAllSessions: query === 'all' });
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
const client = this.host.require();
|
|
293
|
-
const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
|
|
294
|
-
const session = resolveTarget(sessions, query, 'sessionId', item => [sessionLabel(item)]);
|
|
295
|
-
this.store.update({ workspaces, sessions });
|
|
296
|
-
await this.selectSession(string(session.sessionId));
|
|
297
|
-
}
|
|
339
|
+
switchSession(query, signal) { return this.navigation.switchSession(query, signal); }
|
|
298
340
|
/** Prompt for a host path without starting a local agent. */
|
|
299
|
-
enterPath() { this.
|
|
341
|
+
enterPath() { this.navigation.enterPath(); }
|
|
300
342
|
/** Adopt the workspace whose registered path contains the directory this client runs in.
|
|
301
343
|
*
|
|
302
344
|
* Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
|
|
@@ -305,44 +347,58 @@ export class SessionController {
|
|
|
305
347
|
* @param directory - Directory this client was started in.
|
|
306
348
|
* @returns The adopted workspace's id, or undefined when none matches.
|
|
307
349
|
*/
|
|
308
|
-
adoptLocalWorkspace(directory) {
|
|
309
|
-
const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
310
|
-
const target = slashed(directory);
|
|
311
|
-
if (target === '')
|
|
312
|
-
return undefined;
|
|
313
|
-
let best;
|
|
314
|
-
for (const workspace of this.store.state.workspaces) {
|
|
315
|
-
const path = slashed(string(workspace.path));
|
|
316
|
-
if (path === '' || (target !== path && !target.startsWith(`${path}/`)))
|
|
317
|
-
continue;
|
|
318
|
-
if (best === undefined || path.length > best.length)
|
|
319
|
-
best = { id: string(workspace.workspaceId), length: path.length };
|
|
320
|
-
}
|
|
321
|
-
if (best === undefined)
|
|
322
|
-
return undefined;
|
|
323
|
-
this.pickWorkspace(best.id);
|
|
324
|
-
return best.id;
|
|
325
|
-
}
|
|
350
|
+
adoptLocalWorkspace(directory) { return this.navigation.adoptLocalWorkspace(directory); }
|
|
326
351
|
/** Register a host directory and move to its session picker.
|
|
327
352
|
* @param path - Absolute directory path on the host.
|
|
328
353
|
*/
|
|
329
|
-
|
|
330
|
-
const result = object(await this.host.require().call('workspace/create', { request: { path } }));
|
|
331
|
-
const workspace = object(result.workspace);
|
|
332
|
-
await this.showPicker('workspaces');
|
|
333
|
-
this.pickWorkspace(string(workspace.workspaceId));
|
|
334
|
-
}
|
|
354
|
+
createWorkspace(path, signal) { return this.navigation.createWorkspace(path, signal); }
|
|
335
355
|
/** Create a session only after the user explicitly selects New session. */
|
|
336
|
-
|
|
337
|
-
|
|
356
|
+
createSession(signal) { return this.navigation.createSession(signal); }
|
|
357
|
+
/** Create a session for another purpose without selecting it, named so a reader can tell it apart.
|
|
358
|
+
*
|
|
359
|
+
* A verifier runs in its own session while the reviewed session stays selected, so this never
|
|
360
|
+
* follows the new session and never touches the reader's transcript.
|
|
361
|
+
* @param title - Session title to apply; hosts without a title service keep the default.
|
|
362
|
+
* @returns The new session's id, or undefined when its identity could not be read.
|
|
363
|
+
*/
|
|
364
|
+
async createNamedSession(title) {
|
|
365
|
+
const workspaceId = this.store.state.workspaceId;
|
|
366
|
+
if (!workspaceId)
|
|
338
367
|
throw new Error('Select a workspace before creating a session');
|
|
339
|
-
const
|
|
340
|
-
|
|
368
|
+
const created = object(await this.host.require().call('session/create', { request: { workspaceId } }));
|
|
369
|
+
const sessionId = string(created.sessionId);
|
|
370
|
+
try {
|
|
371
|
+
await this.host.require().call('session/rename', { request: { sessionId, title } });
|
|
372
|
+
}
|
|
373
|
+
catch { /* A deployment without the title service still gets a usable session. */ }
|
|
374
|
+
return sessionId;
|
|
375
|
+
}
|
|
376
|
+
/** Stop a session this client owns but has not selected, such as a verifier's.
|
|
377
|
+
*
|
|
378
|
+
* Killing the child client leaves the host's agent generation running, so the turn has to be
|
|
379
|
+
* cancelled on the host itself.
|
|
380
|
+
* @param sessionId - Session whose turn should stop.
|
|
381
|
+
*/
|
|
382
|
+
async cancelNamedSession(sessionId) {
|
|
383
|
+
// Keyed by the session being stopped, not the selection: the verifier's session is someone else's.
|
|
384
|
+
await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
|
|
341
385
|
}
|
|
342
386
|
/** Replace the selected transcript and cancel its preceding follow stream.
|
|
343
387
|
* @param sessionId - Session to follow.
|
|
344
388
|
*/
|
|
345
389
|
async selectSession(sessionId) {
|
|
390
|
+
this.navigation.cancel();
|
|
391
|
+
this.followSession(sessionId, true);
|
|
392
|
+
}
|
|
393
|
+
/** Reattach the selected conversation after reconnect, keeping the current navigation context. */
|
|
394
|
+
restoreSelectedSession() {
|
|
395
|
+
const sessionId = this.store.state.sessionId;
|
|
396
|
+
if (sessionId !== undefined)
|
|
397
|
+
this.followSession(sessionId, false);
|
|
398
|
+
}
|
|
399
|
+
followSession(sessionId, navigate) {
|
|
400
|
+
this.promptBackfill.cancel();
|
|
401
|
+
this.history.cancel();
|
|
346
402
|
this.stoppingSession = undefined;
|
|
347
403
|
this.store.bumpSelection();
|
|
348
404
|
const selection = this.store.selection();
|
|
@@ -353,8 +409,9 @@ export class SessionController {
|
|
|
353
409
|
const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
|
|
354
410
|
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
355
411
|
: this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
|
|
356
|
-
this.
|
|
357
|
-
this.store.update({ sessionId,
|
|
412
|
+
this.runtime.observe(sessionId);
|
|
413
|
+
this.store.update({ sessionId, status: 'Loading session…',
|
|
414
|
+
...(navigate ? { workspaceId, showAllSessions: false, screen: 'chat' } : {}) });
|
|
358
415
|
this.follow = this.host.require().subscribe('session/follow', {
|
|
359
416
|
request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
|
|
360
417
|
}, {
|
|
@@ -367,7 +424,7 @@ export class SessionController {
|
|
|
367
424
|
this.reclaimHistory();
|
|
368
425
|
const frame = object(value);
|
|
369
426
|
if (frame.type === 'snapshot')
|
|
370
|
-
this.
|
|
427
|
+
this.runtime.telemetry.snapshot(sessionId, projectionSnapshot(frame.projections));
|
|
371
428
|
this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
|
|
372
429
|
}
|
|
373
430
|
catch (error) {
|
|
@@ -378,26 +435,15 @@ export class SessionController {
|
|
|
378
435
|
if (selection !== this.store.selection())
|
|
379
436
|
return;
|
|
380
437
|
transcript.ready = false;
|
|
381
|
-
this.store.update({ status: 'Session disconnected',
|
|
438
|
+
this.store.update({ status: 'Session disconnected', lastFailure: errorText(error ?? 'Session stream ended') });
|
|
382
439
|
},
|
|
383
440
|
});
|
|
384
|
-
this.
|
|
441
|
+
this.promptBackfill.start({ sessionId, revision: selection, record: transcript, prompts: this.prompts });
|
|
385
442
|
}
|
|
386
443
|
/** Wait for the selected follow snapshot, failing on disconnect or cancellation.
|
|
387
444
|
* @param signal - Cancels waiting without closing the session.
|
|
388
445
|
*/
|
|
389
|
-
async waitForHistory(signal) {
|
|
390
|
-
const transcript = this.info.record;
|
|
391
|
-
const deadline = Date.now() + this.host.require().timeoutMs;
|
|
392
|
-
while (!transcript.ready) {
|
|
393
|
-
signal.throwIfAborted();
|
|
394
|
-
if (!this.store.state.online || this.info.record !== transcript)
|
|
395
|
-
throw new Error('Session changed while loading history');
|
|
396
|
-
if (Date.now() >= deadline)
|
|
397
|
-
throw new Error('Session snapshot timed out');
|
|
398
|
-
await delay(20, undefined, { signal });
|
|
399
|
-
}
|
|
400
|
-
}
|
|
446
|
+
async waitForHistory(signal) { await this.history.waitForHistory(signal); }
|
|
401
447
|
/** Search the host's bounded global results, optionally retaining workspace members.
|
|
402
448
|
* @param query - Literal message text.
|
|
403
449
|
* @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
|
|
@@ -434,11 +480,14 @@ export class SessionController {
|
|
|
434
480
|
* @returns The host's successful command result text.
|
|
435
481
|
*/
|
|
436
482
|
async command(line, signal) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
483
|
+
const sessionId = this.sessionId;
|
|
484
|
+
// The section decides and issues; the host's answer is awaited outside the gate, because a
|
|
485
|
+
// compaction can take minutes and must not hold every other write on this session.
|
|
486
|
+
const execution = await this.mutations.admit(sessionId, 'normal', () => {
|
|
487
|
+
if (!this.info.record.ready)
|
|
488
|
+
throw new Error('Wait for the session snapshot before running commands');
|
|
489
|
+
return this.host.require().call('commands/execute', { agentId: sessionId, line, submittedAttachments: [] }, signal, null);
|
|
490
|
+
});
|
|
442
491
|
if (execution === undefined)
|
|
443
492
|
throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
|
|
444
493
|
const result = object(object(execution).result);
|
|
@@ -458,7 +507,10 @@ export class SessionController {
|
|
|
458
507
|
* @param itemId - Queue occurrence identity from session/control.
|
|
459
508
|
*/
|
|
460
509
|
async removeQueued(itemId) {
|
|
461
|
-
|
|
510
|
+
const sessionId = this.sessionId;
|
|
511
|
+
await this.mutations.admit(sessionId, 'normal', () => this.host.require().call('session/updateQueue', {
|
|
512
|
+
request: { sessionId, itemId, action: { kind: 'remove' } }
|
|
513
|
+
}));
|
|
462
514
|
}
|
|
463
515
|
/** Export the selected host log to a new local ZIP file.
|
|
464
516
|
* @param path - Optional local destination; existing files are never overwritten.
|
|
@@ -480,16 +532,23 @@ export class SessionController {
|
|
|
480
532
|
* @param text - Composed prompt text.
|
|
481
533
|
*/
|
|
482
534
|
async prompt(text) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
this
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
535
|
+
const sessionId = this.sessionId;
|
|
536
|
+
// Deciding steer-versus-queue and issuing the request are one admitted step: the loop's own sends
|
|
537
|
+
// and everything the reader types go through this same point, so two of them cannot be decided
|
|
538
|
+
// against the same stale running state. The host's answer is awaited outside the gate.
|
|
539
|
+
const admission = await this.mutations.admit(sessionId, 'normal', () => {
|
|
540
|
+
if (this.store.state.pending.length)
|
|
541
|
+
throw new Error('Answer the pending question or approval first');
|
|
542
|
+
this.stoppingSession = undefined;
|
|
543
|
+
if (!this.info.record.ready)
|
|
544
|
+
throw new Error('Wait for the session snapshot before sending');
|
|
545
|
+
const issued = this.host.require().call('session/prompt', { request: {
|
|
546
|
+
sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
|
|
547
|
+
content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
548
|
+
} });
|
|
549
|
+
this.admission = issued;
|
|
550
|
+
return issued;
|
|
551
|
+
});
|
|
493
552
|
try {
|
|
494
553
|
await admission;
|
|
495
554
|
}
|
|
@@ -499,41 +558,32 @@ export class SessionController {
|
|
|
499
558
|
}
|
|
500
559
|
this.store.update({ status: 'Accepted · waiting for host' });
|
|
501
560
|
}
|
|
561
|
+
/** Send one prompt the client assembled, keeping its durable echo out of composer recall.
|
|
562
|
+
*
|
|
563
|
+
* The host records every prompt as a user message, so an agent loop that submits a turn per
|
|
564
|
+
* attempt would otherwise crowd out the prompts the operator actually typed. Suppressing the text
|
|
565
|
+
* before sending is what makes the later echo invisible to ↑/↓.
|
|
566
|
+
* @param text - Prompt the client assembled, not text the operator typed.
|
|
567
|
+
*/
|
|
568
|
+
async promptInternal(text) {
|
|
569
|
+
this.prompts.suppress(text);
|
|
570
|
+
await this.prompt(text);
|
|
571
|
+
}
|
|
502
572
|
/** Answer the oldest selected-session interaction, after explicit user action.
|
|
503
573
|
* @param value - Structured answer value or approval outcome.
|
|
504
574
|
*/
|
|
505
|
-
async answer(value) {
|
|
506
|
-
const pending = this.store.state.pending[0];
|
|
507
|
-
if (!pending)
|
|
508
|
-
throw new Error('No pending interaction');
|
|
509
|
-
await this.reply(pending, { kind: 'result', value });
|
|
510
|
-
this.interactions.delete(string(pending.eventId));
|
|
511
|
-
this.store.update({});
|
|
512
|
-
}
|
|
575
|
+
async answer(value) { await this.interactions.answer(value); }
|
|
513
576
|
/** Restrict an approval command to an approval request.
|
|
514
577
|
* @param allowed - Whether the request is approved once.
|
|
515
578
|
*/
|
|
516
|
-
async approve(allowed) {
|
|
517
|
-
if (this.store.state.pending[0]?.event !== 'approval/request')
|
|
518
|
-
throw new Error('No pending approval');
|
|
519
|
-
await this.answer(allowed ? 'allowed-once' : 'rejected');
|
|
520
|
-
}
|
|
579
|
+
async approve(allowed) { await this.interactions.approve(allowed); }
|
|
521
580
|
/** Dismiss the whole selected-session question set without answering it.
|
|
522
581
|
*
|
|
523
582
|
* The Web client's close button settles the same waterfall the same way — reject with
|
|
524
583
|
* `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
|
|
525
584
|
* batch is answered as one request, so dismissals also discard partial local answers.
|
|
526
585
|
*/
|
|
527
|
-
async dismissQuestion() {
|
|
528
|
-
const pending = this.store.state.pending[0];
|
|
529
|
-
if (pending?.event !== 'user-questions/request')
|
|
530
|
-
throw new Error('No pending question');
|
|
531
|
-
await this.reply(pending, { kind: 'rejected', error: {
|
|
532
|
-
name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
|
|
533
|
-
} });
|
|
534
|
-
this.interactions.delete(string(pending.eventId));
|
|
535
|
-
this.store.update({});
|
|
536
|
-
}
|
|
586
|
+
async dismissQuestion() { await this.interactions.dismissQuestion(); }
|
|
537
587
|
/** Add a page before the retained window using its fixed opening cut.
|
|
538
588
|
*
|
|
539
589
|
* Reclamation is deliberately not pinned here. Every caller that needs the fetched page to
|
|
@@ -544,103 +594,13 @@ export class SessionController {
|
|
|
544
594
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
545
595
|
* @param transcript - Transcript to extend; defaults to the live one.
|
|
546
596
|
*/
|
|
547
|
-
async older(signal, transcript = this.info.record) {
|
|
548
|
-
const selection = this.store.selection();
|
|
549
|
-
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
550
|
-
return;
|
|
551
|
-
const result = await this.host.require().call('session/page', { request: {
|
|
552
|
-
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
|
|
553
|
-
beforeSeq: transcript.beforeSeq, maxMessages: 80,
|
|
554
|
-
} }, signal);
|
|
555
|
-
if (selection !== this.store.selection())
|
|
556
|
-
return;
|
|
557
|
-
transcript.addPage(result);
|
|
558
|
-
this.store.update({});
|
|
559
|
-
}
|
|
597
|
+
async older(signal, transcript = this.info.record) { await this.history.older(signal, transcript); }
|
|
560
598
|
/** Recall one step through the session's prompt index; navigation never touches the network.
|
|
561
599
|
* @param direction - Negative for older input, positive for newer input.
|
|
562
600
|
* @param current - Composer content before recall began, restored at the newest position.
|
|
563
601
|
* @returns The recalled prompt, or the unsent draft.
|
|
564
602
|
*/
|
|
565
603
|
recall(direction, current) { return this.prompts.move(direction, current); }
|
|
566
|
-
/** Composer draft, caret and parked draft, as the selected session holds them. */
|
|
567
|
-
get composer() { return this.info.composer; }
|
|
568
|
-
/** Replace the composer text and caret, publishing only when either actually changed. */
|
|
569
|
-
setComposer(draft, cursor = draft.length) {
|
|
570
|
-
const composer = this.info.composer;
|
|
571
|
-
if (composer.draft === draft && composer.cursor === cursor)
|
|
572
|
-
return;
|
|
573
|
-
composer.draft = draft;
|
|
574
|
-
composer.cursor = cursor;
|
|
575
|
-
this.store.update({});
|
|
576
|
-
}
|
|
577
|
-
/** Move the caret without changing the text. */
|
|
578
|
-
setComposerCursor(cursor) {
|
|
579
|
-
const composer = this.info.composer;
|
|
580
|
-
if (composer.cursor === cursor)
|
|
581
|
-
return;
|
|
582
|
-
composer.cursor = cursor;
|
|
583
|
-
this.store.update({});
|
|
584
|
-
}
|
|
585
|
-
/** Move a non-empty draft aside while a dialog owns the keyboard. */
|
|
586
|
-
parkComposer() {
|
|
587
|
-
const composer = this.info.composer;
|
|
588
|
-
if (composer.draft === '')
|
|
589
|
-
return;
|
|
590
|
-
composer.parked = composer.draft;
|
|
591
|
-
composer.draft = '';
|
|
592
|
-
composer.cursor = 0;
|
|
593
|
-
this.store.update({});
|
|
594
|
-
}
|
|
595
|
-
/** Give a parked draft back once no dialog needs the keyboard. */
|
|
596
|
-
restoreComposer() {
|
|
597
|
-
const composer = this.info.composer;
|
|
598
|
-
if (composer.parked === '')
|
|
599
|
-
return;
|
|
600
|
-
composer.draft = composer.parked;
|
|
601
|
-
composer.cursor = composer.parked.length;
|
|
602
|
-
composer.parked = '';
|
|
603
|
-
this.store.update({});
|
|
604
|
-
}
|
|
605
|
-
/** How the selected session's record is being read right now. */
|
|
606
|
-
get view() { return this.info.view; }
|
|
607
|
-
/** Show a detached history window, releasing the one it replaces.
|
|
608
|
-
* @param window - Record to display, or undefined to return to the live transcript.
|
|
609
|
-
*/
|
|
610
|
-
setViewWindow(window) {
|
|
611
|
-
if (this.info.view.window === window)
|
|
612
|
-
return;
|
|
613
|
-
this.info.closeWindow();
|
|
614
|
-
this.info.view.window = window;
|
|
615
|
-
this.store.update({});
|
|
616
|
-
}
|
|
617
|
-
/** Move the reader's position inside the displayed record.
|
|
618
|
-
* @param scroll - Rows scrolled back from the live end.
|
|
619
|
-
*/
|
|
620
|
-
setScroll(scroll) {
|
|
621
|
-
if (this.info.view.scroll === scroll)
|
|
622
|
-
return;
|
|
623
|
-
this.info.view.scroll = scroll;
|
|
624
|
-
this.store.update({});
|
|
625
|
-
}
|
|
626
|
-
/** Replace the set of expanded reasoning blocks, keyed by message sequence.
|
|
627
|
-
* @param folds - Sequences to expand beyond the default fold.
|
|
628
|
-
*/
|
|
629
|
-
setFolds(folds) {
|
|
630
|
-
if (this.info.view.folds === folds)
|
|
631
|
-
return;
|
|
632
|
-
this.info.view.folds = folds;
|
|
633
|
-
this.store.update({});
|
|
634
|
-
}
|
|
635
|
-
/** Set the fold mode of the live attempt's completed reasoning.
|
|
636
|
-
* @param reasoning - `row` to fold, `full` to keep the streamed text.
|
|
637
|
-
*/
|
|
638
|
-
setLiveReasoning(reasoning) {
|
|
639
|
-
if (this.info.view.liveReasoning === reasoning)
|
|
640
|
-
return;
|
|
641
|
-
this.info.view.liveReasoning = reasoning;
|
|
642
|
-
this.store.update({});
|
|
643
|
-
}
|
|
644
604
|
/** Local answer state for the selected session's pending waterfalls. */
|
|
645
605
|
get interaction() { return this.info.interaction; }
|
|
646
606
|
/** Replace the partly collected answers, keyed by the waterfall event id.
|
|
@@ -668,73 +628,6 @@ export class SessionController {
|
|
|
668
628
|
this.info.interaction.approval = approval;
|
|
669
629
|
this.store.update({});
|
|
670
630
|
}
|
|
671
|
-
/** Composer-adjacent `@` reference menu state. */
|
|
672
|
-
get reference() { return this.info.reference; }
|
|
673
|
-
/** Highlight one row of the open reference menu.
|
|
674
|
-
* @param index - Row index into the current matches.
|
|
675
|
-
*/
|
|
676
|
-
setReferenceIndex(index) {
|
|
677
|
-
if (this.info.reference.index === index)
|
|
678
|
-
return;
|
|
679
|
-
this.info.reference.index = index;
|
|
680
|
-
this.store.update({});
|
|
681
|
-
}
|
|
682
|
-
/** Remember the draft that dismissed the reference menu, so it does not reopen while it stands.
|
|
683
|
-
* @param draft - Composer text at dismissal, or undefined to allow the menu again.
|
|
684
|
-
*/
|
|
685
|
-
setReferenceDismissed(draft) {
|
|
686
|
-
if (this.info.reference.dismissed === draft)
|
|
687
|
-
return;
|
|
688
|
-
this.info.reference.dismissed = draft;
|
|
689
|
-
this.store.update({});
|
|
690
|
-
}
|
|
691
|
-
/** Panels the selected session has open. */
|
|
692
|
-
get panels() { return this.info.panels; }
|
|
693
|
-
/** Show or hide the reasoning panel.
|
|
694
|
-
* @param open - Whether `/think` is open.
|
|
695
|
-
*/
|
|
696
|
-
openThoughts(open) {
|
|
697
|
-
if (this.info.panels.thoughts === open)
|
|
698
|
-
return;
|
|
699
|
-
this.info.panels.thoughts = open;
|
|
700
|
-
this.store.update({});
|
|
701
|
-
}
|
|
702
|
-
/** Show or hide the pending-input panel.
|
|
703
|
-
* @param open - Whether `/queue` is open.
|
|
704
|
-
*/
|
|
705
|
-
openQueue(open) {
|
|
706
|
-
if (this.info.panels.queue === open)
|
|
707
|
-
return;
|
|
708
|
-
this.info.panels.queue = open;
|
|
709
|
-
this.store.update({});
|
|
710
|
-
}
|
|
711
|
-
/** Show the model dialog at one step, or close it.
|
|
712
|
-
* @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
|
|
713
|
-
*/
|
|
714
|
-
setModelPanel(model) {
|
|
715
|
-
if (this.info.panels.model === model)
|
|
716
|
-
return;
|
|
717
|
-
this.info.panels.model = model;
|
|
718
|
-
this.store.update({});
|
|
719
|
-
}
|
|
720
|
-
/** Show the history or content-search dialog, or close it.
|
|
721
|
-
* @param history - Query, content-search mode and matches; undefined closes the dialog.
|
|
722
|
-
*/
|
|
723
|
-
setHistoryPanel(history) {
|
|
724
|
-
if (this.info.panels.history === history)
|
|
725
|
-
return;
|
|
726
|
-
this.info.panels.history = history;
|
|
727
|
-
this.store.update({});
|
|
728
|
-
}
|
|
729
|
-
/** Show the host session-search results, or close them.
|
|
730
|
-
* @param search - Query, results and truncation flag; undefined closes the dialog.
|
|
731
|
-
*/
|
|
732
|
-
setSearchPanel(search) {
|
|
733
|
-
if (this.info.panels.search === search)
|
|
734
|
-
return;
|
|
735
|
-
this.info.panels.search = search;
|
|
736
|
-
this.store.update({});
|
|
737
|
-
}
|
|
738
631
|
/** Remember a locally submitted command, which never becomes a durable session record. */
|
|
739
632
|
recordRecall(value) { this.prompts.record(value); }
|
|
740
633
|
/** Leave recall navigation because the composer was edited or replaced. */
|
|
@@ -763,24 +656,6 @@ export class SessionController {
|
|
|
763
656
|
return false;
|
|
764
657
|
return this.prompts.prepend(this.info.record.promptsBefore(oldest)) > 0;
|
|
765
658
|
}
|
|
766
|
-
/** Seed recall from a complete cached entry, so an open that follows a scan costs no request.
|
|
767
|
-
* @param sessionId - Session being opened.
|
|
768
|
-
* @returns Whether the cache covered this session.
|
|
769
|
-
*/
|
|
770
|
-
adoptCachedPrompts(sessionId) {
|
|
771
|
-
const cached = this.promptCache.get(sessionId);
|
|
772
|
-
if (!cached?.complete)
|
|
773
|
-
return false;
|
|
774
|
-
const oldest = this.prompts.oldest;
|
|
775
|
-
const older = oldest === undefined ? cached.prompts : cached.prompts.filter(prompt => prompt.seq < oldest);
|
|
776
|
-
this.prompts.prepend(older);
|
|
777
|
-
// Same budget as the walk it replaces: `settle` may shed the oldest prefix, and `markComplete`
|
|
778
|
-
// then refuses, so the lazy backward step stays available for whatever was shed.
|
|
779
|
-
this.prompts.settle();
|
|
780
|
-
this.prompts.markComplete();
|
|
781
|
-
this.store.update({});
|
|
782
|
-
return true;
|
|
783
|
-
}
|
|
784
659
|
/** Fold one history page the cost scan already read into the prompt cache.
|
|
785
660
|
*
|
|
786
661
|
* The scan reads every session's whole history on connect, so this is where two readers stop
|
|
@@ -797,165 +672,34 @@ export class SessionController {
|
|
|
797
672
|
rememberScanDone(sessionId) {
|
|
798
673
|
this.promptCache.observe(sessionId, [], true);
|
|
799
674
|
}
|
|
800
|
-
/** Fold every prompt the host still holds into the recall index, in the background.
|
|
801
|
-
*
|
|
802
|
-
* Session start delivers only the newest window, so without this the arrows could reach older
|
|
803
|
-
* prompts but not show them without paging first. Each page is parsed into a temporary transcript
|
|
804
|
-
* and only its prompts are kept, so the live record, its memory window and the row cache never
|
|
805
|
-
* grow. The walk is bounded and the next selection cancels it; anything past the bound is still
|
|
806
|
-
* reachable through the lazy backward step.
|
|
807
|
-
* @param sessionId - Session being opened.
|
|
808
|
-
* @param selection - Selector generation that must still be current.
|
|
809
|
-
*/
|
|
810
|
-
backfillPrompts(sessionId, selection) {
|
|
811
|
-
this.promptBackfill?.abort();
|
|
812
|
-
const abort = new AbortController();
|
|
813
|
-
this.promptBackfill = abort;
|
|
814
|
-
void (async () => {
|
|
815
|
-
try {
|
|
816
|
-
while (!this.info.record.ready) {
|
|
817
|
-
abort.signal.throwIfAborted();
|
|
818
|
-
await delay(20, undefined, { signal: abort.signal });
|
|
819
|
-
}
|
|
820
|
-
// A cost scan or an earlier open may already have this session's prompts cached.
|
|
821
|
-
if (this.adoptCachedPrompts(sessionId))
|
|
822
|
-
return;
|
|
823
|
-
const throughSeq = this.info.record.readThrough;
|
|
824
|
-
let beforeSeq = this.info.record.beforeSeq;
|
|
825
|
-
let hasMore = this.info.record.hasMore;
|
|
826
|
-
for (let page = 0; hasMore && beforeSeq !== undefined && page < PROMPT_BACKFILL_PAGES; page++) {
|
|
827
|
-
abort.signal.throwIfAborted();
|
|
828
|
-
if (selection !== this.store.selection())
|
|
829
|
-
return;
|
|
830
|
-
const result = object(await this.host.require().call('session/page', { request: {
|
|
831
|
-
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
832
|
-
} }, abort.signal));
|
|
833
|
-
abort.signal.throwIfAborted();
|
|
834
|
-
if (selection !== this.store.selection())
|
|
835
|
-
return;
|
|
836
|
-
const temporary = new Transcript();
|
|
837
|
-
try {
|
|
838
|
-
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: result.records, hasMore: result.hasMore });
|
|
839
|
-
const next = temporary.beforeSeq;
|
|
840
|
-
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
841
|
-
throw new Error('Host history page did not advance');
|
|
842
|
-
this.prompts.prepend(temporary.promptsSince(-1).prompts);
|
|
843
|
-
beforeSeq = next;
|
|
844
|
-
hasMore = temporary.hasMore;
|
|
845
|
-
}
|
|
846
|
-
finally {
|
|
847
|
-
temporary.dispose();
|
|
848
|
-
}
|
|
849
|
-
// A scan that finished while this walk ran already established the same list.
|
|
850
|
-
if (this.adoptCachedPrompts(sessionId))
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
this.prompts.settle();
|
|
854
|
-
// Only a walk that ended because the host said "no more" makes the index exhaustive; one
|
|
855
|
-
// stopped by the page bound leaves the lazy backward step in charge of the rest.
|
|
856
|
-
if (!hasMore)
|
|
857
|
-
this.prompts.markComplete();
|
|
858
|
-
// Cache only what the walk established: advertising a capped or shed list would let a later
|
|
859
|
-
// open skip a fetch it still needs.
|
|
860
|
-
if (!hasMore)
|
|
861
|
-
this.promptCache.put(sessionId, { prompts: this.prompts.durableItems, complete: this.prompts.exhausted });
|
|
862
|
-
this.store.update({});
|
|
863
|
-
}
|
|
864
|
-
catch {
|
|
865
|
-
// A cancelled, disconnected or unavailable history leaves the lazy backward step in charge.
|
|
866
|
-
}
|
|
867
|
-
finally {
|
|
868
|
-
if (this.promptBackfill === abort)
|
|
869
|
-
this.promptBackfill = undefined;
|
|
870
|
-
}
|
|
871
|
-
})();
|
|
872
|
-
}
|
|
873
675
|
/** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
|
|
874
676
|
* @param query - Literal, case-insensitive text including folded reasoning.
|
|
875
677
|
* @param signal - Cancels HTTP and processing without cancelling the agent.
|
|
876
678
|
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
877
679
|
*/
|
|
878
|
-
async searchHistory(query, signal) {
|
|
879
|
-
|
|
880
|
-
if (!source.ready)
|
|
881
|
-
throw new Error('Wait for the session snapshot');
|
|
882
|
-
const sessionId = this.sessionId;
|
|
883
|
-
const selection = this.store.selection();
|
|
884
|
-
const throughSeq = source.readThrough;
|
|
885
|
-
const needle = query.toLowerCase();
|
|
886
|
-
const result = { items: [], truncated: false };
|
|
887
|
-
const scan = (transcript) => {
|
|
888
|
-
const messages = transcript.messages;
|
|
889
|
-
for (let index = messages.length - 1; index >= 0; index--) {
|
|
890
|
-
signal.throwIfAborted();
|
|
891
|
-
const message = messages[index];
|
|
892
|
-
if (message.role === 'Tool')
|
|
893
|
-
continue;
|
|
894
|
-
const text = message.text;
|
|
895
|
-
const match = text.toLowerCase().indexOf(needle);
|
|
896
|
-
if (match < 0)
|
|
897
|
-
continue;
|
|
898
|
-
if (result.items.length === SEARCH_MATCH_LIMIT) {
|
|
899
|
-
result.truncated = true;
|
|
900
|
-
return false;
|
|
901
|
-
}
|
|
902
|
-
result.items.push({ seq: message.seq, role: message.role, preview: Buffer.from(toolLine(text.slice(Math.max(0, match - 40), match + needle.length + 100), 160)).toString('utf8') });
|
|
903
|
-
}
|
|
904
|
-
return true;
|
|
905
|
-
};
|
|
906
|
-
signal.throwIfAborted();
|
|
907
|
-
if (!scan(source))
|
|
908
|
-
return result;
|
|
909
|
-
let beforeSeq = source.beforeSeq;
|
|
910
|
-
let hasMore = source.hasMore;
|
|
911
|
-
while (hasMore && beforeSeq !== undefined) {
|
|
912
|
-
const page = object(await this.host.require().call('session/page', { request: {
|
|
913
|
-
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
914
|
-
} }, signal));
|
|
915
|
-
signal.throwIfAborted();
|
|
916
|
-
if (selection !== this.store.selection())
|
|
917
|
-
throw new Error('Session changed while searching history');
|
|
918
|
-
const temporary = new Transcript();
|
|
919
|
-
try {
|
|
920
|
-
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
921
|
-
const next = temporary.beforeSeq;
|
|
922
|
-
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
923
|
-
throw new Error('Host history page did not advance');
|
|
924
|
-
if (!scan(temporary))
|
|
925
|
-
return result;
|
|
926
|
-
beforeSeq = next;
|
|
927
|
-
hasMore = temporary.hasMore;
|
|
928
|
-
}
|
|
929
|
-
finally {
|
|
930
|
-
temporary.dispose();
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
return result;
|
|
934
|
-
}
|
|
935
|
-
/** Load a separate small window ending at a search target; the live transcript keeps following.
|
|
680
|
+
async searchHistory(query, signal) { return this.history.searchHistory(query, signal); }
|
|
681
|
+
/** Select a readable window by sequence; temporary records never escape to the UI.
|
|
936
682
|
* @param target - Durable message sequence to display.
|
|
937
683
|
* @param signal - Cancels the target-page request.
|
|
938
|
-
* @returns A caller-owned historical window that must be disposed when closed.
|
|
939
684
|
*/
|
|
940
|
-
async
|
|
941
|
-
const source = this.info.record;
|
|
942
|
-
const selection = this.store.selection();
|
|
943
|
-
const page = object(await this.host.require().call('session/page', { request: {
|
|
944
|
-
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
|
|
945
|
-
beforeSeq: target + 1, maxMessages: 80,
|
|
946
|
-
} }, signal));
|
|
685
|
+
async openHistory(target, signal) {
|
|
947
686
|
signal.throwIfAborted();
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const
|
|
687
|
+
const selection = this.store.selection();
|
|
688
|
+
const record = this.info.record;
|
|
689
|
+
const displayed = this.info.window ?? record;
|
|
690
|
+
const loaded = displayed.messages.some(message => message.seq === target) ? displayed
|
|
691
|
+
: record.messages.some(message => message.seq === target) ? record : undefined;
|
|
692
|
+
const window = loaded ?? await this.history.historyAt(target, signal);
|
|
951
693
|
try {
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
throw new Error('
|
|
955
|
-
|
|
694
|
+
signal.throwIfAborted();
|
|
695
|
+
if (selection !== this.store.selection() || record !== this.info.record || this.store.state.screen !== 'chat') {
|
|
696
|
+
throw new Error('Session changed while opening history');
|
|
697
|
+
}
|
|
698
|
+
this.setViewWindow(window === record ? undefined : window);
|
|
956
699
|
}
|
|
957
700
|
catch (error) {
|
|
958
|
-
|
|
701
|
+
if (!loaded)
|
|
702
|
+
window.dispose();
|
|
959
703
|
throw error;
|
|
960
704
|
}
|
|
961
705
|
}
|
|
@@ -963,26 +707,12 @@ export class SessionController {
|
|
|
963
707
|
* @param target - Visible record sequence, or first for the oldest available history.
|
|
964
708
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
965
709
|
*/
|
|
966
|
-
async historyThrough(target, signal) {
|
|
967
|
-
const transcript = this.info.record;
|
|
968
|
-
if (!transcript.ready)
|
|
969
|
-
throw new Error('Wait for the session snapshot');
|
|
970
|
-
while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
|
|
971
|
-
signal.throwIfAborted();
|
|
972
|
-
const before = transcript.beforeSeq;
|
|
973
|
-
await this.older(signal);
|
|
974
|
-
if (this.info.record !== transcript)
|
|
975
|
-
throw new Error('Session changed while loading history');
|
|
976
|
-
if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
|
|
977
|
-
throw new Error('Host history page did not advance');
|
|
978
|
-
}
|
|
979
|
-
}
|
|
980
|
-
}
|
|
710
|
+
async historyThrough(target, signal) { await this.history.historyThrough(target, signal); }
|
|
981
711
|
/** Reclaim reloadable history unless the user is reading away from the tail.
|
|
982
712
|
* @returns Number of removed records.
|
|
983
713
|
*/
|
|
984
714
|
reclaimHistory() {
|
|
985
|
-
if (this.
|
|
715
|
+
if (this.historyPinned || !this.store.state.online)
|
|
986
716
|
return 0;
|
|
987
717
|
const removed = this.info.record.trimHistory(this.historyLimits);
|
|
988
718
|
if (removed)
|
|
@@ -991,8 +721,8 @@ export class SessionController {
|
|
|
991
721
|
}
|
|
992
722
|
/** Release the selected transcript and its layout caches. */
|
|
993
723
|
releaseTranscript() {
|
|
994
|
-
this.
|
|
995
|
-
this.promptBackfill
|
|
724
|
+
this.history.cancel();
|
|
725
|
+
this.promptBackfill.cancel();
|
|
996
726
|
this.info.reset();
|
|
997
727
|
}
|
|
998
728
|
/** @returns The selected session identity, or a `Select a session first` failure. */
|
|
@@ -1001,8 +731,4 @@ export class SessionController {
|
|
|
1001
731
|
throw new Error('Select a session first');
|
|
1002
732
|
return this.store.state.sessionId;
|
|
1003
733
|
}
|
|
1004
|
-
/** Answer one retained waterfall through the connection's event-result endpoint. */
|
|
1005
|
-
async reply(frame, outcome) {
|
|
1006
|
-
await this.connection.reply(frame, outcome);
|
|
1007
|
-
}
|
|
1008
734
|
}
|