@itookit/dsht 0.5.1 → 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 +4 -2
- package/README.zh.md +6 -4
- 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 +4 -1
- package/dist/cli/startup.js +30 -11
- package/dist/cli/verifier.d.ts +4 -0
- package/dist/cli/verifier.js +28 -5
- package/dist/contracts.d.ts +20 -5
- package/dist/controller/connection-streams.d.ts +22 -0
- package/dist/controller/connection-streams.js +105 -0
- package/dist/controller/connection.d.ts +14 -3
- package/dist/controller/connection.js +40 -69
- package/dist/controller/controller.d.ts +17 -233
- package/dist/controller/controller.js +108 -811
- package/dist/controller/foreground.d.ts +44 -0
- package/dist/controller/foreground.js +79 -0
- package/dist/controller/loop-coordinator.d.ts +48 -0
- package/dist/controller/loop-coordinator.js +647 -0
- package/dist/controller/verifier.d.ts +4 -0
- package/dist/cost/controller.d.ts +1 -1
- package/dist/cost/controller.js +12 -5
- package/dist/cost/scanner.js +1 -0
- package/dist/session/controller.d.ts +23 -35
- package/dist/session/controller.js +113 -363
- package/dist/session/history-reader.d.ts +32 -0
- package/dist/session/history-reader.js +170 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/info.d.ts +3 -38
- package/dist/session/info.js +14 -1
- package/dist/session/interactions.d.ts +26 -0
- package/dist/session/interactions.js +75 -0
- package/dist/session/navigator.d.ts +47 -0
- package/dist/session/navigator.js +158 -0
- package/dist/session/prompt-backfill.d.ts +23 -0
- package/dist/session/prompt-backfill.js +88 -0
- package/dist/session/state.d.ts +20 -0
- package/dist/session/state.js +1 -0
- package/dist/session/telemetry.d.ts +15 -6
- package/dist/session/telemetry.js +44 -7
- package/dist/session/transcript.d.ts +5 -1
- package/dist/slash/index.d.ts +1 -1
- package/dist/slash/parse.d.ts +2 -126
- package/dist/slash/registry.d.ts +1 -1
- package/dist/slash/types.d.ts +126 -0
- package/dist/slash/types.js +1 -0
- package/dist/state.d.ts +5 -17
- package/dist/state.js +1 -1
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/ui/app.js +86 -301
- package/dist/ui/chat/shell-view.d.ts +2 -0
- package/dist/ui/chat/shell-view.js +8 -0
- package/dist/ui/chat/use-history-view.d.ts +69 -0
- package/dist/ui/chat/use-history-view.js +123 -0
- package/dist/ui/dialogs/use-panels.d.ts +53 -0
- package/dist/ui/dialogs/use-panels.js +51 -0
- 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/package.json +1 -1
|
@@ -1,6 +1,5 @@
|
|
|
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";
|
|
@@ -11,15 +10,14 @@ import { fileReferences } from "./references.js";
|
|
|
11
10
|
import { SessionRuntime } from "./runtime.js";
|
|
12
11
|
import { PromptCache } from "./info.js";
|
|
13
12
|
import { SessionMutationGate } from "./mutation-gate.js";
|
|
14
|
-
import { recordPrompts
|
|
13
|
+
import { recordPrompts } from "./transcript.js";
|
|
15
14
|
import { projectionSnapshot } from "../transport/events.js";
|
|
16
|
-
import {
|
|
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";
|
|
17
19
|
/** Built-in preset identifiers and the labels the web session header shows. */
|
|
18
20
|
const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
19
|
-
/** Bounded match count for one history search. */
|
|
20
|
-
const SEARCH_MATCH_LIMIT = 200;
|
|
21
|
-
/** Bounded page count for the prompt backfill that runs once per opened session. */
|
|
22
|
-
const PROMPT_BACKFILL_PAGES = 200;
|
|
23
21
|
/** Owns the selected session: its follow stream, transcript, history window and interactions. */
|
|
24
22
|
export class SessionController {
|
|
25
23
|
store;
|
|
@@ -27,7 +25,9 @@ export class SessionController {
|
|
|
27
25
|
connection;
|
|
28
26
|
historyLimits;
|
|
29
27
|
follow;
|
|
30
|
-
|
|
28
|
+
history;
|
|
29
|
+
interactions;
|
|
30
|
+
navigation;
|
|
31
31
|
/** Reading protection: reclamation pauses while the reader is away from the live end. */
|
|
32
32
|
historyPinned = false;
|
|
33
33
|
/** Host runtime mirrors for every session this connection has seen. */
|
|
@@ -36,7 +36,7 @@ export class SessionController {
|
|
|
36
36
|
promptBackfill;
|
|
37
37
|
/** Prompts of sessions this process has already read, so re-opening one costs no page request. */
|
|
38
38
|
promptCache = new PromptCache();
|
|
39
|
-
/** Prompt index,
|
|
39
|
+
/** Prompt index, record, history window and interaction state of the selected session. */
|
|
40
40
|
get info() { return this.store.state.session; }
|
|
41
41
|
get prompts() { return this.info.prompts; }
|
|
42
42
|
stoppingSession;
|
|
@@ -50,6 +50,39 @@ export class SessionController {
|
|
|
50
50
|
this.connection = connection;
|
|
51
51
|
this.historyLimits = historyLimits;
|
|
52
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
|
+
});
|
|
53
86
|
}
|
|
54
87
|
/** Host running state covers model generation, tools, and waits between assistant attempts. */
|
|
55
88
|
get running() {
|
|
@@ -68,13 +101,13 @@ export class SessionController {
|
|
|
68
101
|
: row ? sessionLabel(row) : id;
|
|
69
102
|
}
|
|
70
103
|
/** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
|
|
71
|
-
|
|
104
|
+
sessionMode(presets = []) {
|
|
72
105
|
if (!this.store.state.sessionId)
|
|
73
106
|
return undefined;
|
|
74
107
|
const id = this.runtime.telemetry.view(this.store.state.sessionId).values.agentPreset;
|
|
75
108
|
if (typeof id !== 'string')
|
|
76
109
|
return undefined;
|
|
77
|
-
const preset =
|
|
110
|
+
const preset = presets.find(item => item.id === id);
|
|
78
111
|
return preset?.trust === 'system' && BUILT_IN_MODES.has(id) ? BUILT_IN_MODES.get(id)
|
|
79
112
|
: typeof preset?.name === 'string' ? preset.name : id;
|
|
80
113
|
}
|
|
@@ -85,14 +118,7 @@ export class SessionController {
|
|
|
85
118
|
return this.info.record.activeTurnStartedAt ?? this.runtime.observedAt(this.store.state.sessionId);
|
|
86
119
|
}
|
|
87
120
|
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
88
|
-
get visibleSessions() {
|
|
89
|
-
const sessions = this.store.state.sessions.filter(item => !this.host.client()?.archivedSessionIds.has(string(item.sessionId)));
|
|
90
|
-
if (this.store.state.showAllSessions || !this.store.state.workspaceId)
|
|
91
|
-
return sessions;
|
|
92
|
-
const workspace = this.store.state.workspaces.find(item => item.workspaceId === this.store.state.workspaceId);
|
|
93
|
-
const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
|
|
94
|
-
return sessions.filter(item => ids.has(string(item.sessionId)));
|
|
95
|
-
}
|
|
121
|
+
get visibleSessions() { return this.navigation.visibleSessions; }
|
|
96
122
|
/** Whether a turn, cancellation or prompt admission is still in flight. */
|
|
97
123
|
get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
|
|
98
124
|
/** @returns The detached history window the reader opened, if any. */
|
|
@@ -103,6 +129,8 @@ export class SessionController {
|
|
|
103
129
|
setViewWindow(window) {
|
|
104
130
|
if (this.info.window === window)
|
|
105
131
|
return;
|
|
132
|
+
if (this.info.window)
|
|
133
|
+
this.history.cancel(this.info.window);
|
|
106
134
|
this.info.closeWindow();
|
|
107
135
|
this.info.window = window;
|
|
108
136
|
this.store.update({});
|
|
@@ -125,21 +153,22 @@ export class SessionController {
|
|
|
125
153
|
beginGeneration() { this.stoppingSession = undefined; this.runtime.reset(); }
|
|
126
154
|
/** Invalidate in-flight work and drop transient interactions when a generation ends. */
|
|
127
155
|
endGeneration() {
|
|
156
|
+
this.promptBackfill.cancel();
|
|
157
|
+
this.navigation.reset();
|
|
158
|
+
this.history.cancel();
|
|
128
159
|
this.store.bumpSelection();
|
|
129
160
|
this.info.record.ready = false;
|
|
130
161
|
this.interactions.clear();
|
|
131
162
|
}
|
|
132
|
-
/** Wait for
|
|
133
|
-
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(); }
|
|
134
165
|
/** Release the selected transcript and its layout caches. */
|
|
135
|
-
release() { this.releaseTranscript(); }
|
|
166
|
+
release() { this.navigation.reset(); this.releaseTranscript(); }
|
|
136
167
|
/** Pending interactions for the selected chat session, derived independently of frame order.
|
|
137
|
-
* @param
|
|
168
|
+
* @param sessionId - Session whose pending interactions are being published.
|
|
138
169
|
* @returns Retained question and approval frames for that session.
|
|
139
170
|
*/
|
|
140
|
-
pendingFor(
|
|
141
|
-
return [...this.interactions.values()].filter(frame => frame.sessionId === state.sessionId);
|
|
142
|
-
}
|
|
171
|
+
pendingFor(sessionId) { return this.interactions.pendingFor(sessionId); }
|
|
143
172
|
/** Unanswered interactions by session, so a list can show who is waiting without opening them.
|
|
144
173
|
*
|
|
145
174
|
* The host delivers approval and question waterfalls for every session on one stream, and this
|
|
@@ -148,37 +177,16 @@ export class SessionController {
|
|
|
148
177
|
* until the host replays the pending waterfalls.
|
|
149
178
|
* @returns One count per session holding at least one unanswered interaction.
|
|
150
179
|
*/
|
|
151
|
-
pendingCounts() {
|
|
152
|
-
const counts = new Map();
|
|
153
|
-
for (const frame of this.interactions.values()) {
|
|
154
|
-
if (frame.sessionId === '')
|
|
155
|
-
continue;
|
|
156
|
-
counts.set(frame.sessionId, (counts.get(frame.sessionId) ?? 0) + 1);
|
|
157
|
-
}
|
|
158
|
-
return counts;
|
|
159
|
-
}
|
|
180
|
+
pendingCounts() { return this.interactions.counts(); }
|
|
160
181
|
/** Retain a recognized host interaction; anything else belongs to the connection to delegate.
|
|
161
182
|
* @param event - One normalized host event.
|
|
162
183
|
* @returns Whether this domain retained the event for an answer.
|
|
163
184
|
*/
|
|
164
|
-
accept(event) {
|
|
165
|
-
if (event.kind === 'approval-request') {
|
|
166
|
-
this.interactions.set(event.eventId, { kind: 'approval', eventId: event.eventId, sessionId: event.sessionId,
|
|
167
|
-
description: event.description });
|
|
168
|
-
}
|
|
169
|
-
else if (event.kind === 'question-request') {
|
|
170
|
-
this.interactions.set(event.eventId, { kind: 'question', eventId: event.eventId, sessionId: event.sessionId,
|
|
171
|
-
questions: event.questions });
|
|
172
|
-
}
|
|
173
|
-
else
|
|
174
|
-
return false;
|
|
175
|
-
this.store.update({});
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
185
|
+
accept(event) { return this.interactions.accept(event); }
|
|
178
186
|
/** Drop a waterfall the host cancelled.
|
|
179
187
|
* @param eventId - Correlation id previously retained.
|
|
180
188
|
*/
|
|
181
|
-
cancelled(eventId) { this.interactions.
|
|
189
|
+
cancelled(eventId) { this.interactions.cancelled(eventId); }
|
|
182
190
|
/** Apply one host running-state notification to the session list and status line.
|
|
183
191
|
* @param sessionId - Session whose state changed.
|
|
184
192
|
* @param running - Whether the host still runs that session.
|
|
@@ -254,23 +262,18 @@ export class SessionController {
|
|
|
254
262
|
/** Refresh both lists from the host, then show the requested picker.
|
|
255
263
|
* @param screen - Picker to display after the refresh.
|
|
256
264
|
*/
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
|
|
260
|
-
this.store.update({ screen, workspaces, sessions });
|
|
265
|
+
showPicker(screen, signal) {
|
|
266
|
+
return this.navigation.showPicker(screen, signal);
|
|
261
267
|
}
|
|
268
|
+
/** Refresh navigation data without taking the reader to another screen. */
|
|
269
|
+
refreshLists() { return this.navigation.refreshLists(); }
|
|
262
270
|
/** Return to the selected conversation without re-selecting it.
|
|
263
271
|
*
|
|
264
272
|
* A picker opened over a conversation is a detour: leaving it must not re-subscribe, reload the
|
|
265
273
|
* transcript or lose the reading position, so this only changes which screen is shown.
|
|
266
274
|
* @returns Whether there was a selected conversation to return to.
|
|
267
275
|
*/
|
|
268
|
-
showChat() {
|
|
269
|
-
if (this.store.state.sessionId === undefined)
|
|
270
|
-
return false;
|
|
271
|
-
this.store.update({ screen: 'chat' });
|
|
272
|
-
return true;
|
|
273
|
-
}
|
|
276
|
+
showChat() { return this.navigation.showChat(); }
|
|
274
277
|
/** Resolve a removal command to one reviewable object without changing the selection.
|
|
275
278
|
* @param kind - Workspace registration removal or session archival.
|
|
276
279
|
* @param query - Exact name, ID, or unambiguous ID prefix.
|
|
@@ -293,12 +296,14 @@ export class SessionController {
|
|
|
293
296
|
*/
|
|
294
297
|
async removeTarget(target) {
|
|
295
298
|
const client = this.host.require();
|
|
299
|
+
const intent = this.navigation.intent;
|
|
296
300
|
const receipt = object(target.kind === 'workspace'
|
|
297
301
|
? await client.call('workspace/delete', { request: { workspaceId: target.id } })
|
|
298
302
|
// Archiving a session is a write on that session, so it queues behind that session's admissions.
|
|
299
303
|
: await this.mutations.admit(target.id, 'normal', () => client.call('workspace/archiveSession', { request: { sessionId: target.id } })));
|
|
300
304
|
if (client !== this.host.client())
|
|
301
305
|
return;
|
|
306
|
+
const navigate = intent === this.navigation.intent;
|
|
302
307
|
if (target.kind === 'session') {
|
|
303
308
|
client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
|
|
304
309
|
if (this.store.state.sessionId === target.id)
|
|
@@ -310,11 +315,11 @@ export class SessionController {
|
|
|
310
315
|
this.store.update({ workspaces: this.store.state.workspaces.filter(row => row.workspaceId !== target.id),
|
|
311
316
|
...(this.store.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
|
|
312
317
|
}
|
|
313
|
-
this.store.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
|
|
318
|
+
this.store.update({ ...(navigate ? { screen: target.kind === 'workspace' ? 'workspaces' : 'sessions' } : {}),
|
|
314
319
|
status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
|
|
315
320
|
// A refresh failure must not make a successful mutation look like a rejected deletion.
|
|
316
321
|
try {
|
|
317
|
-
await this.
|
|
322
|
+
await this.refreshLists();
|
|
318
323
|
}
|
|
319
324
|
catch (error) {
|
|
320
325
|
this.store.update({ lastFailure: `Removal completed; list refresh failed: ${errorText(error)}` });
|
|
@@ -323,45 +328,17 @@ export class SessionController {
|
|
|
323
328
|
/** Pick a workspace, or use all sessions when the identity is omitted.
|
|
324
329
|
* @param workspaceId - Workspace to select, if any.
|
|
325
330
|
*/
|
|
326
|
-
pickWorkspace(workspaceId) {
|
|
327
|
-
this.store.bumpSelection();
|
|
328
|
-
this.follow?.cancel();
|
|
329
|
-
this.follow = undefined;
|
|
330
|
-
this.releaseTranscript();
|
|
331
|
-
this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
|
|
332
|
-
}
|
|
331
|
+
pickWorkspace(workspaceId) { this.navigation.pickWorkspace(workspaceId); }
|
|
333
332
|
/** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
|
|
334
333
|
* @param query - Workspace target, if any.
|
|
335
334
|
*/
|
|
336
|
-
|
|
337
|
-
if (!query) {
|
|
338
|
-
await this.showPicker('workspaces');
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
const client = this.host.require();
|
|
342
|
-
const workspaces = await client.listWorkspaces();
|
|
343
|
-
const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
|
|
344
|
-
const sessions = await client.listSessions();
|
|
345
|
-
this.store.update({ workspaces, sessions });
|
|
346
|
-
this.pickWorkspace(string(workspace.workspaceId));
|
|
347
|
-
}
|
|
335
|
+
switchWorkspace(query, signal) { return this.navigation.switchWorkspace(query, signal); }
|
|
348
336
|
/** Guide workspace selection, list all sessions with `all`, or resolve an exact session target.
|
|
349
337
|
* @param query - Session target, `all`, or nothing for the guided picker.
|
|
350
338
|
*/
|
|
351
|
-
|
|
352
|
-
if (!query || query === 'all') {
|
|
353
|
-
await this.showPicker(query === 'all' || this.store.state.workspaceId ? 'sessions' : 'workspaces');
|
|
354
|
-
this.store.update({ showAllSessions: query === 'all' });
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const client = this.host.require();
|
|
358
|
-
const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
|
|
359
|
-
const session = resolveTarget(sessions, query, 'sessionId', item => [sessionLabel(item)]);
|
|
360
|
-
this.store.update({ workspaces, sessions });
|
|
361
|
-
await this.selectSession(string(session.sessionId));
|
|
362
|
-
}
|
|
339
|
+
switchSession(query, signal) { return this.navigation.switchSession(query, signal); }
|
|
363
340
|
/** Prompt for a host path without starting a local agent. */
|
|
364
|
-
enterPath() { this.
|
|
341
|
+
enterPath() { this.navigation.enterPath(); }
|
|
365
342
|
/** Adopt the workspace whose registered path contains the directory this client runs in.
|
|
366
343
|
*
|
|
367
344
|
* Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
|
|
@@ -370,40 +347,13 @@ export class SessionController {
|
|
|
370
347
|
* @param directory - Directory this client was started in.
|
|
371
348
|
* @returns The adopted workspace's id, or undefined when none matches.
|
|
372
349
|
*/
|
|
373
|
-
adoptLocalWorkspace(directory) {
|
|
374
|
-
const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
375
|
-
const target = slashed(directory);
|
|
376
|
-
if (target === '')
|
|
377
|
-
return undefined;
|
|
378
|
-
let best;
|
|
379
|
-
for (const workspace of this.store.state.workspaces) {
|
|
380
|
-
const path = slashed(string(workspace.path));
|
|
381
|
-
if (path === '' || (target !== path && !target.startsWith(`${path}/`)))
|
|
382
|
-
continue;
|
|
383
|
-
if (best === undefined || path.length > best.length)
|
|
384
|
-
best = { id: string(workspace.workspaceId), length: path.length };
|
|
385
|
-
}
|
|
386
|
-
if (best === undefined)
|
|
387
|
-
return undefined;
|
|
388
|
-
this.pickWorkspace(best.id);
|
|
389
|
-
return best.id;
|
|
390
|
-
}
|
|
350
|
+
adoptLocalWorkspace(directory) { return this.navigation.adoptLocalWorkspace(directory); }
|
|
391
351
|
/** Register a host directory and move to its session picker.
|
|
392
352
|
* @param path - Absolute directory path on the host.
|
|
393
353
|
*/
|
|
394
|
-
|
|
395
|
-
const result = object(await this.host.require().call('workspace/create', { request: { path } }));
|
|
396
|
-
const workspace = object(result.workspace);
|
|
397
|
-
await this.showPicker('workspaces');
|
|
398
|
-
this.pickWorkspace(string(workspace.workspaceId));
|
|
399
|
-
}
|
|
354
|
+
createWorkspace(path, signal) { return this.navigation.createWorkspace(path, signal); }
|
|
400
355
|
/** Create a session only after the user explicitly selects New session. */
|
|
401
|
-
|
|
402
|
-
if (!this.store.state.workspaceId)
|
|
403
|
-
throw new Error('Select a workspace before creating a session');
|
|
404
|
-
const result = object(await this.host.require().call('session/create', { request: { workspaceId: this.store.state.workspaceId } }));
|
|
405
|
-
await this.selectSession(string(result.sessionId));
|
|
406
|
-
}
|
|
356
|
+
createSession(signal) { return this.navigation.createSession(signal); }
|
|
407
357
|
/** Create a session for another purpose without selecting it, named so a reader can tell it apart.
|
|
408
358
|
*
|
|
409
359
|
* A verifier runs in its own session while the reviewed session stays selected, so this never
|
|
@@ -437,6 +387,18 @@ export class SessionController {
|
|
|
437
387
|
* @param sessionId - Session to follow.
|
|
438
388
|
*/
|
|
439
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();
|
|
440
402
|
this.stoppingSession = undefined;
|
|
441
403
|
this.store.bumpSelection();
|
|
442
404
|
const selection = this.store.selection();
|
|
@@ -448,7 +410,8 @@ export class SessionController {
|
|
|
448
410
|
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
449
411
|
: this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
|
|
450
412
|
this.runtime.observe(sessionId);
|
|
451
|
-
this.store.update({ sessionId,
|
|
413
|
+
this.store.update({ sessionId, status: 'Loading session…',
|
|
414
|
+
...(navigate ? { workspaceId, showAllSessions: false, screen: 'chat' } : {}) });
|
|
452
415
|
this.follow = this.host.require().subscribe('session/follow', {
|
|
453
416
|
request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
|
|
454
417
|
}, {
|
|
@@ -475,23 +438,12 @@ export class SessionController {
|
|
|
475
438
|
this.store.update({ status: 'Session disconnected', lastFailure: errorText(error ?? 'Session stream ended') });
|
|
476
439
|
},
|
|
477
440
|
});
|
|
478
|
-
this.
|
|
441
|
+
this.promptBackfill.start({ sessionId, revision: selection, record: transcript, prompts: this.prompts });
|
|
479
442
|
}
|
|
480
443
|
/** Wait for the selected follow snapshot, failing on disconnect or cancellation.
|
|
481
444
|
* @param signal - Cancels waiting without closing the session.
|
|
482
445
|
*/
|
|
483
|
-
async waitForHistory(signal) {
|
|
484
|
-
const transcript = this.info.record;
|
|
485
|
-
const deadline = Date.now() + this.host.require().timeoutMs;
|
|
486
|
-
while (!transcript.ready) {
|
|
487
|
-
signal.throwIfAborted();
|
|
488
|
-
if (!this.store.state.online || this.info.record !== transcript)
|
|
489
|
-
throw new Error('Session changed while loading history');
|
|
490
|
-
if (Date.now() >= deadline)
|
|
491
|
-
throw new Error('Session snapshot timed out');
|
|
492
|
-
await delay(20, undefined, { signal });
|
|
493
|
-
}
|
|
494
|
-
}
|
|
446
|
+
async waitForHistory(signal) { await this.history.waitForHistory(signal); }
|
|
495
447
|
/** Search the host's bounded global results, optionally retaining workspace members.
|
|
496
448
|
* @param query - Literal message text.
|
|
497
449
|
* @param workspaceOnly - Restrict returned hits to the selected workspace's session IDs.
|
|
@@ -620,41 +572,18 @@ export class SessionController {
|
|
|
620
572
|
/** Answer the oldest selected-session interaction, after explicit user action.
|
|
621
573
|
* @param value - Structured answer value or approval outcome.
|
|
622
574
|
*/
|
|
623
|
-
async answer(value) {
|
|
624
|
-
const pending = this.store.state.pending[0];
|
|
625
|
-
if (!pending)
|
|
626
|
-
throw new Error('No pending interaction');
|
|
627
|
-
await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'result', value }));
|
|
628
|
-
this.interactions.delete(pending.eventId);
|
|
629
|
-
this.store.update({});
|
|
630
|
-
}
|
|
575
|
+
async answer(value) { await this.interactions.answer(value); }
|
|
631
576
|
/** Restrict an approval command to an approval request.
|
|
632
577
|
* @param allowed - Whether the request is approved once.
|
|
633
578
|
*/
|
|
634
|
-
async approve(allowed) {
|
|
635
|
-
const pending = this.store.state.pending[0];
|
|
636
|
-
if (pending?.kind !== 'approval')
|
|
637
|
-
throw new Error('No pending approval');
|
|
638
|
-
await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'result', value: allowed ? 'allowed-once' : 'rejected' }));
|
|
639
|
-
this.interactions.delete(pending.eventId);
|
|
640
|
-
this.store.update({});
|
|
641
|
-
}
|
|
579
|
+
async approve(allowed) { await this.interactions.approve(allowed); }
|
|
642
580
|
/** Dismiss the whole selected-session question set without answering it.
|
|
643
581
|
*
|
|
644
582
|
* The Web client's close button settles the same waterfall the same way — reject with
|
|
645
583
|
* `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
|
|
646
584
|
* batch is answered as one request, so dismissals also discard partial local answers.
|
|
647
585
|
*/
|
|
648
|
-
async dismissQuestion() {
|
|
649
|
-
const pending = this.store.state.pending[0];
|
|
650
|
-
if (pending?.kind !== 'question')
|
|
651
|
-
throw new Error('No pending question');
|
|
652
|
-
await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'rejected', error: {
|
|
653
|
-
name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
|
|
654
|
-
} }));
|
|
655
|
-
this.interactions.delete(pending.eventId);
|
|
656
|
-
this.store.update({});
|
|
657
|
-
}
|
|
586
|
+
async dismissQuestion() { await this.interactions.dismissQuestion(); }
|
|
658
587
|
/** Add a page before the retained window using its fixed opening cut.
|
|
659
588
|
*
|
|
660
589
|
* Reclamation is deliberately not pinned here. Every caller that needs the fetched page to
|
|
@@ -665,19 +594,7 @@ export class SessionController {
|
|
|
665
594
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
666
595
|
* @param transcript - Transcript to extend; defaults to the live one.
|
|
667
596
|
*/
|
|
668
|
-
async older(signal, transcript = this.info.record) {
|
|
669
|
-
const selection = this.store.selection();
|
|
670
|
-
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
671
|
-
return;
|
|
672
|
-
const result = await this.host.require().call('session/page', { request: {
|
|
673
|
-
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
|
|
674
|
-
beforeSeq: transcript.beforeSeq, maxMessages: 80,
|
|
675
|
-
} }, signal);
|
|
676
|
-
if (selection !== this.store.selection())
|
|
677
|
-
return;
|
|
678
|
-
transcript.addPage(result);
|
|
679
|
-
this.store.update({});
|
|
680
|
-
}
|
|
597
|
+
async older(signal, transcript = this.info.record) { await this.history.older(signal, transcript); }
|
|
681
598
|
/** Recall one step through the session's prompt index; navigation never touches the network.
|
|
682
599
|
* @param direction - Negative for older input, positive for newer input.
|
|
683
600
|
* @param current - Composer content before recall began, restored at the newest position.
|
|
@@ -739,24 +656,6 @@ export class SessionController {
|
|
|
739
656
|
return false;
|
|
740
657
|
return this.prompts.prepend(this.info.record.promptsBefore(oldest)) > 0;
|
|
741
658
|
}
|
|
742
|
-
/** Seed recall from a complete cached entry, so an open that follows a scan costs no request.
|
|
743
|
-
* @param sessionId - Session being opened.
|
|
744
|
-
* @returns Whether the cache covered this session.
|
|
745
|
-
*/
|
|
746
|
-
adoptCachedPrompts(sessionId) {
|
|
747
|
-
const cached = this.promptCache.get(sessionId);
|
|
748
|
-
if (!cached?.complete)
|
|
749
|
-
return false;
|
|
750
|
-
const oldest = this.prompts.oldest;
|
|
751
|
-
const older = oldest === undefined ? cached.prompts : cached.prompts.filter(prompt => prompt.seq < oldest);
|
|
752
|
-
this.prompts.prepend(older);
|
|
753
|
-
// Same budget as the walk it replaces: `settle` may shed the oldest prefix, and `markComplete`
|
|
754
|
-
// then refuses, so the lazy backward step stays available for whatever was shed.
|
|
755
|
-
this.prompts.settle();
|
|
756
|
-
this.prompts.markComplete();
|
|
757
|
-
this.store.update({});
|
|
758
|
-
return true;
|
|
759
|
-
}
|
|
760
659
|
/** Fold one history page the cost scan already read into the prompt cache.
|
|
761
660
|
*
|
|
762
661
|
* The scan reads every session's whole history on connect, so this is where two readers stop
|
|
@@ -773,165 +672,34 @@ export class SessionController {
|
|
|
773
672
|
rememberScanDone(sessionId) {
|
|
774
673
|
this.promptCache.observe(sessionId, [], true);
|
|
775
674
|
}
|
|
776
|
-
/** Fold every prompt the host still holds into the recall index, in the background.
|
|
777
|
-
*
|
|
778
|
-
* Session start delivers only the newest window, so without this the arrows could reach older
|
|
779
|
-
* prompts but not show them without paging first. Each page is parsed into a temporary transcript
|
|
780
|
-
* and only its prompts are kept, so the live record, its memory window and the row cache never
|
|
781
|
-
* grow. The walk is bounded and the next selection cancels it; anything past the bound is still
|
|
782
|
-
* reachable through the lazy backward step.
|
|
783
|
-
* @param sessionId - Session being opened.
|
|
784
|
-
* @param selection - Selector generation that must still be current.
|
|
785
|
-
*/
|
|
786
|
-
backfillPrompts(sessionId, selection) {
|
|
787
|
-
this.promptBackfill?.abort();
|
|
788
|
-
const abort = new AbortController();
|
|
789
|
-
this.promptBackfill = abort;
|
|
790
|
-
void (async () => {
|
|
791
|
-
try {
|
|
792
|
-
while (!this.info.record.ready) {
|
|
793
|
-
abort.signal.throwIfAborted();
|
|
794
|
-
await delay(20, undefined, { signal: abort.signal });
|
|
795
|
-
}
|
|
796
|
-
// A cost scan or an earlier open may already have this session's prompts cached.
|
|
797
|
-
if (this.adoptCachedPrompts(sessionId))
|
|
798
|
-
return;
|
|
799
|
-
const throughSeq = this.info.record.readThrough;
|
|
800
|
-
let beforeSeq = this.info.record.beforeSeq;
|
|
801
|
-
let hasMore = this.info.record.hasMore;
|
|
802
|
-
for (let page = 0; hasMore && beforeSeq !== undefined && page < PROMPT_BACKFILL_PAGES; page++) {
|
|
803
|
-
abort.signal.throwIfAborted();
|
|
804
|
-
if (selection !== this.store.selection())
|
|
805
|
-
return;
|
|
806
|
-
const result = object(await this.host.require().call('session/page', { request: {
|
|
807
|
-
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
808
|
-
} }, abort.signal));
|
|
809
|
-
abort.signal.throwIfAborted();
|
|
810
|
-
if (selection !== this.store.selection())
|
|
811
|
-
return;
|
|
812
|
-
const temporary = new Transcript();
|
|
813
|
-
try {
|
|
814
|
-
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: result.records, hasMore: result.hasMore });
|
|
815
|
-
const next = temporary.beforeSeq;
|
|
816
|
-
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
817
|
-
throw new Error('Host history page did not advance');
|
|
818
|
-
this.prompts.prepend(temporary.promptsSince(-1).prompts);
|
|
819
|
-
beforeSeq = next;
|
|
820
|
-
hasMore = temporary.hasMore;
|
|
821
|
-
}
|
|
822
|
-
finally {
|
|
823
|
-
temporary.dispose();
|
|
824
|
-
}
|
|
825
|
-
// A scan that finished while this walk ran already established the same list.
|
|
826
|
-
if (this.adoptCachedPrompts(sessionId))
|
|
827
|
-
return;
|
|
828
|
-
}
|
|
829
|
-
this.prompts.settle();
|
|
830
|
-
// Only a walk that ended because the host said "no more" makes the index exhaustive; one
|
|
831
|
-
// stopped by the page bound leaves the lazy backward step in charge of the rest.
|
|
832
|
-
if (!hasMore)
|
|
833
|
-
this.prompts.markComplete();
|
|
834
|
-
// Cache only what the walk established: advertising a capped or shed list would let a later
|
|
835
|
-
// open skip a fetch it still needs.
|
|
836
|
-
if (!hasMore)
|
|
837
|
-
this.promptCache.put(sessionId, { prompts: this.prompts.durableItems, complete: this.prompts.exhausted });
|
|
838
|
-
this.store.update({});
|
|
839
|
-
}
|
|
840
|
-
catch {
|
|
841
|
-
// A cancelled, disconnected or unavailable history leaves the lazy backward step in charge.
|
|
842
|
-
}
|
|
843
|
-
finally {
|
|
844
|
-
if (this.promptBackfill === abort)
|
|
845
|
-
this.promptBackfill = undefined;
|
|
846
|
-
}
|
|
847
|
-
})();
|
|
848
|
-
}
|
|
849
675
|
/** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
|
|
850
676
|
* @param query - Literal, case-insensitive text including folded reasoning.
|
|
851
677
|
* @param signal - Cancels HTTP and processing without cancelling the agent.
|
|
852
678
|
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
853
679
|
*/
|
|
854
|
-
async searchHistory(query, signal) {
|
|
855
|
-
|
|
856
|
-
if (!source.ready)
|
|
857
|
-
throw new Error('Wait for the session snapshot');
|
|
858
|
-
const sessionId = this.sessionId;
|
|
859
|
-
const selection = this.store.selection();
|
|
860
|
-
const throughSeq = source.readThrough;
|
|
861
|
-
const needle = query.toLowerCase();
|
|
862
|
-
const result = { items: [], truncated: false };
|
|
863
|
-
const scan = (transcript) => {
|
|
864
|
-
const messages = transcript.messages;
|
|
865
|
-
for (let index = messages.length - 1; index >= 0; index--) {
|
|
866
|
-
signal.throwIfAborted();
|
|
867
|
-
const message = messages[index];
|
|
868
|
-
if (message.role === 'Tool')
|
|
869
|
-
continue;
|
|
870
|
-
const text = message.text;
|
|
871
|
-
const match = text.toLowerCase().indexOf(needle);
|
|
872
|
-
if (match < 0)
|
|
873
|
-
continue;
|
|
874
|
-
if (result.items.length === SEARCH_MATCH_LIMIT) {
|
|
875
|
-
result.truncated = true;
|
|
876
|
-
return false;
|
|
877
|
-
}
|
|
878
|
-
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') });
|
|
879
|
-
}
|
|
880
|
-
return true;
|
|
881
|
-
};
|
|
882
|
-
signal.throwIfAborted();
|
|
883
|
-
if (!scan(source))
|
|
884
|
-
return result;
|
|
885
|
-
let beforeSeq = source.beforeSeq;
|
|
886
|
-
let hasMore = source.hasMore;
|
|
887
|
-
while (hasMore && beforeSeq !== undefined) {
|
|
888
|
-
const page = object(await this.host.require().call('session/page', { request: {
|
|
889
|
-
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
890
|
-
} }, signal));
|
|
891
|
-
signal.throwIfAborted();
|
|
892
|
-
if (selection !== this.store.selection())
|
|
893
|
-
throw new Error('Session changed while searching history');
|
|
894
|
-
const temporary = new Transcript();
|
|
895
|
-
try {
|
|
896
|
-
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
897
|
-
const next = temporary.beforeSeq;
|
|
898
|
-
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
899
|
-
throw new Error('Host history page did not advance');
|
|
900
|
-
if (!scan(temporary))
|
|
901
|
-
return result;
|
|
902
|
-
beforeSeq = next;
|
|
903
|
-
hasMore = temporary.hasMore;
|
|
904
|
-
}
|
|
905
|
-
finally {
|
|
906
|
-
temporary.dispose();
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
return result;
|
|
910
|
-
}
|
|
911
|
-
/** 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.
|
|
912
682
|
* @param target - Durable message sequence to display.
|
|
913
683
|
* @param signal - Cancels the target-page request.
|
|
914
|
-
* @returns A caller-owned historical window that must be disposed when closed.
|
|
915
684
|
*/
|
|
916
|
-
async
|
|
917
|
-
const source = this.info.record;
|
|
918
|
-
const selection = this.store.selection();
|
|
919
|
-
const page = object(await this.host.require().call('session/page', { request: {
|
|
920
|
-
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
|
|
921
|
-
beforeSeq: target + 1, maxMessages: 80,
|
|
922
|
-
} }, signal));
|
|
685
|
+
async openHistory(target, signal) {
|
|
923
686
|
signal.throwIfAborted();
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
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);
|
|
927
693
|
try {
|
|
928
|
-
|
|
929
|
-
if (
|
|
930
|
-
throw new Error('
|
|
931
|
-
|
|
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);
|
|
932
699
|
}
|
|
933
700
|
catch (error) {
|
|
934
|
-
|
|
701
|
+
if (!loaded)
|
|
702
|
+
window.dispose();
|
|
935
703
|
throw error;
|
|
936
704
|
}
|
|
937
705
|
}
|
|
@@ -939,21 +707,7 @@ export class SessionController {
|
|
|
939
707
|
* @param target - Visible record sequence, or first for the oldest available history.
|
|
940
708
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
941
709
|
*/
|
|
942
|
-
async historyThrough(target, signal) {
|
|
943
|
-
const transcript = this.info.record;
|
|
944
|
-
if (!transcript.ready)
|
|
945
|
-
throw new Error('Wait for the session snapshot');
|
|
946
|
-
while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
|
|
947
|
-
signal.throwIfAborted();
|
|
948
|
-
const before = transcript.beforeSeq;
|
|
949
|
-
await this.older(signal);
|
|
950
|
-
if (this.info.record !== transcript)
|
|
951
|
-
throw new Error('Session changed while loading history');
|
|
952
|
-
if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
|
|
953
|
-
throw new Error('Host history page did not advance');
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
}
|
|
710
|
+
async historyThrough(target, signal) { await this.history.historyThrough(target, signal); }
|
|
957
711
|
/** Reclaim reloadable history unless the user is reading away from the tail.
|
|
958
712
|
* @returns Number of removed records.
|
|
959
713
|
*/
|
|
@@ -967,8 +721,8 @@ export class SessionController {
|
|
|
967
721
|
}
|
|
968
722
|
/** Release the selected transcript and its layout caches. */
|
|
969
723
|
releaseTranscript() {
|
|
970
|
-
this.
|
|
971
|
-
this.promptBackfill
|
|
724
|
+
this.history.cancel();
|
|
725
|
+
this.promptBackfill.cancel();
|
|
972
726
|
this.info.reset();
|
|
973
727
|
}
|
|
974
728
|
/** @returns The selected session identity, or a `Select a session first` failure. */
|
|
@@ -977,8 +731,4 @@ export class SessionController {
|
|
|
977
731
|
throw new Error('Select a session first');
|
|
978
732
|
return this.store.state.sessionId;
|
|
979
733
|
}
|
|
980
|
-
/** Answer one retained waterfall through the connection's event-result endpoint. */
|
|
981
|
-
async reply(eventId, outcome) {
|
|
982
|
-
await this.connection.reply(eventId, outcome);
|
|
983
|
-
}
|
|
984
734
|
}
|