@itookit/dsht 0.2.1 → 0.3.0
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 +64 -29
- package/README.zh.md +64 -29
- package/dist/app.d.ts +16 -1
- package/dist/app.js +516 -80
- package/dist/cli.js +6 -1
- package/dist/client.d.ts +10 -1
- package/dist/client.js +24 -4
- package/dist/controller.d.ts +98 -5
- package/dist/controller.js +391 -78
- package/dist/cost-view.js +3 -1
- package/dist/export.d.ts +9 -0
- package/dist/export.js +52 -0
- package/dist/history-view.d.ts +8 -0
- package/dist/history-view.js +12 -0
- package/dist/history.d.ts +29 -12
- package/dist/history.js +172 -27
- package/dist/input-history.d.ts +19 -0
- package/dist/input-history.js +43 -0
- package/dist/input.d.ts +3 -1
- package/dist/input.js +3 -1
- package/dist/memory.d.ts +13 -0
- package/dist/memory.js +22 -0
- package/dist/mouse.d.ts +3 -1
- package/dist/mouse.js +15 -4
- package/dist/navigation.d.ts +1 -1
- package/dist/navigation.js +2 -2
- package/dist/status.d.ts +3 -1
- package/dist/status.js +63 -42
- package/dist/telemetry.d.ts +14 -0
- package/dist/telemetry.js +26 -3
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +15 -0
- package/dist/transcript.d.ts +91 -6
- package/dist/transcript.js +393 -49
- package/dsht-m.png +0 -0
- package/package.json +5 -3
- package/dsht.png +0 -0
package/dist/controller.js
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
/** UI state and connection generations for the standalone terminal client. */
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { saveSessionLog } from "./export.js";
|
|
3
4
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
5
|
import { Client, HttpError, RemoteError } from "./client.js";
|
|
5
6
|
import { AuthenticationRequired } from "./auth.js";
|
|
6
7
|
import { resolveTarget, sessionLabel } from "./navigation.js";
|
|
7
8
|
import { costRecords } from "./cost.js";
|
|
8
9
|
import { Telemetry } from "./telemetry.js";
|
|
9
|
-
import { Transcript } from "./transcript.js";
|
|
10
|
+
import { Transcript, toolLine } from "./transcript.js";
|
|
11
|
+
import { releaseHistoryLayout } from "./history.js";
|
|
12
|
+
import { DEFAULT_HISTORY_LIMITS } from "./memory.js";
|
|
10
13
|
import { fileReferences } from "./references.js";
|
|
11
14
|
import { array, errorText, object, string } from "./wire.js";
|
|
15
|
+
/** Wire addresses for one `session/list` row, in the order the cost scan should try them.
|
|
16
|
+
*
|
|
17
|
+
* A subagent child is reachable only under its durable parent, and the list row omits the delivery
|
|
18
|
+
* mode, so both modes are offered with the continuable form first.
|
|
19
|
+
* @param session - One row from the host session list.
|
|
20
|
+
* @returns One plain-session address, or both subagent forms when the row is a child.
|
|
21
|
+
*/
|
|
22
|
+
export function costAddresses(session) {
|
|
23
|
+
const sessionId = string(session.sessionId);
|
|
24
|
+
const parentSessionId = typeof session.parentSessionId === 'string' ? session.parentSessionId : '';
|
|
25
|
+
if (session.origin !== 'subagent' || parentSessionId === '')
|
|
26
|
+
return [{ kind: 'session', sessionId }];
|
|
27
|
+
return [
|
|
28
|
+
{ kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'continuable' },
|
|
29
|
+
{ kind: 'subagent', parentSessionId, childSessionId: sessionId, mode: 'one-shot' },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
12
32
|
/** Owns reconnects and subscriptions. User commands remain single-attempt operations. */
|
|
13
33
|
export class Controller {
|
|
14
34
|
base;
|
|
@@ -16,9 +36,11 @@ export class Controller {
|
|
|
16
36
|
makeClient;
|
|
17
37
|
authenticate;
|
|
18
38
|
costs;
|
|
39
|
+
historyLimits;
|
|
19
40
|
state = { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
|
|
20
41
|
error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
|
|
21
42
|
observers = new Set();
|
|
43
|
+
interactions = new Map();
|
|
22
44
|
abort = new AbortController();
|
|
23
45
|
client;
|
|
24
46
|
clientId = '';
|
|
@@ -26,8 +48,10 @@ export class Controller {
|
|
|
26
48
|
runTask;
|
|
27
49
|
generationFailed;
|
|
28
50
|
selection = 0;
|
|
29
|
-
|
|
51
|
+
historyPinned = false;
|
|
52
|
+
telemetry = new Telemetry(new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']));
|
|
30
53
|
observedRunningAt = new Map();
|
|
54
|
+
presetClient;
|
|
31
55
|
catalogRevision = 0;
|
|
32
56
|
catalogTasks = new Set();
|
|
33
57
|
runningUpdates = new Map();
|
|
@@ -54,6 +78,61 @@ export class Controller {
|
|
|
54
78
|
return title !== undefined ? sessionLabel({ sessionId: id, projections: { values: { title } } })
|
|
55
79
|
: row ? sessionLabel(row) : id;
|
|
56
80
|
}
|
|
81
|
+
/** Current agent-preset name, matching the web header's built-in labels and custom metadata. */
|
|
82
|
+
get sessionMode() {
|
|
83
|
+
if (!this.state.sessionId)
|
|
84
|
+
return undefined;
|
|
85
|
+
const id = this.telemetry.view(this.state.sessionId).values.agentPreset;
|
|
86
|
+
if (typeof id !== 'string')
|
|
87
|
+
return undefined;
|
|
88
|
+
const preset = this.state.presets?.find(item => item.id === id);
|
|
89
|
+
const builtIn = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
90
|
+
return preset?.trust === 'system' && builtIn.has(id) ? builtIn.get(id)
|
|
91
|
+
: typeof preset?.name === 'string' ? preset.name : id;
|
|
92
|
+
}
|
|
93
|
+
/** Load the optional preset roster once per connection, only when a session names a preset. */
|
|
94
|
+
loadPresetNames() {
|
|
95
|
+
const client = this.client;
|
|
96
|
+
if (!client || !this.state.online || this.presetClient === client)
|
|
97
|
+
return;
|
|
98
|
+
this.presetClient = client;
|
|
99
|
+
const task = client.call('agentPresets/list', {}).then(value => {
|
|
100
|
+
if (client === this.client)
|
|
101
|
+
this.update({ presets: array(object(value).presets).map(object), presetError: undefined });
|
|
102
|
+
}).catch(error => {
|
|
103
|
+
if (client === this.client)
|
|
104
|
+
this.update({ presets: [], presetError: errorText(error) });
|
|
105
|
+
});
|
|
106
|
+
this.catalogTasks.add(task);
|
|
107
|
+
void task.finally(() => this.catalogTasks.delete(task));
|
|
108
|
+
}
|
|
109
|
+
/** Fetch current model routes and adapter-owned reasoning choices for the selected session.
|
|
110
|
+
* @returns Host catalog; provider failures remain available to the selector.
|
|
111
|
+
*/
|
|
112
|
+
async modelCatalog() {
|
|
113
|
+
const sessionId = this.sessionId;
|
|
114
|
+
const selection = this.selection;
|
|
115
|
+
const value = object(await this.host.call('session/modelCatalog', {}));
|
|
116
|
+
if (selection !== this.selection || sessionId !== this.state.sessionId)
|
|
117
|
+
throw new Error('Session changed while loading models');
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
/** Select the next request's model; the host also attempts to save its deployment default.
|
|
121
|
+
* @param provider - Host provider route ID.
|
|
122
|
+
* @param model - Exact model ID.
|
|
123
|
+
* @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
|
|
124
|
+
*/
|
|
125
|
+
async selectModel(provider, model, reasoningEffort) {
|
|
126
|
+
const sessionId = this.sessionId;
|
|
127
|
+
const selection = this.selection;
|
|
128
|
+
const selected = object(object(await this.host.call('session/selectModel', { request: {
|
|
129
|
+
sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
130
|
+
} })).selected);
|
|
131
|
+
if (selection !== this.selection || sessionId !== this.state.sessionId)
|
|
132
|
+
return;
|
|
133
|
+
this.update({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
|
|
134
|
+
this.refreshCatalog(this.host);
|
|
135
|
+
}
|
|
57
136
|
/** Epoch start from the retained turn log, or when this client first observed the run. */
|
|
58
137
|
get workingSince() {
|
|
59
138
|
if (!this.running || !this.state.sessionId)
|
|
@@ -92,12 +171,13 @@ export class Controller {
|
|
|
92
171
|
void task.finally(() => { this.interruptTask = undefined; });
|
|
93
172
|
return task;
|
|
94
173
|
}
|
|
95
|
-
constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs) {
|
|
174
|
+
constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS) {
|
|
96
175
|
this.base = base;
|
|
97
176
|
this.initialSession = initialSession;
|
|
98
177
|
this.makeClient = makeClient;
|
|
99
178
|
this.authenticate = authenticate;
|
|
100
179
|
this.costs = costs;
|
|
180
|
+
this.historyLimits = historyLimits;
|
|
101
181
|
}
|
|
102
182
|
/** React-compatible state subscription. */
|
|
103
183
|
subscribe = (listener) => {
|
|
@@ -118,6 +198,28 @@ export class Controller {
|
|
|
118
198
|
await this.interruptTask;
|
|
119
199
|
await Promise.all(this.catalogTasks);
|
|
120
200
|
await this.costTask;
|
|
201
|
+
this.releaseTranscript();
|
|
202
|
+
}
|
|
203
|
+
/** Keep history stable while the user reads, searches, or expands it; trim again at the live tail.
|
|
204
|
+
* @param pinned - Whether the main transcript is actively being read away from its tail.
|
|
205
|
+
*/
|
|
206
|
+
pinHistory(pinned) {
|
|
207
|
+
this.historyPinned = pinned;
|
|
208
|
+
if (!pinned && this.reclaimHistory())
|
|
209
|
+
this.update({});
|
|
210
|
+
}
|
|
211
|
+
reclaimHistory() {
|
|
212
|
+
if (this.historyPinned || !this.state.online)
|
|
213
|
+
return 0;
|
|
214
|
+
const removed = this.state.transcript.trimHistory(this.historyLimits);
|
|
215
|
+
if (removed)
|
|
216
|
+
releaseHistoryLayout(this.state.transcript);
|
|
217
|
+
return removed;
|
|
218
|
+
}
|
|
219
|
+
releaseTranscript() {
|
|
220
|
+
releaseHistoryLayout(this.state.transcript);
|
|
221
|
+
this.state.transcript.dispose();
|
|
222
|
+
this.historyPinned = false;
|
|
121
223
|
}
|
|
122
224
|
/** Stop the selected turn and then close, so quitting does not leave host work running.
|
|
123
225
|
* An idle session stays untouched, and an in-flight cancellation is awaited rather than repeated.
|
|
@@ -172,62 +274,26 @@ export class Controller {
|
|
|
172
274
|
try {
|
|
173
275
|
const sessions = array(object(await client.call('session/list', { _request: {} }, signal)).items).map(object);
|
|
174
276
|
signal.throwIfAborted();
|
|
277
|
+
const failures = [];
|
|
175
278
|
for (const session of sessions) {
|
|
176
279
|
signal.throwIfAborted();
|
|
177
280
|
const sessionId = string(session.sessionId);
|
|
178
281
|
if (!session.running && typeof session.updatedAt === 'number' && this.costUpdates.get(sessionId) === session.updatedAt)
|
|
179
282
|
continue;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
reject(error);
|
|
190
|
-
else
|
|
191
|
-
resolve(frame);
|
|
192
|
-
};
|
|
193
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
194
|
-
try {
|
|
195
|
-
sub = client.subscribe('session/follow', { request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true } }, {
|
|
196
|
-
item: value => { const frame = object(value); if (frame.type === 'snapshot')
|
|
197
|
-
finish(undefined, frame); },
|
|
198
|
-
end: error => finish(error ?? new Error('Cost history stream ended')),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
finish(error instanceof Error ? error : new Error(errorText(error)));
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
const cut = snapshot.cursor;
|
|
206
|
-
if (typeof cut !== 'number' || !Number.isSafeInteger(cut))
|
|
207
|
-
throw new Error('Invalid cost history cursor');
|
|
208
|
-
let page = snapshot;
|
|
209
|
-
const events = [];
|
|
210
|
-
while (true) {
|
|
283
|
+
try {
|
|
284
|
+
const history = await this.sessionCostHistory(session, signal);
|
|
285
|
+
await ledger.replace(sessionId, history.cursor, history.events);
|
|
286
|
+
if (!session.running && typeof session.updatedAt === 'number')
|
|
287
|
+
this.costUpdates.set(sessionId, session.updatedAt);
|
|
288
|
+
this.update({});
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
// One unreachable or rejected session must not freeze every other session's rates.
|
|
211
292
|
signal.throwIfAborted();
|
|
212
|
-
|
|
213
|
-
events.push(...costRecords(records));
|
|
214
|
-
if (!page.hasMore)
|
|
215
|
-
break;
|
|
216
|
-
const seqs = records.map(r => object(object(r).event).seq);
|
|
217
|
-
if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
|
|
218
|
-
throw new Error('Invalid cost history page');
|
|
219
|
-
const beforeSeq = Math.min(...seqs);
|
|
220
|
-
page = object(await client.call('session/page', { request: { address: { kind: 'session', sessionId }, throughSeq: cut, beforeSeq, maxMessages: 80 } }, signal));
|
|
221
|
-
if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
|
|
222
|
-
throw new Error('Cost history page did not advance');
|
|
293
|
+
failures.push(`${sessionId}: ${errorText(error)}`);
|
|
223
294
|
}
|
|
224
|
-
if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
|
|
225
|
-
throw new Error('Cannot attribute inherited session usage');
|
|
226
|
-
await ledger.replace(sessionId, cut, events);
|
|
227
|
-
if (!session.running && typeof session.updatedAt === 'number')
|
|
228
|
-
this.costUpdates.set(sessionId, session.updatedAt);
|
|
229
|
-
this.update({});
|
|
230
295
|
}
|
|
296
|
+
ledger.error = failures.length === 0 ? '' : `${failures.length} of ${sessions.length} sessions failed: ${failures[0]}`;
|
|
231
297
|
ledger.scannedAt = Date.now();
|
|
232
298
|
}
|
|
233
299
|
catch (error) {
|
|
@@ -246,16 +312,132 @@ export class Controller {
|
|
|
246
312
|
this.costTask = undefined;
|
|
247
313
|
}
|
|
248
314
|
}
|
|
315
|
+
/** Read one session's complete cost history, retrying a subagent child with its other delivery mode. */
|
|
316
|
+
async sessionCostHistory(session, signal) {
|
|
317
|
+
let lastError;
|
|
318
|
+
for (const address of costAddresses(session)) {
|
|
319
|
+
try {
|
|
320
|
+
return await this.readCostHistory(address, signal);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
lastError = error;
|
|
324
|
+
// Only a delivery-mode mismatch justifies the other form; every other failure is final here.
|
|
325
|
+
if (!(error instanceof RemoteError && error.code === 'subagent/unauthorized'))
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
throw lastError;
|
|
330
|
+
}
|
|
331
|
+
/** Page one addressed session's history into the billing events the ledger folds. */
|
|
332
|
+
async readCostHistory(address, signal) {
|
|
333
|
+
const client = this.host;
|
|
334
|
+
const snapshot = await new Promise((resolve, reject) => {
|
|
335
|
+
let sub;
|
|
336
|
+
const timeout = setTimeout(() => finish(new Error('Cost history snapshot timed out')), client.timeoutMs);
|
|
337
|
+
const onAbort = () => finish(new Error('Cost refresh cancelled'));
|
|
338
|
+
const finish = (error, frame) => {
|
|
339
|
+
clearTimeout(timeout);
|
|
340
|
+
signal.removeEventListener('abort', onAbort);
|
|
341
|
+
sub?.cancel();
|
|
342
|
+
if (error)
|
|
343
|
+
reject(error);
|
|
344
|
+
else
|
|
345
|
+
resolve(frame);
|
|
346
|
+
};
|
|
347
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
348
|
+
try {
|
|
349
|
+
sub = client.subscribe('session/follow', { request: { address, maxMessages: 80, assistantStream: true } }, {
|
|
350
|
+
item: value => { const frame = object(value); if (frame.type === 'snapshot')
|
|
351
|
+
finish(undefined, frame); },
|
|
352
|
+
end: error => finish(error ?? new Error('Cost history stream ended')),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
finish(error instanceof Error ? error : new Error(errorText(error)));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
const cursor = snapshot.cursor;
|
|
360
|
+
if (typeof cursor !== 'number' || !Number.isSafeInteger(cursor))
|
|
361
|
+
throw new Error('Invalid cost history cursor');
|
|
362
|
+
let page = snapshot;
|
|
363
|
+
const events = [];
|
|
364
|
+
while (true) {
|
|
365
|
+
signal.throwIfAborted();
|
|
366
|
+
const records = array(page.records);
|
|
367
|
+
events.push(...costRecords(records));
|
|
368
|
+
if (!page.hasMore)
|
|
369
|
+
break;
|
|
370
|
+
const seqs = records.map(r => object(object(r).event).seq);
|
|
371
|
+
if (!seqs.length || seqs.some(n => typeof n !== 'number' || !Number.isSafeInteger(n)))
|
|
372
|
+
throw new Error('Invalid cost history page');
|
|
373
|
+
const beforeSeq = Math.min(...seqs);
|
|
374
|
+
page = object(await client.call('session/page', { request: { address, throughSeq: cursor, beforeSeq, maxMessages: 80 } }, signal));
|
|
375
|
+
if (page.hasMore && array(page.records).every(r => Number(object(object(r).event).seq) >= beforeSeq))
|
|
376
|
+
throw new Error('Cost history page did not advance');
|
|
377
|
+
}
|
|
378
|
+
if (object(snapshot.header).isSeeded === true && !events.some(e => e.type === 'session/end-seed' && object(e.data).inherited === true))
|
|
379
|
+
throw new Error('Cannot attribute inherited session usage');
|
|
380
|
+
return { cursor, events };
|
|
381
|
+
}
|
|
249
382
|
/** Refresh both lists from the host, then show the requested picker. */
|
|
250
383
|
async showPicker(screen) {
|
|
251
|
-
await this.releasePending();
|
|
252
384
|
const [workspaces, sessions] = await Promise.all([this.host.listWorkspaces(), this.host.listSessions()]);
|
|
253
385
|
this.update({ screen, workspaces, sessions });
|
|
254
386
|
}
|
|
387
|
+
/** Resolve a removal command to one reviewable object without changing the selection.
|
|
388
|
+
* @param kind - Workspace registration removal or session archival.
|
|
389
|
+
* @param query - Exact name, ID, or unambiguous ID prefix.
|
|
390
|
+
* @returns The fixed identity and display details for confirmation.
|
|
391
|
+
*/
|
|
392
|
+
async removalTarget(kind, query) {
|
|
393
|
+
const items = kind === 'workspace' ? await this.host.listWorkspaces() : await this.host.listSessions();
|
|
394
|
+
const row = resolveTarget(items, query, kind === 'workspace' ? 'workspaceId' : 'sessionId', item => kind === 'workspace' ? [string(item.title), string(item.path)] : [sessionLabel(item)]);
|
|
395
|
+
return kind === 'workspace' ? { kind, id: string(row.workspaceId), name: string(row.title), path: string(row.path) }
|
|
396
|
+
: { kind, id: string(row.sessionId), name: sessionLabel(row),
|
|
397
|
+
empty: row.blank === true && row.running === false
|
|
398
|
+
&& !this.runningUpdates.get(string(row.sessionId))
|
|
399
|
+
&& !(this.telemetry.view(string(row.sessionId)).queued ?? 0)
|
|
400
|
+
&& !(this.telemetry.view(string(row.sessionId)).jobs ?? 0)
|
|
401
|
+
&& !(this.state.sessionId === row.sessionId && this.admission) };
|
|
402
|
+
}
|
|
403
|
+
/** Apply a confirmed removal or freshly verified empty-session archival; directories and logs are preserved.
|
|
404
|
+
* @param target - Exact workspace or session identity reviewed by the user.
|
|
405
|
+
*/
|
|
406
|
+
async removeTarget(target) {
|
|
407
|
+
const client = this.host;
|
|
408
|
+
const receipt = object(await client.call(target.kind === 'workspace' ? 'workspace/delete' : 'workspace/archiveSession', {
|
|
409
|
+
request: target.kind === 'workspace' ? { workspaceId: target.id } : { sessionId: target.id },
|
|
410
|
+
}));
|
|
411
|
+
if (client !== this.client)
|
|
412
|
+
return;
|
|
413
|
+
if (target.kind === 'session') {
|
|
414
|
+
client.archivedSessionIds = new Set(array(receipt.archivedSessionIds).map(string));
|
|
415
|
+
if (this.state.sessionId === target.id) {
|
|
416
|
+
this.pickWorkspace(this.state.workspaceId);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
if (receipt.deleted !== true)
|
|
421
|
+
throw new Error('Host did not confirm workspace removal');
|
|
422
|
+
this.update({ workspaces: this.state.workspaces.filter(row => row.workspaceId !== target.id),
|
|
423
|
+
...(this.state.workspaceId === target.id ? { workspaceId: undefined } : {}) });
|
|
424
|
+
}
|
|
425
|
+
this.update({ screen: target.kind === 'workspace' ? 'workspaces' : 'sessions',
|
|
426
|
+
status: target.kind === 'workspace' ? 'Workspace registration removed' : 'Session archived' });
|
|
427
|
+
// A refresh failure must not make a successful mutation look like a rejected deletion.
|
|
428
|
+
try {
|
|
429
|
+
await this.showPicker(target.kind === 'workspace' ? 'workspaces' : 'sessions');
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
this.update({ error: `Removal completed; list refresh failed: ${errorText(error)}` });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
255
435
|
/** Pick a workspace, or use all sessions when the identity is omitted. */
|
|
256
436
|
pickWorkspace(workspaceId) {
|
|
257
|
-
this.follow?.cancel();
|
|
258
437
|
this.selection++;
|
|
438
|
+
this.follow?.cancel();
|
|
439
|
+
this.follow = undefined;
|
|
440
|
+
this.releaseTranscript();
|
|
259
441
|
this.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
|
|
260
442
|
}
|
|
261
443
|
/** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix. */
|
|
@@ -267,7 +449,6 @@ export class Controller {
|
|
|
267
449
|
const workspaces = await this.host.listWorkspaces();
|
|
268
450
|
const workspace = resolveTarget(workspaces, query, 'workspaceId', item => [string(item.title), string(item.path)]);
|
|
269
451
|
const sessions = await this.host.listSessions();
|
|
270
|
-
await this.releasePending();
|
|
271
452
|
this.update({ workspaces, sessions });
|
|
272
453
|
this.pickWorkspace(string(workspace.workspaceId));
|
|
273
454
|
}
|
|
@@ -302,9 +483,10 @@ export class Controller {
|
|
|
302
483
|
/** Replace the selected transcript and cancel its preceding follow stream. */
|
|
303
484
|
async selectSession(sessionId) {
|
|
304
485
|
this.stoppingSession = undefined;
|
|
305
|
-
await this.releasePending();
|
|
306
|
-
this.follow?.cancel();
|
|
307
486
|
const selection = ++this.selection;
|
|
487
|
+
this.follow?.cancel();
|
|
488
|
+
this.follow = undefined;
|
|
489
|
+
this.releaseTranscript();
|
|
308
490
|
const transcript = new Transcript();
|
|
309
491
|
const workspace = this.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
|
|
310
492
|
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
@@ -320,10 +502,11 @@ export class Controller {
|
|
|
320
502
|
return;
|
|
321
503
|
try {
|
|
322
504
|
transcript.accept(value);
|
|
505
|
+
this.reclaimHistory();
|
|
323
506
|
const frame = object(value);
|
|
324
507
|
if (frame.type === 'snapshot')
|
|
325
508
|
this.telemetry.snapshot(sessionId, frame.projections);
|
|
326
|
-
this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.
|
|
509
|
+
this.update({ transcript, status: this.stoppingSession === sessionId ? this.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
|
|
327
510
|
}
|
|
328
511
|
catch (error) {
|
|
329
512
|
this.generationFailed?.(new Error(errorText(error)));
|
|
@@ -382,13 +565,50 @@ export class Controller {
|
|
|
382
565
|
async references(query, signal) {
|
|
383
566
|
return fileReferences(await this.host.call('fileReferences/list', { agentId: this.sessionId, query }, signal));
|
|
384
567
|
}
|
|
385
|
-
/**
|
|
386
|
-
|
|
568
|
+
/** Execute a human command directly, outside the model prompt queue.
|
|
569
|
+
* @param line - Complete slash command, including arguments.
|
|
570
|
+
* @param signal - Cancels the request while the host performs compaction.
|
|
571
|
+
* @returns The host's successful command result text.
|
|
572
|
+
*/
|
|
573
|
+
async command(line, signal) {
|
|
574
|
+
if (!this.state.transcript.ready)
|
|
575
|
+
throw new Error('Wait for the session snapshot before running commands');
|
|
576
|
+
const execution = await this.host.call('commands/execute', {
|
|
577
|
+
agentId: this.sessionId, line, submittedAttachments: [],
|
|
578
|
+
}, signal, null);
|
|
579
|
+
if (execution === undefined)
|
|
580
|
+
throw new Error(`This host does not provide ${line.split(/\s/, 1)[0]}`);
|
|
581
|
+
const result = object(object(execution).result);
|
|
582
|
+
if ((result.kind !== 'success' && result.kind !== 'error') || (result.text !== undefined && typeof result.text !== 'string')) {
|
|
583
|
+
throw new Error('Invalid command result from host');
|
|
584
|
+
}
|
|
585
|
+
if (result.kind === 'error')
|
|
586
|
+
throw new Error(string(result.text));
|
|
587
|
+
return result.text === undefined ? 'Command completed.' : string(result.text);
|
|
588
|
+
}
|
|
589
|
+
/** Remove one host-owned pending input; an already claimed item reports a host error.
|
|
590
|
+
* @param itemId - Queue occurrence identity from session/control.
|
|
591
|
+
*/
|
|
592
|
+
async removeQueued(itemId) {
|
|
593
|
+
await this.host.call('session/updateQueue', { request: { sessionId: this.sessionId, itemId, action: { kind: 'remove' } } });
|
|
594
|
+
}
|
|
595
|
+
/** Export the selected host log to a new local ZIP file.
|
|
596
|
+
* @param path - Optional local destination; existing files are never overwritten.
|
|
597
|
+
* @param signal - Cancels the download and removes an incomplete file.
|
|
598
|
+
* @returns Absolute saved filename.
|
|
599
|
+
*/
|
|
600
|
+
async exportLog(path, signal) {
|
|
601
|
+
return saveSessionLog(this.host, this.sessionId, path, signal);
|
|
602
|
+
}
|
|
603
|
+
/** Admit text once as steering while running, or a new turn while idle; a lost response can leave delivery uncertain. */
|
|
604
|
+
async prompt(text) {
|
|
605
|
+
if (this.state.pending.length)
|
|
606
|
+
throw new Error('Answer the pending question or approval first');
|
|
387
607
|
this.stoppingSession = undefined;
|
|
388
608
|
if (!this.state.transcript.ready)
|
|
389
609
|
throw new Error('Wait for the session snapshot before sending');
|
|
390
610
|
const admission = this.host.call('session/prompt', { request: {
|
|
391
|
-
sessionId: this.sessionId, requestId: randomUUID(), mode,
|
|
611
|
+
sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
|
|
392
612
|
content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
393
613
|
} });
|
|
394
614
|
this.admission = admission;
|
|
@@ -407,18 +627,109 @@ export class Controller {
|
|
|
407
627
|
this.update({ status: 'Cancellation requested' });
|
|
408
628
|
}
|
|
409
629
|
/** Add a page before the retained window using its fixed opening cut. */
|
|
410
|
-
async older(signal) {
|
|
411
|
-
const
|
|
630
|
+
async older(signal, transcript = this.state.transcript) {
|
|
631
|
+
const selection = this.selection;
|
|
632
|
+
if (transcript === this.state.transcript)
|
|
633
|
+
this.historyPinned = true;
|
|
412
634
|
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
413
635
|
return;
|
|
414
636
|
const result = await this.host.call('session/page', { request: {
|
|
415
637
|
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: transcript.cursor,
|
|
416
638
|
beforeSeq: transcript.beforeSeq, maxMessages: 80,
|
|
417
639
|
} }, signal);
|
|
418
|
-
if (
|
|
640
|
+
if (selection !== this.selection)
|
|
419
641
|
return;
|
|
420
642
|
transcript.addPage(result);
|
|
421
|
-
this.update({
|
|
643
|
+
this.update({});
|
|
644
|
+
}
|
|
645
|
+
/** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
|
|
646
|
+
* @param query - Literal, case-insensitive text including folded reasoning.
|
|
647
|
+
* @param signal - Cancels HTTP and processing without cancelling the agent.
|
|
648
|
+
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
649
|
+
*/
|
|
650
|
+
async searchHistory(query, signal) {
|
|
651
|
+
const source = this.state.transcript;
|
|
652
|
+
if (!source.ready)
|
|
653
|
+
throw new Error('Wait for the session snapshot');
|
|
654
|
+
const sessionId = this.sessionId;
|
|
655
|
+
const selection = this.selection;
|
|
656
|
+
const throughSeq = source.readThrough;
|
|
657
|
+
const needle = query.toLowerCase();
|
|
658
|
+
const result = { items: [], truncated: false };
|
|
659
|
+
const scan = (transcript) => {
|
|
660
|
+
const messages = transcript.messages;
|
|
661
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
662
|
+
signal.throwIfAborted();
|
|
663
|
+
const message = messages[index];
|
|
664
|
+
if (message.role === 'Tool')
|
|
665
|
+
continue;
|
|
666
|
+
const text = message.text;
|
|
667
|
+
const match = text.toLowerCase().indexOf(needle);
|
|
668
|
+
if (match < 0)
|
|
669
|
+
continue;
|
|
670
|
+
if (result.items.length === 200) {
|
|
671
|
+
result.truncated = true;
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
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') });
|
|
675
|
+
}
|
|
676
|
+
return true;
|
|
677
|
+
};
|
|
678
|
+
signal.throwIfAborted();
|
|
679
|
+
if (!scan(source))
|
|
680
|
+
return result;
|
|
681
|
+
let beforeSeq = source.beforeSeq;
|
|
682
|
+
let hasMore = source.hasMore;
|
|
683
|
+
while (hasMore && beforeSeq !== undefined) {
|
|
684
|
+
const page = object(await this.host.call('session/page', { request: {
|
|
685
|
+
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
686
|
+
} }, signal));
|
|
687
|
+
signal.throwIfAborted();
|
|
688
|
+
if (selection !== this.selection)
|
|
689
|
+
throw new Error('Session changed while searching history');
|
|
690
|
+
const temporary = new Transcript();
|
|
691
|
+
try {
|
|
692
|
+
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
693
|
+
const next = temporary.beforeSeq;
|
|
694
|
+
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
695
|
+
throw new Error('Host history page did not advance');
|
|
696
|
+
if (!scan(temporary))
|
|
697
|
+
return result;
|
|
698
|
+
beforeSeq = next;
|
|
699
|
+
hasMore = temporary.hasMore;
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
temporary.dispose();
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
/** Load a separate small window ending at a search target; the live transcript keeps following.
|
|
708
|
+
* @param target - Durable message sequence to display.
|
|
709
|
+
* @param signal - Cancels the target-page request.
|
|
710
|
+
* @returns A caller-owned historical window that must be disposed when closed.
|
|
711
|
+
*/
|
|
712
|
+
async historyAt(target, signal) {
|
|
713
|
+
const source = this.state.transcript;
|
|
714
|
+
const selection = this.selection;
|
|
715
|
+
const page = object(await this.host.call('session/page', { request: {
|
|
716
|
+
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
|
|
717
|
+
beforeSeq: target + 1, maxMessages: 80,
|
|
718
|
+
} }, signal));
|
|
719
|
+
signal.throwIfAborted();
|
|
720
|
+
if (selection !== this.selection)
|
|
721
|
+
throw new Error('Session changed while opening history');
|
|
722
|
+
const window = new Transcript();
|
|
723
|
+
try {
|
|
724
|
+
window.accept({ type: 'snapshot', cursor: source.readThrough, assistantStream: { revision: 0 }, records: page.records, hasMore: page.hasMore });
|
|
725
|
+
if (!window.messages.some(message => message.seq === target))
|
|
726
|
+
throw new Error('The host did not return the requested message');
|
|
727
|
+
return window;
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
window.dispose();
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
422
733
|
}
|
|
423
734
|
/** Load the prefix required for an explicit history jump; never loop on an unadvancing page.
|
|
424
735
|
* @param target - Visible record sequence, or first for the oldest available history.
|
|
@@ -445,7 +756,8 @@ export class Controller {
|
|
|
445
756
|
if (!pending)
|
|
446
757
|
throw new Error('No pending interaction');
|
|
447
758
|
await this.reply(pending, { kind: 'result', value });
|
|
448
|
-
this.
|
|
759
|
+
this.interactions.delete(string(pending.eventId));
|
|
760
|
+
this.update({});
|
|
449
761
|
}
|
|
450
762
|
/** Restrict an approval command to an approval request. */
|
|
451
763
|
async approve(allowed) {
|
|
@@ -455,11 +767,12 @@ export class Controller {
|
|
|
455
767
|
}
|
|
456
768
|
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
457
769
|
get visibleSessions() {
|
|
770
|
+
const sessions = this.state.sessions.filter(item => !this.client?.archivedSessionIds.has(string(item.sessionId)));
|
|
458
771
|
if (this.state.showAllSessions || !this.state.workspaceId)
|
|
459
|
-
return
|
|
772
|
+
return sessions;
|
|
460
773
|
const workspace = this.state.workspaces.find(item => item.workspaceId === this.state.workspaceId);
|
|
461
774
|
const ids = new Set(array(workspace?.sessionIds ?? []).map(string));
|
|
462
|
-
return
|
|
775
|
+
return sessions.filter(item => ids.has(string(item.sessionId)));
|
|
463
776
|
}
|
|
464
777
|
get host() {
|
|
465
778
|
if (!this.client || !this.state.online)
|
|
@@ -472,18 +785,16 @@ export class Controller {
|
|
|
472
785
|
return this.state.sessionId;
|
|
473
786
|
}
|
|
474
787
|
update(patch) {
|
|
475
|
-
|
|
788
|
+
const next = { ...this.state, ...patch, version: this.state.version + 1 };
|
|
789
|
+
next.pending = next.online && next.screen === 'chat'
|
|
790
|
+
? [...this.interactions.values()].filter(frame => frame.agentId === next.sessionId) : [];
|
|
791
|
+
this.state = next;
|
|
476
792
|
for (const observer of this.observers)
|
|
477
793
|
observer();
|
|
478
794
|
}
|
|
479
795
|
async reply(frame, outcome) {
|
|
480
796
|
await this.host.call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
|
|
481
797
|
}
|
|
482
|
-
async releasePending() {
|
|
483
|
-
for (const pending of this.state.pending)
|
|
484
|
-
await this.reply(pending, { kind: 'next' });
|
|
485
|
-
this.update({ pending: [] });
|
|
486
|
-
}
|
|
487
798
|
refreshCatalog(client) {
|
|
488
799
|
const revision = ++this.catalogRevision;
|
|
489
800
|
const task = client.call('session/modelCatalog', {}).then(value => {
|
|
@@ -506,7 +817,7 @@ export class Controller {
|
|
|
506
817
|
this.runningUpdates.clear();
|
|
507
818
|
this.observedRunningAt.clear();
|
|
508
819
|
this.telemetry = new Telemetry();
|
|
509
|
-
this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined });
|
|
820
|
+
this.update({ controlError: undefined, modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
|
|
510
821
|
const client = this.makeClient();
|
|
511
822
|
this.client = client;
|
|
512
823
|
try {
|
|
@@ -526,9 +837,9 @@ export class Controller {
|
|
|
526
837
|
resolve();
|
|
527
838
|
}
|
|
528
839
|
else if (frame.type === 'waterfall') {
|
|
529
|
-
if (
|
|
530
|
-
|
|
531
|
-
this.update({
|
|
840
|
+
if (['approval/request', 'user-questions/request'].includes(string(frame.event))) {
|
|
841
|
+
this.interactions.set(string(frame.eventId), frame);
|
|
842
|
+
this.update({});
|
|
532
843
|
}
|
|
533
844
|
else {
|
|
534
845
|
void client.call('$events/result', { clientId: this.clientId,
|
|
@@ -536,7 +847,8 @@ export class Controller {
|
|
|
536
847
|
}
|
|
537
848
|
}
|
|
538
849
|
else if (frame.type === 'cancel') {
|
|
539
|
-
this.
|
|
850
|
+
this.interactions.delete(string(frame.eventId));
|
|
851
|
+
this.update({});
|
|
540
852
|
}
|
|
541
853
|
else if (frame.type === 'emit' && frame.event === 'api-session/status') {
|
|
542
854
|
const args = array(frame.args);
|
|
@@ -630,6 +942,7 @@ export class Controller {
|
|
|
630
942
|
this.generationFailed = undefined;
|
|
631
943
|
this.selection++;
|
|
632
944
|
this.state.transcript.ready = false;
|
|
945
|
+
this.interactions.clear();
|
|
633
946
|
this.update({ online: false, pending: [] });
|
|
634
947
|
await client.close();
|
|
635
948
|
await this.costTask;
|