@itookit/dsht 0.3.7 → 0.5.1
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 +31 -11
- package/README.zh.md +31 -11
- package/dist/cli/dsht.js +207 -19
- package/dist/cli/startup.d.ts +40 -0
- package/dist/cli/startup.js +295 -0
- package/dist/cli/trace-summary.d.ts +78 -0
- package/dist/cli/trace-summary.js +241 -0
- package/dist/cli/verifier.d.ts +60 -0
- package/dist/cli/verifier.js +242 -0
- package/dist/contracts.d.ts +344 -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.d.ts +11 -29
- package/dist/controller/connection.js +26 -60
- package/dist/controller/controller.d.ts +619 -164
- package/dist/controller/controller.js +1420 -141
- 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-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 +126 -0
- package/dist/controller/verifier.js +75 -0
- 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/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 +82 -72
- package/dist/session/controller.js +211 -209
- package/dist/session/history.d.ts +9 -1
- package/dist/session/history.js +1 -9
- package/dist/session/index.d.ts +9 -4
- package/dist/session/index.js +7 -3
- package/dist/session/info.d.ts +25 -52
- package/dist/session/info.js +39 -25
- 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/peek.d.ts +38 -0
- package/dist/session/peek.js +103 -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/telemetry.d.ts +12 -13
- package/dist/session/telemetry.js +27 -58
- package/dist/session/transcript.d.ts +0 -6
- 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 +97 -0
- package/dist/shell/controller.js +158 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +3 -0
- package/dist/shell/runner.d.ts +38 -0
- package/dist/shell/runner.js +147 -0
- package/dist/slash/index.d.ts +10 -0
- package/dist/slash/index.js +7 -0
- package/dist/slash/parse.d.ts +166 -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/state.d.ts +14 -4
- package/dist/state.js +3 -2
- package/dist/text.d.ts +28 -0
- package/dist/text.js +55 -0
- 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 +865 -431
- package/dist/ui/chat/header.js +1 -1
- package/dist/ui/chat/history-view.d.ts +1 -1
- package/dist/ui/chat/history-view.js +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 +47 -0
- package/dist/ui/chat/shell-view.js +145 -0
- package/dist/ui/chat/status.d.ts +47 -3
- package/dist/ui/chat/status.js +65 -50
- 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/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/status/model.d.ts +7 -0
- package/dist/ui/status/model.js +5 -0
- package/dist/ui/theme/index.d.ts +6 -1
- package/dist/ui/theme/index.js +2 -1
- package/package.json +6 -4
- package/dist/ui/commands/parse.d.ts +0 -99
- package/dist/ui/commands/parse.js +0 -126
- package/dist/ui/commands/registry.d.ts +0 -33
- package/dist/ui/commands/registry.js +0 -73
|
@@ -4,11 +4,16 @@ import { setTimeout as delay } from 'node:timers/promises';
|
|
|
4
4
|
import { array, errorText, object, string } from "../transport/wire.js";
|
|
5
5
|
import { saveSessionLog } from "./export.js";
|
|
6
6
|
import { saveTranscriptHtml } from "./export-html.js";
|
|
7
|
-
import { releaseHistoryLayout } from "./history.js";
|
|
8
|
-
import { resolveTarget
|
|
7
|
+
import { historyLayout, releaseHistoryLayout } from "./history.js";
|
|
8
|
+
import { resolveTarget } from "./navigation.js";
|
|
9
|
+
import { sessionLabel } from "../session-title.js";
|
|
9
10
|
import { fileReferences } from "./references.js";
|
|
11
|
+
import { SessionRuntime } from "./runtime.js";
|
|
10
12
|
import { PromptCache } from "./info.js";
|
|
11
|
-
import {
|
|
13
|
+
import { SessionMutationGate } from "./mutation-gate.js";
|
|
14
|
+
import { recordPrompts, Transcript } from "./transcript.js";
|
|
15
|
+
import { projectionSnapshot } from "../transport/events.js";
|
|
16
|
+
import { toolLine } from "../text.js";
|
|
12
17
|
/** Built-in preset identifiers and the labels the web session header shows. */
|
|
13
18
|
const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
14
19
|
/** Bounded match count for one history search. */
|
|
@@ -23,6 +28,10 @@ export class SessionController {
|
|
|
23
28
|
historyLimits;
|
|
24
29
|
follow;
|
|
25
30
|
interactions = new Map();
|
|
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. */
|
|
@@ -33,16 +42,19 @@ export class SessionController {
|
|
|
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);
|
|
41
53
|
}
|
|
42
54
|
/** Host running state covers model generation, tools, and waits between assistant attempts. */
|
|
43
55
|
get running() {
|
|
44
56
|
const id = this.store.state.sessionId;
|
|
45
|
-
return id !== undefined && (this.
|
|
57
|
+
return id !== undefined && (this.runtime.runningFor(id)
|
|
46
58
|
?? this.store.state.sessions.find(row => row.sessionId === id)?.running === true);
|
|
47
59
|
}
|
|
48
60
|
/** Current title projection, falling back to the list title and then the session ID. */
|
|
@@ -50,7 +62,7 @@ export class SessionController {
|
|
|
50
62
|
const id = this.store.state.sessionId;
|
|
51
63
|
if (!id)
|
|
52
64
|
return;
|
|
53
|
-
const title = this.
|
|
65
|
+
const title = this.runtime.telemetry.view(id).values.title;
|
|
54
66
|
const row = this.store.state.sessions.find(item => item.sessionId === id);
|
|
55
67
|
return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
|
|
56
68
|
: row ? sessionLabel(row) : id;
|
|
@@ -59,7 +71,7 @@ export class SessionController {
|
|
|
59
71
|
get sessionMode() {
|
|
60
72
|
if (!this.store.state.sessionId)
|
|
61
73
|
return undefined;
|
|
62
|
-
const id = this.
|
|
74
|
+
const id = this.runtime.telemetry.view(this.store.state.sessionId).values.agentPreset;
|
|
63
75
|
if (typeof id !== 'string')
|
|
64
76
|
return undefined;
|
|
65
77
|
const preset = this.store.state.presets?.find(item => item.id === id);
|
|
@@ -70,7 +82,7 @@ export class SessionController {
|
|
|
70
82
|
get workingSince() {
|
|
71
83
|
if (!this.running || !this.store.state.sessionId)
|
|
72
84
|
return undefined;
|
|
73
|
-
return this.info.record.activeTurnStartedAt ?? this.
|
|
85
|
+
return this.info.record.activeTurnStartedAt ?? this.runtime.observedAt(this.store.state.sessionId);
|
|
74
86
|
}
|
|
75
87
|
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
76
88
|
get visibleSessions() {
|
|
@@ -83,10 +95,34 @@ export class SessionController {
|
|
|
83
95
|
}
|
|
84
96
|
/** Whether a turn, cancellation or prompt admission is still in flight. */
|
|
85
97
|
get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
|
|
98
|
+
/** @returns The detached history window the reader opened, if any. */
|
|
99
|
+
get window() { return this.info.window; }
|
|
100
|
+
/** Show a detached history window, releasing the one it replaces.
|
|
101
|
+
* @param window - Record to display, or undefined to return to the live transcript.
|
|
102
|
+
*/
|
|
103
|
+
setViewWindow(window) {
|
|
104
|
+
if (this.info.window === window)
|
|
105
|
+
return;
|
|
106
|
+
this.info.closeWindow();
|
|
107
|
+
this.info.window = window;
|
|
108
|
+
this.store.update({});
|
|
109
|
+
}
|
|
110
|
+
/** @returns Host projection store of the current generation, owned by this session's runtime. */
|
|
111
|
+
get telemetry() { return this.runtime.telemetry; }
|
|
112
|
+
/** Lay out one record for a terminal width.
|
|
113
|
+
*
|
|
114
|
+
* The projection engine stays in this domain; the UI receives plain rows and offsets instead of
|
|
115
|
+
* importing it, and `Reasoning` for committed messages is always the folded default.
|
|
116
|
+
*/
|
|
117
|
+
render(input) {
|
|
118
|
+
return historyLayout(input.transcript, input.width, 'row', input.folds, input.liveReasoning);
|
|
119
|
+
}
|
|
120
|
+
/** Apply one normalized control frame to this session's runtime. */
|
|
121
|
+
acceptControl(frame) { this.runtime.acceptControl(frame); }
|
|
86
122
|
/** Whether reading protects the loaded window, suspending history reclamation. */
|
|
87
|
-
get pinned() { return this.
|
|
123
|
+
get pinned() { return this.historyPinned; }
|
|
88
124
|
/** Drop generation-scoped state before a new connection generation begins. */
|
|
89
|
-
beginGeneration() { this.stoppingSession = undefined; }
|
|
125
|
+
beginGeneration() { this.stoppingSession = undefined; this.runtime.reset(); }
|
|
90
126
|
/** Invalidate in-flight work and drop transient interactions when a generation ends. */
|
|
91
127
|
endGeneration() {
|
|
92
128
|
this.store.bumpSelection();
|
|
@@ -102,7 +138,7 @@ export class SessionController {
|
|
|
102
138
|
* @returns Retained question and approval frames for that session.
|
|
103
139
|
*/
|
|
104
140
|
pendingFor(state) {
|
|
105
|
-
return [...this.interactions.values()].filter(frame => frame.
|
|
141
|
+
return [...this.interactions.values()].filter(frame => frame.sessionId === state.sessionId);
|
|
106
142
|
}
|
|
107
143
|
/** Unanswered interactions by session, so a list can show who is waiting without opening them.
|
|
108
144
|
*
|
|
@@ -115,20 +151,27 @@ export class SessionController {
|
|
|
115
151
|
pendingCounts() {
|
|
116
152
|
const counts = new Map();
|
|
117
153
|
for (const frame of this.interactions.values()) {
|
|
118
|
-
if (
|
|
154
|
+
if (frame.sessionId === '')
|
|
119
155
|
continue;
|
|
120
|
-
counts.set(frame.
|
|
156
|
+
counts.set(frame.sessionId, (counts.get(frame.sessionId) ?? 0) + 1);
|
|
121
157
|
}
|
|
122
158
|
return counts;
|
|
123
159
|
}
|
|
124
|
-
/** Retain a recognized host
|
|
125
|
-
* @param
|
|
126
|
-
* @returns Whether this domain retained the
|
|
160
|
+
/** Retain a recognized host interaction; anything else belongs to the connection to delegate.
|
|
161
|
+
* @param event - One normalized host event.
|
|
162
|
+
* @returns Whether this domain retained the event for an answer.
|
|
127
163
|
*/
|
|
128
|
-
|
|
129
|
-
if (
|
|
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
|
|
130
174
|
return false;
|
|
131
|
-
this.interactions.set(string(frame.eventId), frame);
|
|
132
175
|
this.store.update({});
|
|
133
176
|
return true;
|
|
134
177
|
}
|
|
@@ -141,6 +184,7 @@ export class SessionController {
|
|
|
141
184
|
* @param running - Whether the host still runs that session.
|
|
142
185
|
*/
|
|
143
186
|
status(sessionId, running) {
|
|
187
|
+
this.runtime.accept(sessionId, running);
|
|
144
188
|
if (!running && this.stoppingSession === sessionId)
|
|
145
189
|
this.stoppingSession = undefined;
|
|
146
190
|
this.store.update({ sessions: this.store.state.sessions.map(row => row.sessionId === sessionId ? { ...row, running } : row),
|
|
@@ -148,11 +192,11 @@ export class SessionController {
|
|
|
148
192
|
}
|
|
149
193
|
/** Surface a host-reported error for the selected session.
|
|
150
194
|
* @param sessionId - Session the host reported on.
|
|
151
|
-
* @param error -
|
|
195
|
+
* @param error - Already-normalized error text.
|
|
152
196
|
*/
|
|
153
197
|
reportError(sessionId, error) {
|
|
154
198
|
if (sessionId === this.store.state.sessionId)
|
|
155
|
-
this.store.update({ status: 'Agent error',
|
|
199
|
+
this.store.update({ status: 'Agent error', lastFailure: error });
|
|
156
200
|
}
|
|
157
201
|
/** Stop the selected turn, or allow exit only while idle. Repeated keys share one request.
|
|
158
202
|
* @param force - Send an explicit cancellation even when the cached running flag is idle.
|
|
@@ -162,23 +206,27 @@ export class SessionController {
|
|
|
162
206
|
if (this.interruptTask)
|
|
163
207
|
return this.interruptTask;
|
|
164
208
|
if (!force && !this.running && !this.admission && this.store.state.pending.length === 0) {
|
|
165
|
-
|
|
209
|
+
// An idle press may exit only when no operation owns the client; the store's failure line is
|
|
210
|
+
// not that fact, so the application asks itself.
|
|
211
|
+
return Promise.resolve(!this.store.busy());
|
|
166
212
|
}
|
|
167
213
|
const sessionId = this.sessionId;
|
|
168
214
|
this.stoppingSession = sessionId;
|
|
169
|
-
this.store.update({ status: 'Stopping…',
|
|
215
|
+
this.store.update({ status: 'Stopping…', lastFailure: '' });
|
|
170
216
|
const task = (async () => {
|
|
171
217
|
try {
|
|
172
218
|
// Admission must settle before cancellation can address the newly submitted turn.
|
|
173
219
|
// The prompt caller reports admission failures; an existing turn still needs cancellation.
|
|
220
|
+
// This wait stays outside the gate: a control admission overtakes the *waiting* queue, and
|
|
221
|
+
// holding the gate while waiting here would block the very writes it precedes.
|
|
174
222
|
await this.admission?.catch(() => undefined);
|
|
175
|
-
await this.host.require().call('session/cancel', { request: { sessionId } });
|
|
223
|
+
await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
|
|
176
224
|
if (this.stoppingSession === sessionId && this.store.state.sessionId === sessionId)
|
|
177
225
|
this.store.update({ status: 'Cancellation requested · waiting for host' });
|
|
178
226
|
}
|
|
179
227
|
catch (error) {
|
|
180
228
|
this.stoppingSession = undefined;
|
|
181
|
-
this.store.update({ status: 'Cancellation failed',
|
|
229
|
+
this.store.update({ status: 'Cancellation failed', lastFailure: errorText(error) });
|
|
182
230
|
}
|
|
183
231
|
return false;
|
|
184
232
|
})();
|
|
@@ -190,13 +238,17 @@ export class SessionController {
|
|
|
190
238
|
* @param pinned - Whether the main transcript is actively being read away from its tail.
|
|
191
239
|
*/
|
|
192
240
|
pinHistory(pinned) {
|
|
193
|
-
this.
|
|
241
|
+
if (this.historyPinned === pinned)
|
|
242
|
+
return;
|
|
243
|
+
this.historyPinned = pinned;
|
|
194
244
|
if (!pinned && this.reclaimHistory())
|
|
195
245
|
this.store.update({});
|
|
196
246
|
}
|
|
197
247
|
/** Cancel the active turn; pending queue items remain host-owned. */
|
|
198
248
|
async cancelTurn() {
|
|
199
|
-
|
|
249
|
+
const sessionId = this.sessionId;
|
|
250
|
+
// Cancellation is a control action: it may overtake queued writes, since its whole value is speed.
|
|
251
|
+
await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
|
|
200
252
|
this.store.update({ status: 'Cancellation requested' });
|
|
201
253
|
}
|
|
202
254
|
/** Refresh both lists from the host, then show the requested picker.
|
|
@@ -207,6 +259,18 @@ export class SessionController {
|
|
|
207
259
|
const [workspaces, sessions] = await Promise.all([client.listWorkspaces(), client.listSessions()]);
|
|
208
260
|
this.store.update({ screen, workspaces, sessions });
|
|
209
261
|
}
|
|
262
|
+
/** Return to the selected conversation without re-selecting it.
|
|
263
|
+
*
|
|
264
|
+
* A picker opened over a conversation is a detour: leaving it must not re-subscribe, reload the
|
|
265
|
+
* transcript or lose the reading position, so this only changes which screen is shown.
|
|
266
|
+
* @returns Whether there was a selected conversation to return to.
|
|
267
|
+
*/
|
|
268
|
+
showChat() {
|
|
269
|
+
if (this.store.state.sessionId === undefined)
|
|
270
|
+
return false;
|
|
271
|
+
this.store.update({ screen: 'chat' });
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
210
274
|
/** Resolve a removal command to one reviewable object without changing the selection.
|
|
211
275
|
* @param kind - Workspace registration removal or session archival.
|
|
212
276
|
* @param query - Exact name, ID, or unambiguous ID prefix.
|
|
@@ -219,9 +283,9 @@ export class SessionController {
|
|
|
219
283
|
return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
|
|
220
284
|
: { kind, id: string(row.sessionId), name: sessionLabel(row),
|
|
221
285
|
empty: row.blank === true && row.running === false
|
|
222
|
-
&& !this.
|
|
223
|
-
&& !(this.
|
|
224
|
-
&& !(this.
|
|
286
|
+
&& !this.runtime.runningFor(string(row.sessionId))
|
|
287
|
+
&& !(this.runtime.telemetry.view(string(row.sessionId)).queued ?? 0)
|
|
288
|
+
&& !(this.runtime.telemetry.view(string(row.sessionId)).jobs ?? 0)
|
|
225
289
|
&& !(this.store.state.sessionId === row.sessionId && this.admission) };
|
|
226
290
|
}
|
|
227
291
|
/** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
|
|
@@ -229,9 +293,10 @@ export class SessionController {
|
|
|
229
293
|
*/
|
|
230
294
|
async removeTarget(target) {
|
|
231
295
|
const client = this.host.require();
|
|
232
|
-
const receipt = object(
|
|
233
|
-
|
|
234
|
-
|
|
296
|
+
const receipt = object(target.kind === 'workspace'
|
|
297
|
+
? await client.call('workspace/delete', { request: { workspaceId: target.id } })
|
|
298
|
+
// Archiving a session is a write on that session, so it queues behind that session's admissions.
|
|
299
|
+
: await this.mutations.admit(target.id, 'normal', () => client.call('workspace/archiveSession', { request: { sessionId: target.id } })));
|
|
235
300
|
if (client !== this.host.client())
|
|
236
301
|
return;
|
|
237
302
|
if (target.kind === 'session') {
|
|
@@ -252,7 +317,7 @@ export class SessionController {
|
|
|
252
317
|
await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
|
|
253
318
|
}
|
|
254
319
|
catch (error) {
|
|
255
|
-
this.store.update({
|
|
320
|
+
this.store.update({ lastFailure: `Removal completed; list refresh failed: ${errorText(error)}` });
|
|
256
321
|
}
|
|
257
322
|
}
|
|
258
323
|
/** Pick a workspace, or use all sessions when the identity is omitted.
|
|
@@ -297,6 +362,32 @@ export class SessionController {
|
|
|
297
362
|
}
|
|
298
363
|
/** Prompt for a host path without starting a local agent. */
|
|
299
364
|
enterPath() { this.store.update({ screen: 'path' }); }
|
|
365
|
+
/** Adopt the workspace whose registered path contains the directory this client runs in.
|
|
366
|
+
*
|
|
367
|
+
* Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
|
|
368
|
+
* path segments so `/srv/app-old` cannot match `/srv/app`. A remote host's paths usually differ
|
|
369
|
+
* from the client's, in which case nothing matches and the picker stays exactly as before.
|
|
370
|
+
* @param directory - Directory this client was started in.
|
|
371
|
+
* @returns The adopted workspace's id, or undefined when none matches.
|
|
372
|
+
*/
|
|
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
|
+
}
|
|
300
391
|
/** Register a host directory and move to its session picker.
|
|
301
392
|
* @param path - Absolute directory path on the host.
|
|
302
393
|
*/
|
|
@@ -313,6 +404,35 @@ export class SessionController {
|
|
|
313
404
|
const result = object(await this.host.require().call('session/create', { request: { workspaceId: this.store.state.workspaceId } }));
|
|
314
405
|
await this.selectSession(string(result.sessionId));
|
|
315
406
|
}
|
|
407
|
+
/** Create a session for another purpose without selecting it, named so a reader can tell it apart.
|
|
408
|
+
*
|
|
409
|
+
* A verifier runs in its own session while the reviewed session stays selected, so this never
|
|
410
|
+
* follows the new session and never touches the reader's transcript.
|
|
411
|
+
* @param title - Session title to apply; hosts without a title service keep the default.
|
|
412
|
+
* @returns The new session's id, or undefined when its identity could not be read.
|
|
413
|
+
*/
|
|
414
|
+
async createNamedSession(title) {
|
|
415
|
+
const workspaceId = this.store.state.workspaceId;
|
|
416
|
+
if (!workspaceId)
|
|
417
|
+
throw new Error('Select a workspace before creating a session');
|
|
418
|
+
const created = object(await this.host.require().call('session/create', { request: { workspaceId } }));
|
|
419
|
+
const sessionId = string(created.sessionId);
|
|
420
|
+
try {
|
|
421
|
+
await this.host.require().call('session/rename', { request: { sessionId, title } });
|
|
422
|
+
}
|
|
423
|
+
catch { /* A deployment without the title service still gets a usable session. */ }
|
|
424
|
+
return sessionId;
|
|
425
|
+
}
|
|
426
|
+
/** Stop a session this client owns but has not selected, such as a verifier's.
|
|
427
|
+
*
|
|
428
|
+
* Killing the child client leaves the host's agent generation running, so the turn has to be
|
|
429
|
+
* cancelled on the host itself.
|
|
430
|
+
* @param sessionId - Session whose turn should stop.
|
|
431
|
+
*/
|
|
432
|
+
async cancelNamedSession(sessionId) {
|
|
433
|
+
// Keyed by the session being stopped, not the selection: the verifier's session is someone else's.
|
|
434
|
+
await this.mutations.admit(sessionId, 'control', () => this.host.require().call('session/cancel', { request: { sessionId } }));
|
|
435
|
+
}
|
|
316
436
|
/** Replace the selected transcript and cancel its preceding follow stream.
|
|
317
437
|
* @param sessionId - Session to follow.
|
|
318
438
|
*/
|
|
@@ -327,7 +447,7 @@ export class SessionController {
|
|
|
327
447
|
const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
|
|
328
448
|
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
329
449
|
: this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
|
|
330
|
-
this.
|
|
450
|
+
this.runtime.observe(sessionId);
|
|
331
451
|
this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
|
|
332
452
|
this.follow = this.host.require().subscribe('session/follow', {
|
|
333
453
|
request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
|
|
@@ -341,7 +461,7 @@ export class SessionController {
|
|
|
341
461
|
this.reclaimHistory();
|
|
342
462
|
const frame = object(value);
|
|
343
463
|
if (frame.type === 'snapshot')
|
|
344
|
-
this.
|
|
464
|
+
this.runtime.telemetry.snapshot(sessionId, projectionSnapshot(frame.projections));
|
|
345
465
|
this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
|
|
346
466
|
}
|
|
347
467
|
catch (error) {
|
|
@@ -352,7 +472,7 @@ export class SessionController {
|
|
|
352
472
|
if (selection !== this.store.selection())
|
|
353
473
|
return;
|
|
354
474
|
transcript.ready = false;
|
|
355
|
-
this.store.update({ status: 'Session disconnected',
|
|
475
|
+
this.store.update({ status: 'Session disconnected', lastFailure: errorText(error ?? 'Session stream ended') });
|
|
356
476
|
},
|
|
357
477
|
});
|
|
358
478
|
this.backfillPrompts(sessionId, selection);
|
|
@@ -408,11 +528,14 @@ export class SessionController {
|
|
|
408
528
|
* @returns The host's successful command result text.
|
|
409
529
|
*/
|
|
410
530
|
async command(line, signal) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
531
|
+
const sessionId = this.sessionId;
|
|
532
|
+
// The section decides and issues; the host's answer is awaited outside the gate, because a
|
|
533
|
+
// compaction can take minutes and must not hold every other write on this session.
|
|
534
|
+
const execution = await this.mutations.admit(sessionId, 'normal', () => {
|
|
535
|
+
if (!this.info.record.ready)
|
|
536
|
+
throw new Error('Wait for the session snapshot before running commands');
|
|
537
|
+
return this.host.require().call('commands/execute', { agentId: sessionId, line, submittedAttachments: [] }, signal, null);
|
|
538
|
+
});
|
|
416
539
|
if (execution === undefined)
|
|
417
540
|
throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
|
|
418
541
|
const result = object(object(execution).result);
|
|
@@ -432,7 +555,10 @@ export class SessionController {
|
|
|
432
555
|
* @param itemId - Queue occurrence identity from session/control.
|
|
433
556
|
*/
|
|
434
557
|
async removeQueued(itemId) {
|
|
435
|
-
|
|
558
|
+
const sessionId = this.sessionId;
|
|
559
|
+
await this.mutations.admit(sessionId, 'normal', () => this.host.require().call('session/updateQueue', {
|
|
560
|
+
request: { sessionId, itemId, action: { kind: 'remove' } }
|
|
561
|
+
}));
|
|
436
562
|
}
|
|
437
563
|
/** Export the selected host log to a new local ZIP file.
|
|
438
564
|
* @param path - Optional local destination; existing files are never overwritten.
|
|
@@ -454,16 +580,23 @@ export class SessionController {
|
|
|
454
580
|
* @param text - Composed prompt text.
|
|
455
581
|
*/
|
|
456
582
|
async prompt(text) {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
this
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
583
|
+
const sessionId = this.sessionId;
|
|
584
|
+
// Deciding steer-versus-queue and issuing the request are one admitted step: the loop's own sends
|
|
585
|
+
// and everything the reader types go through this same point, so two of them cannot be decided
|
|
586
|
+
// against the same stale running state. The host's answer is awaited outside the gate.
|
|
587
|
+
const admission = await this.mutations.admit(sessionId, 'normal', () => {
|
|
588
|
+
if (this.store.state.pending.length)
|
|
589
|
+
throw new Error('Answer the pending question or approval first');
|
|
590
|
+
this.stoppingSession = undefined;
|
|
591
|
+
if (!this.info.record.ready)
|
|
592
|
+
throw new Error('Wait for the session snapshot before sending');
|
|
593
|
+
const issued = this.host.require().call('session/prompt', { request: {
|
|
594
|
+
sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
|
|
595
|
+
content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
596
|
+
} });
|
|
597
|
+
this.admission = issued;
|
|
598
|
+
return issued;
|
|
599
|
+
});
|
|
467
600
|
try {
|
|
468
601
|
await admission;
|
|
469
602
|
}
|
|
@@ -473,6 +606,17 @@ export class SessionController {
|
|
|
473
606
|
}
|
|
474
607
|
this.store.update({ status: 'Accepted · waiting for host' });
|
|
475
608
|
}
|
|
609
|
+
/** Send one prompt the client assembled, keeping its durable echo out of composer recall.
|
|
610
|
+
*
|
|
611
|
+
* The host records every prompt as a user message, so an agent loop that submits a turn per
|
|
612
|
+
* attempt would otherwise crowd out the prompts the operator actually typed. Suppressing the text
|
|
613
|
+
* before sending is what makes the later echo invisible to ↑/↓.
|
|
614
|
+
* @param text - Prompt the client assembled, not text the operator typed.
|
|
615
|
+
*/
|
|
616
|
+
async promptInternal(text) {
|
|
617
|
+
this.prompts.suppress(text);
|
|
618
|
+
await this.prompt(text);
|
|
619
|
+
}
|
|
476
620
|
/** Answer the oldest selected-session interaction, after explicit user action.
|
|
477
621
|
* @param value - Structured answer value or approval outcome.
|
|
478
622
|
*/
|
|
@@ -480,17 +624,20 @@ export class SessionController {
|
|
|
480
624
|
const pending = this.store.state.pending[0];
|
|
481
625
|
if (!pending)
|
|
482
626
|
throw new Error('No pending interaction');
|
|
483
|
-
await this.reply(pending, { kind: 'result', value });
|
|
484
|
-
this.interactions.delete(
|
|
627
|
+
await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'result', value }));
|
|
628
|
+
this.interactions.delete(pending.eventId);
|
|
485
629
|
this.store.update({});
|
|
486
630
|
}
|
|
487
631
|
/** Restrict an approval command to an approval request.
|
|
488
632
|
* @param allowed - Whether the request is approved once.
|
|
489
633
|
*/
|
|
490
634
|
async approve(allowed) {
|
|
491
|
-
|
|
635
|
+
const pending = this.store.state.pending[0];
|
|
636
|
+
if (pending?.kind !== 'approval')
|
|
492
637
|
throw new Error('No pending approval');
|
|
493
|
-
await this.
|
|
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({});
|
|
494
641
|
}
|
|
495
642
|
/** Dismiss the whole selected-session question set without answering it.
|
|
496
643
|
*
|
|
@@ -500,12 +647,12 @@ export class SessionController {
|
|
|
500
647
|
*/
|
|
501
648
|
async dismissQuestion() {
|
|
502
649
|
const pending = this.store.state.pending[0];
|
|
503
|
-
if (pending?.
|
|
650
|
+
if (pending?.kind !== 'question')
|
|
504
651
|
throw new Error('No pending question');
|
|
505
|
-
await this.reply(pending, { kind: 'rejected', error: {
|
|
652
|
+
await this.mutations.admit(this.sessionId, 'normal', () => this.reply(pending.eventId, { kind: 'rejected', error: {
|
|
506
653
|
name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
|
|
507
|
-
} });
|
|
508
|
-
this.interactions.delete(
|
|
654
|
+
} }));
|
|
655
|
+
this.interactions.delete(pending.eventId);
|
|
509
656
|
this.store.update({});
|
|
510
657
|
}
|
|
511
658
|
/** Add a page before the retained window using its fixed opening cut.
|
|
@@ -537,84 +684,6 @@ export class SessionController {
|
|
|
537
684
|
* @returns The recalled prompt, or the unsent draft.
|
|
538
685
|
*/
|
|
539
686
|
recall(direction, current) { return this.prompts.move(direction, current); }
|
|
540
|
-
/** Composer draft, caret and parked draft, as the selected session holds them. */
|
|
541
|
-
get composer() { return this.info.composer; }
|
|
542
|
-
/** Replace the composer text and caret, publishing only when either actually changed. */
|
|
543
|
-
setComposer(draft, cursor = draft.length) {
|
|
544
|
-
const composer = this.info.composer;
|
|
545
|
-
if (composer.draft === draft && composer.cursor === cursor)
|
|
546
|
-
return;
|
|
547
|
-
composer.draft = draft;
|
|
548
|
-
composer.cursor = cursor;
|
|
549
|
-
this.store.update({});
|
|
550
|
-
}
|
|
551
|
-
/** Move the caret without changing the text. */
|
|
552
|
-
setComposerCursor(cursor) {
|
|
553
|
-
const composer = this.info.composer;
|
|
554
|
-
if (composer.cursor === cursor)
|
|
555
|
-
return;
|
|
556
|
-
composer.cursor = cursor;
|
|
557
|
-
this.store.update({});
|
|
558
|
-
}
|
|
559
|
-
/** Move a non-empty draft aside while a dialog owns the keyboard. */
|
|
560
|
-
parkComposer() {
|
|
561
|
-
const composer = this.info.composer;
|
|
562
|
-
if (composer.draft === '')
|
|
563
|
-
return;
|
|
564
|
-
composer.parked = composer.draft;
|
|
565
|
-
composer.draft = '';
|
|
566
|
-
composer.cursor = 0;
|
|
567
|
-
this.store.update({});
|
|
568
|
-
}
|
|
569
|
-
/** Give a parked draft back once no dialog needs the keyboard. */
|
|
570
|
-
restoreComposer() {
|
|
571
|
-
const composer = this.info.composer;
|
|
572
|
-
if (composer.parked === '')
|
|
573
|
-
return;
|
|
574
|
-
composer.draft = composer.parked;
|
|
575
|
-
composer.cursor = composer.parked.length;
|
|
576
|
-
composer.parked = '';
|
|
577
|
-
this.store.update({});
|
|
578
|
-
}
|
|
579
|
-
/** How the selected session's record is being read right now. */
|
|
580
|
-
get view() { return this.info.view; }
|
|
581
|
-
/** Show a detached history window, releasing the one it replaces.
|
|
582
|
-
* @param window - Record to display, or undefined to return to the live transcript.
|
|
583
|
-
*/
|
|
584
|
-
setViewWindow(window) {
|
|
585
|
-
if (this.info.view.window === window)
|
|
586
|
-
return;
|
|
587
|
-
this.info.closeWindow();
|
|
588
|
-
this.info.view.window = window;
|
|
589
|
-
this.store.update({});
|
|
590
|
-
}
|
|
591
|
-
/** Move the reader's position inside the displayed record.
|
|
592
|
-
* @param scroll - Rows scrolled back from the live end.
|
|
593
|
-
*/
|
|
594
|
-
setScroll(scroll) {
|
|
595
|
-
if (this.info.view.scroll === scroll)
|
|
596
|
-
return;
|
|
597
|
-
this.info.view.scroll = scroll;
|
|
598
|
-
this.store.update({});
|
|
599
|
-
}
|
|
600
|
-
/** Replace the set of expanded reasoning blocks, keyed by message sequence.
|
|
601
|
-
* @param folds - Sequences to expand beyond the default fold.
|
|
602
|
-
*/
|
|
603
|
-
setFolds(folds) {
|
|
604
|
-
if (this.info.view.folds === folds)
|
|
605
|
-
return;
|
|
606
|
-
this.info.view.folds = folds;
|
|
607
|
-
this.store.update({});
|
|
608
|
-
}
|
|
609
|
-
/** Set the fold mode of the live attempt's completed reasoning.
|
|
610
|
-
* @param reasoning - `row` to fold, `full` to keep the streamed text.
|
|
611
|
-
*/
|
|
612
|
-
setLiveReasoning(reasoning) {
|
|
613
|
-
if (this.info.view.liveReasoning === reasoning)
|
|
614
|
-
return;
|
|
615
|
-
this.info.view.liveReasoning = reasoning;
|
|
616
|
-
this.store.update({});
|
|
617
|
-
}
|
|
618
687
|
/** Local answer state for the selected session's pending waterfalls. */
|
|
619
688
|
get interaction() { return this.info.interaction; }
|
|
620
689
|
/** Replace the partly collected answers, keyed by the waterfall event id.
|
|
@@ -642,73 +711,6 @@ export class SessionController {
|
|
|
642
711
|
this.info.interaction.approval = approval;
|
|
643
712
|
this.store.update({});
|
|
644
713
|
}
|
|
645
|
-
/** Composer-adjacent `@` reference menu state. */
|
|
646
|
-
get reference() { return this.info.reference; }
|
|
647
|
-
/** Highlight one row of the open reference menu.
|
|
648
|
-
* @param index - Row index into the current matches.
|
|
649
|
-
*/
|
|
650
|
-
setReferenceIndex(index) {
|
|
651
|
-
if (this.info.reference.index === index)
|
|
652
|
-
return;
|
|
653
|
-
this.info.reference.index = index;
|
|
654
|
-
this.store.update({});
|
|
655
|
-
}
|
|
656
|
-
/** Remember the draft that dismissed the reference menu, so it does not reopen while it stands.
|
|
657
|
-
* @param draft - Composer text at dismissal, or undefined to allow the menu again.
|
|
658
|
-
*/
|
|
659
|
-
setReferenceDismissed(draft) {
|
|
660
|
-
if (this.info.reference.dismissed === draft)
|
|
661
|
-
return;
|
|
662
|
-
this.info.reference.dismissed = draft;
|
|
663
|
-
this.store.update({});
|
|
664
|
-
}
|
|
665
|
-
/** Panels the selected session has open. */
|
|
666
|
-
get panels() { return this.info.panels; }
|
|
667
|
-
/** Show or hide the reasoning panel.
|
|
668
|
-
* @param open - Whether `/think` is open.
|
|
669
|
-
*/
|
|
670
|
-
openThoughts(open) {
|
|
671
|
-
if (this.info.panels.thoughts === open)
|
|
672
|
-
return;
|
|
673
|
-
this.info.panels.thoughts = open;
|
|
674
|
-
this.store.update({});
|
|
675
|
-
}
|
|
676
|
-
/** Show or hide the pending-input panel.
|
|
677
|
-
* @param open - Whether `/queue` is open.
|
|
678
|
-
*/
|
|
679
|
-
openQueue(open) {
|
|
680
|
-
if (this.info.panels.queue === open)
|
|
681
|
-
return;
|
|
682
|
-
this.info.panels.queue = open;
|
|
683
|
-
this.store.update({});
|
|
684
|
-
}
|
|
685
|
-
/** Show the model dialog at one step, or close it.
|
|
686
|
-
* @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
|
|
687
|
-
*/
|
|
688
|
-
setModelPanel(model) {
|
|
689
|
-
if (this.info.panels.model === model)
|
|
690
|
-
return;
|
|
691
|
-
this.info.panels.model = model;
|
|
692
|
-
this.store.update({});
|
|
693
|
-
}
|
|
694
|
-
/** Show the history or content-search dialog, or close it.
|
|
695
|
-
* @param history - Query, content-search mode and matches; undefined closes the dialog.
|
|
696
|
-
*/
|
|
697
|
-
setHistoryPanel(history) {
|
|
698
|
-
if (this.info.panels.history === history)
|
|
699
|
-
return;
|
|
700
|
-
this.info.panels.history = history;
|
|
701
|
-
this.store.update({});
|
|
702
|
-
}
|
|
703
|
-
/** Show the host session-search results, or close them.
|
|
704
|
-
* @param search - Query, results and truncation flag; undefined closes the dialog.
|
|
705
|
-
*/
|
|
706
|
-
setSearchPanel(search) {
|
|
707
|
-
if (this.info.panels.search === search)
|
|
708
|
-
return;
|
|
709
|
-
this.info.panels.search = search;
|
|
710
|
-
this.store.update({});
|
|
711
|
-
}
|
|
712
714
|
/** Remember a locally submitted command, which never becomes a durable session record. */
|
|
713
715
|
recordRecall(value) { this.prompts.record(value); }
|
|
714
716
|
/** Leave recall navigation because the composer was edited or replaced. */
|
|
@@ -956,7 +958,7 @@ export class SessionController {
|
|
|
956
958
|
* @returns Number of removed records.
|
|
957
959
|
*/
|
|
958
960
|
reclaimHistory() {
|
|
959
|
-
if (this.
|
|
961
|
+
if (this.historyPinned || !this.store.state.online)
|
|
960
962
|
return 0;
|
|
961
963
|
const removed = this.info.record.trimHistory(this.historyLimits);
|
|
962
964
|
if (removed)
|
|
@@ -976,7 +978,7 @@ export class SessionController {
|
|
|
976
978
|
return this.store.state.sessionId;
|
|
977
979
|
}
|
|
978
980
|
/** Answer one retained waterfall through the connection's event-result endpoint. */
|
|
979
|
-
async reply(
|
|
980
|
-
await this.connection.reply(
|
|
981
|
+
async reply(eventId, outcome) {
|
|
982
|
+
await this.connection.reply(eventId, outcome);
|
|
981
983
|
}
|
|
982
984
|
}
|