@itookit/dsht 0.3.8 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +33 -12
- package/README.zh.md +35 -14
- package/dist/catalog/controller.d.ts +26 -6
- package/dist/catalog/controller.js +73 -45
- package/dist/catalog/index.d.ts +1 -0
- package/dist/cli/dsht.js +206 -18
- package/dist/cli/startup.d.ts +40 -0
- package/dist/cli/startup.js +314 -0
- package/dist/cli/trace-summary.d.ts +78 -0
- package/dist/cli/trace-summary.js +241 -0
- package/dist/cli/verifier.d.ts +64 -0
- package/dist/cli/verifier.js +265 -0
- package/dist/contracts.d.ts +359 -0
- package/dist/contracts.js +1 -0
- package/dist/controller/commands.d.ts +47 -0
- package/dist/controller/commands.js +322 -0
- package/dist/controller/connection-streams.d.ts +22 -0
- package/dist/controller/connection-streams.js +105 -0
- package/dist/controller/connection.d.ts +24 -31
- package/dist/controller/connection.js +48 -111
- package/dist/controller/controller.d.ts +412 -178
- package/dist/controller/controller.js +713 -167
- package/dist/controller/foreground.d.ts +44 -0
- package/dist/controller/foreground.js +79 -0
- package/dist/controller/index.d.ts +8 -1
- package/dist/controller/index.js +5 -0
- package/dist/controller/loop-contract.d.ts +136 -0
- package/dist/controller/loop-contract.js +308 -0
- package/dist/controller/loop-coordinator.d.ts +48 -0
- package/dist/controller/loop-coordinator.js +647 -0
- package/dist/controller/loop-prompts-schema.d.ts +56 -0
- package/dist/controller/loop-prompts-schema.js +144 -0
- package/dist/controller/loop-prompts.d.ts +55 -0
- package/dist/controller/loop-prompts.generated.d.ts +104 -0
- package/dist/controller/loop-prompts.generated.js +185 -0
- package/dist/controller/loop-prompts.js +104 -0
- package/dist/controller/loop-protocols.d.ts +39 -0
- package/dist/controller/loop-protocols.js +115 -0
- package/dist/controller/loop.d.ts +275 -0
- package/dist/controller/loop.js +378 -0
- package/dist/controller/prompts.d.ts +54 -0
- package/dist/controller/prompts.js +162 -0
- package/dist/controller/trace-log.d.ts +45 -0
- package/dist/controller/trace-log.js +144 -0
- package/dist/controller/verifier.d.ts +130 -0
- package/dist/controller/verifier.js +75 -0
- package/dist/cost/controller.d.ts +1 -1
- package/dist/cost/controller.js +12 -5
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger.d.ts +0 -1
- package/dist/cost/ledger.js +0 -1
- package/dist/cost/scanner.js +1 -0
- package/dist/json.d.ts +18 -0
- package/dist/json.js +19 -0
- package/dist/references.d.ts +25 -0
- package/dist/references.js +26 -0
- package/dist/session/connection-view.d.ts +2 -11
- package/dist/session/controller.d.ts +94 -105
- package/dist/session/controller.js +262 -536
- package/dist/session/history-reader.d.ts +32 -0
- package/dist/session/history-reader.js +170 -0
- package/dist/session/history.d.ts +6 -18
- package/dist/session/history.js +1 -24
- package/dist/session/index.d.ts +9 -4
- package/dist/session/index.js +7 -3
- package/dist/session/info.d.ts +20 -82
- package/dist/session/info.js +52 -25
- package/dist/session/interactions.d.ts +26 -0
- package/dist/session/interactions.js +75 -0
- package/dist/session/markdown.js +1 -1
- package/dist/session/math.js +1 -1
- package/dist/session/mutation-gate.d.ts +51 -0
- package/dist/session/mutation-gate.js +73 -0
- package/dist/session/navigation.d.ts +2 -89
- package/dist/session/navigation.js +2 -129
- package/dist/session/navigator.d.ts +47 -0
- package/dist/session/navigator.js +158 -0
- package/dist/session/peek.d.ts +38 -0
- package/dist/session/peek.js +103 -0
- package/dist/session/prompt-backfill.d.ts +23 -0
- package/dist/session/prompt-backfill.js +88 -0
- package/dist/session/references.d.ts +2 -20
- package/dist/session/references.js +1 -26
- package/dist/session/runtime.d.ts +26 -0
- package/dist/session/runtime.js +28 -0
- package/dist/session/state.d.ts +20 -0
- package/dist/session/state.js +1 -0
- package/dist/session/telemetry.d.ts +25 -17
- package/dist/session/telemetry.js +66 -60
- package/dist/session/transcript.d.ts +5 -7
- package/dist/session/transcript.js +2 -15
- package/dist/session/types.d.ts +25 -0
- package/dist/session/types.js +0 -1
- package/dist/session-title.d.ts +9 -0
- package/dist/session-title.js +21 -0
- package/dist/shell/controller.d.ts +31 -1
- package/dist/shell/controller.js +34 -2
- package/dist/shell/index.d.ts +3 -3
- package/dist/shell/index.js +2 -2
- package/dist/shell/runner.d.ts +10 -0
- package/dist/shell/runner.js +48 -9
- package/dist/slash/index.d.ts +10 -0
- package/dist/slash/index.js +7 -0
- package/dist/slash/parse.d.ts +42 -0
- package/dist/slash/parse.js +259 -0
- package/dist/slash/pipeline.d.ts +140 -0
- package/dist/slash/pipeline.js +115 -0
- package/dist/slash/registry.d.ts +88 -0
- package/dist/slash/registry.js +177 -0
- package/dist/slash/types.d.ts +126 -0
- package/dist/slash/types.js +1 -0
- package/dist/state.d.ts +16 -18
- package/dist/state.js +4 -3
- package/dist/text.d.ts +28 -0
- package/dist/text.js +55 -0
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/transport/events.d.ts +104 -0
- package/dist/transport/events.js +149 -0
- package/dist/transport/wire.d.ts +9 -17
- package/dist/transport/wire.js +2 -27
- package/dist/ui/app.js +750 -550
- package/dist/ui/chat/header.js +1 -1
- package/dist/ui/chat/history-view.d.ts +1 -1
- package/dist/ui/chat/loop-status.d.ts +11 -0
- package/dist/ui/chat/loop-status.js +28 -0
- package/dist/ui/chat/navigation-model.d.ts +86 -0
- package/dist/ui/chat/navigation-model.js +107 -0
- package/dist/ui/chat/shell-view.d.ts +17 -2
- package/dist/ui/chat/shell-view.js +45 -3
- package/dist/ui/chat/status.d.ts +47 -3
- package/dist/ui/chat/status.js +65 -50
- package/dist/ui/chat/use-history-view.d.ts +69 -0
- package/dist/ui/chat/use-history-view.js +123 -0
- package/dist/ui/chat/viewport.d.ts +1 -1
- package/dist/ui/dialogs/cost.d.ts +21 -4
- package/dist/ui/dialogs/cost.js +7 -12
- package/dist/ui/dialogs/index.d.ts +22 -5
- package/dist/ui/dialogs/index.js +19 -3
- package/dist/ui/dialogs/loop.d.ts +43 -0
- package/dist/ui/dialogs/loop.js +224 -0
- package/dist/ui/dialogs/peek.d.ts +25 -0
- package/dist/ui/dialogs/peek.js +35 -0
- package/dist/ui/dialogs/picker.d.ts +2 -0
- package/dist/ui/dialogs/picker.js +4 -2
- package/dist/ui/dialogs/use-panels.d.ts +53 -0
- package/dist/ui/dialogs/use-panels.js +51 -0
- package/dist/ui/input/mouse.d.ts +12 -2
- package/dist/ui/input/mouse.js +20 -7
- package/dist/ui/input/references.d.ts +1 -1
- package/dist/ui/input/use-composer.d.ts +35 -0
- package/dist/ui/input/use-composer.js +109 -0
- package/dist/ui/input/use-deferred-lines.d.ts +16 -0
- package/dist/ui/input/use-deferred-lines.js +54 -0
- package/dist/ui/input/use-history-recall.d.ts +20 -0
- package/dist/ui/input/use-history-recall.js +47 -0
- package/dist/ui/status/model.d.ts +7 -0
- package/dist/ui/status/model.js +5 -0
- package/dist/ui/theme/index.d.ts +1 -1
- package/package.json +6 -4
- package/dist/ui/commands/parse.d.ts +0 -104
- package/dist/ui/commands/parse.js +0 -135
- package/dist/ui/commands/registry.d.ts +0 -33
- package/dist/ui/commands/registry.js +0 -73
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
/** Application facade: composes the connection, session, catalog and cost domains. */
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import { Client } from "../transport/client.js";
|
|
3
|
-
import { writeHeapSnapshot } from "../storage/index.js";
|
|
4
|
-
import { errorText } from "../transport/wire.js";
|
|
4
|
+
import { removeFile, writeHeapSnapshot } from "../storage/index.js";
|
|
5
|
+
import { errorText, string } from "../transport/wire.js";
|
|
5
6
|
import { DEFAULT_HISTORY_LIMITS } from "../session/memory.js";
|
|
6
7
|
import { layoutStats } from "../session/history.js";
|
|
7
8
|
import { markdownCacheStats } from "../session/markdown.js";
|
|
8
9
|
import { SessionController } from "../session/controller.js";
|
|
9
10
|
import { CatalogController } from "../catalog/controller.js";
|
|
10
11
|
import { CostController } from "../cost/controller.js";
|
|
11
|
-
import { costText } from "../cost/ledger.js";
|
|
12
12
|
import { ConnectionController } from "./connection.js";
|
|
13
13
|
import { MemoryLog } from "./memory-log.js";
|
|
14
|
+
import { ForegroundSlot } from "./foreground.js";
|
|
15
|
+
import { TraceLog } from "./trace-log.js";
|
|
16
|
+
import { PromptStore } from "./prompts.js";
|
|
17
|
+
import { latestAssistantText } from "./loop.js";
|
|
18
|
+
import { LoopCoordinator } from "./loop-coordinator.js";
|
|
19
|
+
import { loopRecords as listLoopRecords } from "./loop-protocols.js";
|
|
20
|
+
import { SessionPeek } from "../session/peek.js";
|
|
21
|
+
import { costAddresses } from "../cost/scanner.js";
|
|
22
|
+
import { sessionLabel } from "../session-title.js";
|
|
14
23
|
import { clearReactMeasures, measureCount } from "./perf-measures.js";
|
|
15
24
|
import { initialState } from "../state.js";
|
|
16
|
-
import { ShellController } from "../shell/index.js";
|
|
25
|
+
import { ShellController, localSourceId } from "../shell/index.js";
|
|
17
26
|
/** Environment for a local `!` command: this client's variables without its credentials.
|
|
18
27
|
*
|
|
19
28
|
* `DSH_URL` is removed as well as the token, because the URL form the README documents can carry a
|
|
@@ -27,18 +36,39 @@ function shellEnv() {
|
|
|
27
36
|
delete env.DSH_URL;
|
|
28
37
|
return env;
|
|
29
38
|
}
|
|
39
|
+
/** Sources this client registers before it forgets the oldest; a session list can still find them. */
|
|
40
|
+
const SOURCE_LIMIT = 50;
|
|
41
|
+
/** The tag every verifier session title starts with, so a reader can tell it apart in a list. */
|
|
42
|
+
const VERIFIER_TAG = '[dsht-verify] ';
|
|
43
|
+
/** What the read-only view calls a verifier's session: its title without the marker prefix. */
|
|
44
|
+
function verifierLabel(title) {
|
|
45
|
+
const stripped = title.startsWith(VERIFIER_TAG) ? title.slice(VERIFIER_TAG.length) : title;
|
|
46
|
+
return stripped.trim() === '' ? title : stripped.trim();
|
|
47
|
+
}
|
|
48
|
+
/** File `/handoff` clears on this machine before it asks the agent to write a handoff. */
|
|
49
|
+
const HANDOFF_FILE = 'HANDOFF.md';
|
|
50
|
+
/** Instruction `/handoff` sends once this client's own copy of the file is gone.
|
|
51
|
+
*
|
|
52
|
+
* The sections are explicit because a handoff is read by whoever continues the work: why the
|
|
53
|
+
* session exists, the goal, and the state of every task, including the ones that cannot be done.
|
|
54
|
+
*/
|
|
55
|
+
const HANDOFF_PROMPT = [
|
|
56
|
+
'Write a session handoff to HANDOFF.md in the workspace root, overwriting whatever is there,',
|
|
57
|
+
'in the language of this conversation. Cover, in this order:',
|
|
58
|
+
'(1) why this session exists and what triggered it;',
|
|
59
|
+
'(2) the goal it is working toward;',
|
|
60
|
+
'(3) every task and its state — completed, still open, or impossible, each with its reason;',
|
|
61
|
+
'(4) the decisions made and the files changed;',
|
|
62
|
+
'(5) how to verify the current state;',
|
|
63
|
+
'(6) the exact next steps for whoever continues this work.',
|
|
64
|
+
'Base it only on this session; never invent work that did not happen.',
|
|
65
|
+
].join(' ');
|
|
30
66
|
/** Application facade over the domain controllers; the UI owns only this object.
|
|
31
67
|
*
|
|
32
68
|
* State lives here, connection generations live in `connection`, the selected session and its
|
|
33
69
|
* history live in `session`, model metadata lives in `catalog`, and billing lives in `cost`.
|
|
34
70
|
*/
|
|
35
71
|
export class Controller {
|
|
36
|
-
base;
|
|
37
|
-
initialSession;
|
|
38
|
-
costs;
|
|
39
|
-
historyLimits;
|
|
40
|
-
memoryLogPath;
|
|
41
|
-
localDirectory;
|
|
42
72
|
state = initialState();
|
|
43
73
|
/** Physical connection, retry loop and projection store. */
|
|
44
74
|
connection;
|
|
@@ -50,31 +80,92 @@ export class Controller {
|
|
|
50
80
|
cost;
|
|
51
81
|
/** Bounded runtime memory samples; present only when a log path was supplied. */
|
|
52
82
|
memoryLog;
|
|
83
|
+
/** Bounded connection, screen and selection trace; present only when a path was supplied. */
|
|
84
|
+
trace;
|
|
85
|
+
/** Shortcut prompts the operator saved; in memory for this run when no path was supplied. */
|
|
86
|
+
promptStore;
|
|
87
|
+
/** Finished turns of the selected session, so a waiter never has to sample a cached flag. */
|
|
88
|
+
completedTurns = 0;
|
|
89
|
+
/** Sessions the host reported busy, so a lone idle frame cannot claim a finished turn. */
|
|
90
|
+
busySessions = new Set();
|
|
91
|
+
/** Loop execution owns all verifier, settling and deadline state. */
|
|
92
|
+
loops;
|
|
53
93
|
/** Local `!` commands, run on this machine and shown inline in the transcript. */
|
|
54
94
|
shell;
|
|
95
|
+
/** Mutating surface the UI drives. */
|
|
96
|
+
actions;
|
|
97
|
+
/** Read-only surface the UI drives. */
|
|
98
|
+
queries;
|
|
99
|
+
/** Host base URL this client talks to. */
|
|
100
|
+
base;
|
|
101
|
+
/** Billing ledger supplied at construction, when any. */
|
|
102
|
+
costs;
|
|
103
|
+
/** History retention budgets in force. */
|
|
104
|
+
historyLimits;
|
|
105
|
+
/** Runtime memory log path, when one was configured. */
|
|
106
|
+
memoryLogPath;
|
|
107
|
+
/** Directory this client runs in. */
|
|
108
|
+
localDirectory;
|
|
109
|
+
/** Session opened at startup, when one was named. */
|
|
110
|
+
initialSession;
|
|
55
111
|
observers = new Set();
|
|
56
112
|
selector = 0;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
|
|
113
|
+
connectionSettled = false;
|
|
114
|
+
/** Startup defaults apply once; subsequent generations restore the operator's selection. */
|
|
115
|
+
initialized = false;
|
|
116
|
+
/** Counter behind `commandId`, so every executed line has one identifier in begin and end. */
|
|
117
|
+
commandSeq = 0;
|
|
118
|
+
/** Scheduling and cancellation are owned independently of domain operations. */
|
|
119
|
+
foregroundSlot = new ForegroundSlot({
|
|
120
|
+
changed: started => this.update(started ? { lastFailure: '' } : {}),
|
|
121
|
+
trace: event => this.traceEvent('foreground', event),
|
|
122
|
+
});
|
|
123
|
+
/** Read-only follower for the full-screen view; it borrows the connection and never selects. */
|
|
124
|
+
peek;
|
|
125
|
+
/** Sources this client created, keyed by session id; the host cannot record that lineage itself. */
|
|
126
|
+
createdSources = new Map();
|
|
127
|
+
/** The source the read-only view is showing, when one is open. */
|
|
128
|
+
peekId;
|
|
129
|
+
constructor(options) {
|
|
130
|
+
const { base, token, initialSession, costs } = options;
|
|
131
|
+
const makeClient = options.makeClient ?? (() => new Client(base));
|
|
132
|
+
const authenticate = options.authenticate ?? (client => client.authenticate(token ?? ''));
|
|
133
|
+
const historyLimits = options.historyLimits ?? DEFAULT_HISTORY_LIMITS;
|
|
134
|
+
const shellEnabled = options.shellEnabled ?? true;
|
|
62
135
|
this.base = base;
|
|
63
|
-
this.initialSession = initialSession;
|
|
64
136
|
this.costs = costs;
|
|
65
137
|
this.historyLimits = historyLimits;
|
|
66
|
-
this.memoryLogPath = memoryLogPath;
|
|
67
|
-
this.localDirectory = localDirectory;
|
|
68
|
-
|
|
69
|
-
this.
|
|
70
|
-
|
|
138
|
+
this.memoryLogPath = options.memoryLogPath;
|
|
139
|
+
this.localDirectory = options.localDirectory ?? process.cwd();
|
|
140
|
+
this.initialSession = initialSession;
|
|
141
|
+
this.loops = new LoopCoordinator({
|
|
142
|
+
facts: () => ({ sessionId: this.state.sessionId, online: this.state.online,
|
|
143
|
+
ready: this.state.session.record.ready, busy: this.foregroundSlot.snapshot !== undefined, pending: this.state.pending.length > 0 }),
|
|
144
|
+
reply: () => latestAssistantText(this.state.session.record.messages),
|
|
145
|
+
send: prompt => this.session.promptInternal(prompt),
|
|
146
|
+
publish: failure => this.update(failure === undefined ? {} : { lastFailure: failure }),
|
|
147
|
+
trace: (event, detail) => this.traceEvent(event, detail),
|
|
148
|
+
}, { directory: this.localDirectory, verifier: options.verifier, verdictRoot: options.verdictRoot,
|
|
149
|
+
deadlineMs: options.deadlineMs, allowSelfFallback: options.allowSelfFallback });
|
|
150
|
+
const connectionOptions = { base, token, initialSession, makeClient, authenticate };
|
|
151
|
+
this.connection = new ConnectionController(this, connectionOptions, this);
|
|
152
|
+
// The view follows other sessions over the same connection; it never becomes a second writer.
|
|
153
|
+
this.peek = new SessionPeek(this.connection);
|
|
154
|
+
// Every session write is admitted in order; the trace records that order, which is what answers
|
|
155
|
+
// "who dispatched first" when two mutations of one session compete.
|
|
156
|
+
this.session = new SessionController(this, this.connection, this.connection, historyLimits, admission => this.traceEvent('mutation', { session: admission.sessionId, lane: admission.lane, waited: admission.waited }));
|
|
71
157
|
this.shell = new ShellController({
|
|
72
158
|
publish: () => this.update({}),
|
|
73
159
|
cwd: () => this.localDirectory,
|
|
74
160
|
env: () => shellEnv(),
|
|
75
161
|
anchor: () => this.state.session.record.readThrough,
|
|
76
162
|
}, shellEnabled);
|
|
77
|
-
this.catalog = new CatalogController(
|
|
163
|
+
this.catalog = new CatalogController({
|
|
164
|
+
client: () => this.connection.client(), require: () => this.connection.require(),
|
|
165
|
+
online: () => this.state.online, signal: () => this.connection.signal(),
|
|
166
|
+
selection: () => ({ revision: this.selection(), sessionId: this.state.sessionId }),
|
|
167
|
+
publish: patch => this.update(patch),
|
|
168
|
+
});
|
|
78
169
|
if (costs)
|
|
79
170
|
this.cost = new CostController(costs, {
|
|
80
171
|
client: () => this.connection.client(),
|
|
@@ -86,8 +177,13 @@ export class Controller {
|
|
|
86
177
|
scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
|
|
87
178
|
scanDone: sessionId => this.session.rememberScanDone(sessionId),
|
|
88
179
|
});
|
|
89
|
-
if (memoryLogPath !== undefined)
|
|
90
|
-
this.memoryLog = new MemoryLog(memoryLogPath, () => this.memorySample());
|
|
180
|
+
if (this.memoryLogPath !== undefined)
|
|
181
|
+
this.memoryLog = new MemoryLog(this.memoryLogPath, () => this.memorySample());
|
|
182
|
+
if (options.tracePath !== undefined)
|
|
183
|
+
this.trace = new TraceLog(options.tracePath);
|
|
184
|
+
this.promptStore = new PromptStore(options.promptsPath);
|
|
185
|
+
this.actions = this.buildActions();
|
|
186
|
+
this.queries = this.buildQueries();
|
|
91
187
|
}
|
|
92
188
|
/** React-compatible state subscription. */
|
|
93
189
|
subscribe = (listener) => {
|
|
@@ -97,7 +193,7 @@ export class Controller {
|
|
|
97
193
|
/** Snapshot identity changes only when the controller publishes. */
|
|
98
194
|
snapshot = () => this.state;
|
|
99
195
|
/** Current projection store; replaced at each connection generation. */
|
|
100
|
-
get telemetry() { return this.
|
|
196
|
+
get telemetry() { return this.session.telemetry; }
|
|
101
197
|
/** Record of the selected session; the one strong owner lives in `State.session`. */
|
|
102
198
|
get record() { return this.state.session.record; }
|
|
103
199
|
/** @returns The current selector generation. */
|
|
@@ -107,24 +203,221 @@ export class Controller {
|
|
|
107
203
|
/** Publish a state patch, re-deriving the visible pending interactions.
|
|
108
204
|
* @param patch - Fields to replace on the current state.
|
|
109
205
|
*/
|
|
206
|
+
/** Whether an operation owns the client's foreground slot right now.
|
|
207
|
+
* @returns True while an operation is running.
|
|
208
|
+
*/
|
|
209
|
+
busy() { return this.foregroundSlot.snapshot !== undefined; }
|
|
110
210
|
update(patch) {
|
|
211
|
+
const previous = this.state;
|
|
111
212
|
const next = { ...this.state, ...patch, version: this.state.version + 1 };
|
|
112
213
|
next.pending = next.online && next.screen === 'chat' && this.session
|
|
113
|
-
? this.session.pendingFor(next) : [];
|
|
214
|
+
? this.session.pendingFor(next.sessionId) : [];
|
|
215
|
+
// The shell service owns its blocks; state carries only the plain snapshot the UI renders.
|
|
216
|
+
if (this.shell)
|
|
217
|
+
next.shell = this.shell.snapshot();
|
|
218
|
+
// A loop belongs to one session: selecting a different session ends it, but a reconnect — which
|
|
219
|
+
// transiently clears the selection — must not, or a long review could never finish.
|
|
220
|
+
if (next.sessionId !== undefined && next.sessionId !== this.state.sessionId && this.loops.sessionId !== next.sessionId) {
|
|
221
|
+
this.loops.forget();
|
|
222
|
+
// The read-only view belongs to the conversation that opened it; leaving that conversation
|
|
223
|
+
// closes it, and the session switch that follows is never left rendering someone else's rows.
|
|
224
|
+
this.dropPeek();
|
|
225
|
+
}
|
|
114
226
|
this.state = next;
|
|
227
|
+
this.traceTransition(previous, next);
|
|
115
228
|
for (const observer of this.observers)
|
|
116
229
|
observer();
|
|
230
|
+
this.loops.changed();
|
|
231
|
+
}
|
|
232
|
+
/** Record one diagnostic event; a no-op when no trace path was configured. */
|
|
233
|
+
traceEvent(event, detail = {}) {
|
|
234
|
+
this.trace?.record({ event, ...detail });
|
|
235
|
+
}
|
|
236
|
+
/** Record one diagnostic event from outside the controller, such as a UI-only decision.
|
|
237
|
+
*
|
|
238
|
+
* The composition root knows things this class cannot see — which record the operator highlighted,
|
|
239
|
+
* that a form was refused a name — and a start that never reaches `startLoop` is otherwise invisible
|
|
240
|
+
* in every log. Same no-op contract as the internal call.
|
|
241
|
+
* @param event - Event name, e.g. `loop-ui`.
|
|
242
|
+
* @param detail - Identifiers only; never prompt or session text.
|
|
243
|
+
*/
|
|
244
|
+
traceNote(event, detail = {}) {
|
|
245
|
+
this.traceEvent(event, detail);
|
|
246
|
+
}
|
|
247
|
+
/** Mint the identifier one executed line carries through its `begin` and `end` events.
|
|
248
|
+
*
|
|
249
|
+
* Short and process-local on purpose: it exists to pair two lines of one file, not to identify a
|
|
250
|
+
* line across runs, so a monotonic counter beats a random id a reader would have to match by eye.
|
|
251
|
+
* @returns The next command id, such as `C17`.
|
|
252
|
+
*/
|
|
253
|
+
nextCommandId() {
|
|
254
|
+
this.commandSeq += 1;
|
|
255
|
+
return `C${this.commandSeq}`;
|
|
256
|
+
}
|
|
257
|
+
/** Record the state fields that decide which screen the reader is looking at.
|
|
258
|
+
*
|
|
259
|
+
* Every non-user transition is written here as well as at its cause, so a screen that moved
|
|
260
|
+
* without an expected reason is still visible in the trace rather than silently skipped.
|
|
261
|
+
* @param previous - State before the patch.
|
|
262
|
+
* @param next - State after the patch.
|
|
263
|
+
*/
|
|
264
|
+
traceTransition(previous, next) {
|
|
265
|
+
if (this.trace === undefined)
|
|
266
|
+
return;
|
|
267
|
+
const changed = {};
|
|
268
|
+
if (previous.screen !== next.screen)
|
|
269
|
+
changed.screen = `${previous.screen} -> ${next.screen}`;
|
|
270
|
+
if (previous.sessionId !== next.sessionId)
|
|
271
|
+
changed.session = `${previous.sessionId ?? 'none'} -> ${next.sessionId ?? 'none'}`;
|
|
272
|
+
if (previous.workspaceId !== next.workspaceId)
|
|
273
|
+
changed.workspace = `${previous.workspaceId ?? 'none'} -> ${next.workspaceId ?? 'none'}`;
|
|
274
|
+
if (previous.online !== next.online)
|
|
275
|
+
changed.online = next.online;
|
|
276
|
+
if (Object.keys(changed).length > 0)
|
|
277
|
+
this.traceEvent('state', changed);
|
|
278
|
+
}
|
|
279
|
+
/** Bind every mutating entry point to its private implementation.
|
|
280
|
+
*
|
|
281
|
+
* Each one names the kind and label of the operation it performs, because that is now the single
|
|
282
|
+
* answer to "what owns the client right now" (§6.2): the front end renders it and cancels it, and
|
|
283
|
+
* nothing else has to know which layer started the work.
|
|
284
|
+
*/
|
|
285
|
+
buildActions() {
|
|
286
|
+
return {
|
|
287
|
+
foreground: (kind, label, work, wait) => this.foregroundSlot.run(kind, label, work, wait),
|
|
288
|
+
cancelForeground: () => this.foregroundSlot.cancel(),
|
|
289
|
+
openPeek: id => this.openPeek(id),
|
|
290
|
+
closePeek: () => this.closePeek(),
|
|
291
|
+
switchWorkspace: id => this.runAction('navigation', 'Switching workspace…', signal => this.switchWorkspace(id, signal)),
|
|
292
|
+
switchSession: query => this.runAction('navigation', 'Switching session…', signal => this.switchSession(query, signal)),
|
|
293
|
+
selectSession: id => this.runAction('navigation', 'Loading session…', () => this.selectSession(id)),
|
|
294
|
+
createWorkspace: path => this.runAction('navigation', 'Registering workspace…', signal => this.createWorkspace(path, signal)),
|
|
295
|
+
createSession: () => this.runAction('navigation', 'Creating session…', signal => this.createSession(signal)),
|
|
296
|
+
createVerifierSession: title => this.runActionValue('verifier', 'Creating verifier session…', () => this.createVerifierSession(title)),
|
|
297
|
+
cancelVerifierSession: sessionId => this.runImmediateAction(async () => {
|
|
298
|
+
await this.session.cancelNamedSession(sessionId);
|
|
299
|
+
// The run is over even though its transcript stays readable; the list should say so.
|
|
300
|
+
this.endSource(sessionId, Date.now());
|
|
301
|
+
}),
|
|
302
|
+
showPicker: screen => this.runAction('picker', 'Listing…', signal => this.showPicker(screen, signal)),
|
|
303
|
+
removeTarget: target => this.runAction('removal', 'Removing…', () => this.removeTarget(target)),
|
|
304
|
+
removalTarget: (kind, query) => this.runActionValue('removal', 'Reading target…', () => this.removalTarget(kind, query)),
|
|
305
|
+
waitForHistory: signal => this.runAction('history', 'Loading history…', () => this.waitForHistory(signal)),
|
|
306
|
+
searchSessions: (query, workspaceOnly, signal) => this.runActionValue('search', 'Searching sessions…', () => this.searchSessions(query, workspaceOnly, signal)),
|
|
307
|
+
openHistory: (target, signal) => this.runAction('history', 'Opening history…', operation => this.session.openHistory(target, AbortSignal.any([signal, operation]))),
|
|
308
|
+
searchHistory: (query, signal) => this.runActionValue('search', 'Searching history…', operation => this.searchHistory(query, AbortSignal.any([signal, operation]))),
|
|
309
|
+
prompt: text => this.runAction('prompt', 'Sending…', () => this.prompt(text)),
|
|
310
|
+
handoff: () => this.runAction('handoff', 'Requesting handoff…', () => this.handoff()),
|
|
311
|
+
startLoop: (protocol, limits) => this.runAction('loop', 'Starting loop…', () => this.loops.start(protocol, limits)),
|
|
312
|
+
stopLoop: () => this.loops.stop(),
|
|
313
|
+
clearLoopResult: () => this.loops.clearResult(),
|
|
314
|
+
answerLoop: text => this.runAction('loop', 'Answering the verifier…', () => this.loops.answer(text)),
|
|
315
|
+
cancelTurn: () => this.runAction('interaction', 'Cancelling…', () => this.cancelTurn()),
|
|
316
|
+
answer: value => this.runAction('interaction', 'Answering…', () => this.answer(value)),
|
|
317
|
+
answerQuestion: input => this.runAction('interaction', 'Answering…', async () => {
|
|
318
|
+
if (!await this.answerQuestion(input))
|
|
319
|
+
throw new Error('The answer could not be sent');
|
|
320
|
+
}),
|
|
321
|
+
approve: allowed => this.runAction('interaction', 'Answering…', () => this.approve(allowed)),
|
|
322
|
+
dismissQuestion: () => this.runAction('interaction', 'Dismissing…', () => this.dismissQuestion()),
|
|
323
|
+
interrupt: force => this.interrupt(force),
|
|
324
|
+
older: (signal, transcript) => this.runAction('history', 'Loading history…', operation => this.older(signal === undefined ? operation : AbortSignal.any([signal, operation]), transcript)),
|
|
325
|
+
historyThrough: (target, signal) => this.runAction('history', 'Loading history…', operation => this.historyThrough(target, AbortSignal.any([signal, operation]))),
|
|
326
|
+
removeQueued: itemId => this.runAction('command', 'Removing queued input…', () => this.removeQueued(itemId)),
|
|
327
|
+
savePrompt: text => this.runImmediateAction(async () => {
|
|
328
|
+
await this.promptStore.save(text);
|
|
329
|
+
this.update({ lastFailure: '' });
|
|
330
|
+
}),
|
|
331
|
+
updatePrompt: (id, text) => this.runImmediateAction(async () => {
|
|
332
|
+
if (!await this.promptStore.update(id, text))
|
|
333
|
+
throw new Error('That saved prompt no longer exists');
|
|
334
|
+
this.update({ lastFailure: '' });
|
|
335
|
+
}),
|
|
336
|
+
deletePrompt: id => this.runImmediateAction(async () => {
|
|
337
|
+
if (!await this.promptStore.remove(id))
|
|
338
|
+
throw new Error('That saved prompt no longer exists');
|
|
339
|
+
this.update({ lastFailure: '' });
|
|
340
|
+
}),
|
|
341
|
+
command: (line, signal) => this.runActionValue('command', 'Running command…', () => this.command(line, signal)),
|
|
342
|
+
exportLog: (path, signal) => this.runActionValue('export', 'Exporting session log…', () => this.exportLog(path, signal)),
|
|
343
|
+
exportHtml: (path, signal) => this.runActionValue('export', 'Exporting conversation…', () => this.exportHtml(path, signal)),
|
|
344
|
+
selectModel: (provider, model, effort) => this.runAction('model', 'Selecting model…', signal => this.catalog.selectModel(provider, model, effort, signal)),
|
|
345
|
+
modelCatalog: () => this.runActionValue('model', 'Loading models…', signal => this.catalog.modelCatalog(signal)),
|
|
346
|
+
refreshCosts: signal => this.runAction('cost', 'Refreshing costs…', operation => this.refreshCosts(signal === undefined ? operation : AbortSignal.any([signal, operation]))),
|
|
347
|
+
loadPresetNames: () => this.loadPresetNames(),
|
|
348
|
+
clearFailure: () => this.clearFailure(),
|
|
349
|
+
enterPath: () => this.enterPath(),
|
|
350
|
+
pickWorkspace: id => this.pickWorkspace(id),
|
|
351
|
+
showChat: () => this.showChat(),
|
|
352
|
+
showLatest: () => this.session.setViewWindow(undefined),
|
|
353
|
+
pinHistory: pinned => this.pinHistory(pinned),
|
|
354
|
+
setAnswers: answers => this.setAnswers(answers),
|
|
355
|
+
setOption: option => this.setOption(option),
|
|
356
|
+
setApproval: approval => this.setApproval(approval),
|
|
357
|
+
recordRecall: value => this.recordRecall(value),
|
|
358
|
+
resetRecall: () => this.resetRecall(),
|
|
359
|
+
refillRecall: () => this.refillRecall(),
|
|
360
|
+
heapSnapshot: tag => this.heapSnapshot(tag),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/** Bind every read-only entry point; getters stay live because the values move between renders. */
|
|
364
|
+
buildQueries() {
|
|
365
|
+
const controller = this;
|
|
366
|
+
return {
|
|
367
|
+
get running() { return controller.running; },
|
|
368
|
+
get turnsCompleted() { return controller.completedTurns; },
|
|
369
|
+
get forkedVerification() { return controller.loops.forkedVerification; },
|
|
370
|
+
get selfScoring() { return controller.loops.selfScoring; },
|
|
371
|
+
get connectionSettled() { return controller.connectionSettled; },
|
|
372
|
+
get sessionName() { return controller.sessionName; },
|
|
373
|
+
get sessionMode() { return controller.sessionMode; },
|
|
374
|
+
get workingSince() { return controller.workingSince; },
|
|
375
|
+
get visibleSessions() { return controller.visibleSessions; },
|
|
376
|
+
get record() { return controller.record; },
|
|
377
|
+
get window() { return controller.window; },
|
|
378
|
+
get interaction() { return controller.interaction; },
|
|
379
|
+
get telemetry() { return controller.telemetry.reader; },
|
|
380
|
+
get recallAtOldest() { return controller.recallAtOldest; },
|
|
381
|
+
get recallLength() { return controller.recallLength; },
|
|
382
|
+
get recallHasOlder() { return controller.recallHasOlder; },
|
|
383
|
+
get prompts() { return controller.promptStore.list; },
|
|
384
|
+
get promptsError() { return controller.promptStore.error; },
|
|
385
|
+
get loop() { return controller.loops.progress; },
|
|
386
|
+
get foreground() { return controller.foregroundSlot.snapshot; },
|
|
387
|
+
get sources() { return controller.outputSources(); },
|
|
388
|
+
get peek() { return controller.peekSnapshot(); },
|
|
389
|
+
get activity() { return controller.activity; },
|
|
390
|
+
get loopRecords() { return listLoopRecords(); },
|
|
391
|
+
pendingCounts: () => controller.pendingCounts(),
|
|
392
|
+
recall: (direction, current) => controller.recall(direction, current),
|
|
393
|
+
references: (query, signal) => controller.references(query, signal),
|
|
394
|
+
render: input => controller.render(input),
|
|
395
|
+
};
|
|
117
396
|
}
|
|
118
397
|
/** Start one retry loop, with a fresh snapshot generation after every disconnect. */
|
|
119
|
-
start() {
|
|
398
|
+
start() {
|
|
399
|
+
this.connection.start();
|
|
400
|
+
this.memoryLog?.start();
|
|
401
|
+
// Reading the shortcuts file is local and may finish after the first paint. Nothing is
|
|
402
|
+
// republished for an empty list: the picker reads the live list when it opens, so a load that
|
|
403
|
+
// found nothing must not add a render to an unrelated interaction.
|
|
404
|
+
void this.promptStore.load().then(changed => { if (changed)
|
|
405
|
+
this.update({}); });
|
|
406
|
+
}
|
|
120
407
|
/** Cancel retries and HTTP, close the socket, and release session and catalog work. */
|
|
121
408
|
async stop() {
|
|
409
|
+
// Nobody waits forever for a slot that will never be handed out again.
|
|
410
|
+
this.foregroundSlot.close();
|
|
411
|
+
this.catalog.close();
|
|
412
|
+
await this.loops.close();
|
|
413
|
+
this.dropPeek();
|
|
122
414
|
await this.shell.stop();
|
|
123
415
|
await this.connection.stop();
|
|
124
416
|
await this.session.settle();
|
|
125
417
|
await this.catalog.settle();
|
|
126
418
|
await this.cost?.stop();
|
|
127
419
|
await this.memoryLog?.stop();
|
|
420
|
+
await this.trace?.settle();
|
|
128
421
|
this.session.release();
|
|
129
422
|
}
|
|
130
423
|
/** Stop the selected turn and then close, so quitting does not leave host work running.
|
|
@@ -135,25 +428,61 @@ export class Controller {
|
|
|
135
428
|
await this.session.interrupt(true);
|
|
136
429
|
await this.stop();
|
|
137
430
|
}
|
|
138
|
-
/** Run
|
|
431
|
+
/** Run one mutating operation inside the client's single foreground slot.
|
|
432
|
+
*
|
|
433
|
+
* Private on purpose: the application owns the slot, so a caller never hands the controller a
|
|
434
|
+
* closure to orchestrate. Every entry of `actions` uses it, and `actions.foreground` is the one
|
|
435
|
+
* door left open for work the front end orchestrates itself.
|
|
436
|
+
* @param kind - What the operation is, for the label and the trace.
|
|
437
|
+
* @param label - Human label shown while it runs.
|
|
139
438
|
* @param operation - Operation to run while the client is busy.
|
|
140
|
-
* @returns Whether the operation
|
|
439
|
+
* @returns Whether the operation ran to completion.
|
|
141
440
|
*/
|
|
142
|
-
async
|
|
143
|
-
if (
|
|
441
|
+
async runAction(kind, label, operation) {
|
|
442
|
+
if (!this.state.online) {
|
|
443
|
+
this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
|
|
144
444
|
return false;
|
|
145
|
-
|
|
445
|
+
}
|
|
446
|
+
return await this.foregroundSlot.run(kind, label, async (signal) => {
|
|
447
|
+
try {
|
|
448
|
+
await operation(signal);
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
this.update({ lastFailure: errorText(error) });
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
}) === true;
|
|
456
|
+
}
|
|
457
|
+
/** Same slot, for an operation that produces a value the caller needs. */
|
|
458
|
+
async runActionValue(kind, label, operation) {
|
|
459
|
+
if (!this.state.online) {
|
|
460
|
+
this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
return await this.foregroundSlot.run(kind, label, async (signal) => {
|
|
464
|
+
try {
|
|
465
|
+
return await operation(signal);
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
this.update({ lastFailure: errorText(error) });
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
/** Run outside the foreground slot, with the same application failure reporting.
|
|
474
|
+
* Local prompt edits need no connection; verifier cancellation uses the session control lane and
|
|
475
|
+
* must reach the host even while the foreground is busy or closed for shutdown.
|
|
476
|
+
*/
|
|
477
|
+
async runImmediateAction(operation) {
|
|
146
478
|
try {
|
|
147
479
|
await operation();
|
|
148
480
|
return true;
|
|
149
481
|
}
|
|
150
482
|
catch (error) {
|
|
151
|
-
this.update({
|
|
483
|
+
this.update({ lastFailure: errorText(error) });
|
|
152
484
|
return false;
|
|
153
485
|
}
|
|
154
|
-
finally {
|
|
155
|
-
this.update({ busy: false });
|
|
156
|
-
}
|
|
157
486
|
}
|
|
158
487
|
/** Read the counters one memory sample records; content never leaves as text.
|
|
159
488
|
*
|
|
@@ -211,92 +540,279 @@ export class Controller {
|
|
|
211
540
|
}
|
|
212
541
|
/** A new generation starts; drop generation-scoped domain state. */
|
|
213
542
|
begin() {
|
|
543
|
+
this.traceEvent('generation', { phase: 'begin', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
|
|
214
544
|
this.session.beginGeneration();
|
|
215
545
|
this.catalog.reset();
|
|
546
|
+
this.connectionSettled = false;
|
|
547
|
+
// A busy state does not survive the connection it was observed on.
|
|
548
|
+
this.busySessions.clear();
|
|
216
549
|
this.update({ controlError: undefined });
|
|
217
550
|
}
|
|
218
551
|
/** The event stream is ready and the control baseline is applied. */
|
|
219
552
|
async ready() {
|
|
553
|
+
this.update({ online: true, status: 'Connected', pending: [], lastFailure: '' });
|
|
220
554
|
this.catalog.refresh();
|
|
221
|
-
this.update({ online: true, status: 'Connected', error: '', pending: [] });
|
|
222
555
|
const screen = this.state.screen;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
556
|
+
this.traceEvent('generation', { phase: 'ready', screen });
|
|
557
|
+
this.traceEvent('navigation-refresh', { screen });
|
|
558
|
+
await this.session.refreshLists();
|
|
559
|
+
// Startup may choose the local workspace. Reconnect only refreshes data: it must not reinterpret
|
|
560
|
+
// the reader's current screen as a request to navigate or discard a selected conversation.
|
|
561
|
+
if (!this.initialized && this.state.screen === 'workspaces' && !this.state.sessionId && !this.initialSession) {
|
|
562
|
+
const adopted = this.session.adoptLocalWorkspace(this.localDirectory);
|
|
563
|
+
this.traceEvent('adopt', { directory: this.localDirectory, workspace: adopted ?? 'none' });
|
|
564
|
+
if (adopted !== undefined)
|
|
565
|
+
this.update({ status: 'Workspace from this directory · ← to switch' });
|
|
228
566
|
}
|
|
229
|
-
const
|
|
230
|
-
|
|
567
|
+
const selected = this.state.sessionId;
|
|
568
|
+
const sessionId = selected ?? this.loops.sessionId ?? (!this.initialized ? this.initialSession : undefined);
|
|
569
|
+
this.traceEvent('resolve', { session: sessionId ?? 'none', looping: this.loops.sessionId ?? 'none', reselect: sessionId !== undefined });
|
|
570
|
+
if (selected !== undefined)
|
|
571
|
+
this.session.restoreSelectedSession();
|
|
572
|
+
else if (sessionId !== undefined)
|
|
231
573
|
await this.session.selectSession(sessionId);
|
|
574
|
+
this.initialized = true;
|
|
575
|
+
// `online` means the socket works; `connectionSettled` means the picker and the selection are
|
|
576
|
+
// done, which is what an automation caller must wait for or it races `showPicker`.
|
|
577
|
+
this.connectionSettled = true;
|
|
578
|
+
this.update({});
|
|
579
|
+
this.traceEvent('generation', { phase: 'settled', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
|
|
232
580
|
this.cost?.start();
|
|
233
581
|
}
|
|
234
|
-
/** The generation ended; invalidate session work and stop the scan.
|
|
582
|
+
/** The generation ended; invalidate session work and stop the scan.
|
|
583
|
+
*
|
|
584
|
+
* A running loop is deliberately left alone: the host keeps running the review, and the client
|
|
585
|
+
* re-selects the same session after reconnecting, so only a switch to another session ends it.
|
|
586
|
+
*/
|
|
235
587
|
async ended() {
|
|
588
|
+
this.traceEvent('generation', { phase: 'ended', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
|
|
236
589
|
this.session.endGeneration();
|
|
237
590
|
await this.cost?.stop();
|
|
238
591
|
}
|
|
239
|
-
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
592
|
+
/** Route one normalized host event to the domain that owns it.
|
|
593
|
+
*
|
|
594
|
+
* This is the composition point: the connection knows only that an event arrived, so a feature
|
|
595
|
+
* never has to hold another feature. `false` means the host's waterfall is still unsettled.
|
|
596
|
+
* @param event - Normalized host event.
|
|
597
|
+
* @returns Whether a retained waterfall was consumed.
|
|
598
|
+
*/
|
|
599
|
+
event(event) {
|
|
600
|
+
switch (event.kind) {
|
|
601
|
+
case 'approval-request':
|
|
602
|
+
case 'question-request': return this.session.accept(event);
|
|
603
|
+
case 'waterfall-delegate': return false;
|
|
604
|
+
case 'cancel':
|
|
605
|
+
this.session.cancelled(event.eventId);
|
|
606
|
+
return true;
|
|
607
|
+
case 'agent-status':
|
|
608
|
+
this.session.status(event.sessionId, event.running);
|
|
609
|
+
if (event.running)
|
|
610
|
+
this.busySessions.add(event.sessionId);
|
|
611
|
+
else {
|
|
612
|
+
// Only a turn this client watched start counts as finished. A replayed or duplicated idle
|
|
613
|
+
// frame would otherwise look like a prompt completing, which `--wait` would trust.
|
|
614
|
+
const started = this.busySessions.delete(event.sessionId);
|
|
615
|
+
if (started && event.sessionId === this.state.sessionId)
|
|
616
|
+
this.completedTurns += 1;
|
|
617
|
+
this.cost?.onTurnIdle();
|
|
618
|
+
this.loops.idle(event.sessionId);
|
|
619
|
+
}
|
|
620
|
+
return true;
|
|
621
|
+
case 'catalog-invalidated':
|
|
622
|
+
this.catalog.refresh();
|
|
623
|
+
return true;
|
|
624
|
+
case 'session-error':
|
|
625
|
+
this.session.reportError(event.sessionId, event.error);
|
|
626
|
+
return true;
|
|
627
|
+
case 'control':
|
|
628
|
+
this.session.acceptControl(event.frame);
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
262
632
|
/** @returns Host running state of the selected session. */
|
|
263
633
|
get running() { return this.session.running; }
|
|
264
634
|
/** @returns Current session title, falling back to the list title and then the ID. */
|
|
265
635
|
get sessionName() { return this.session.sessionName; }
|
|
266
636
|
/** @returns Current agent-preset label. */
|
|
267
|
-
get sessionMode() { return this.session.sessionMode; }
|
|
637
|
+
get sessionMode() { return this.session.sessionMode(this.state.presets); }
|
|
268
638
|
/** @returns Epoch start of the active turn, when known. */
|
|
269
639
|
get workingSince() { return this.session.workingSince; }
|
|
640
|
+
/** What the client is doing right now, merged across the host turn and any running loop.
|
|
641
|
+
*
|
|
642
|
+
* This is the controller's answer, not a view's guess: a turn speaks for the session while it runs,
|
|
643
|
+
* otherwise a live loop speaks for itself with its own sub-state and clock, and neither means idle.
|
|
644
|
+
* @returns The activity, or undefined when nothing is in flight.
|
|
645
|
+
*/
|
|
646
|
+
get activity() {
|
|
647
|
+
if (this.running) {
|
|
648
|
+
return { kind: 'turn', ...(this.workingSince === undefined ? {} : { since: this.workingSince }) };
|
|
649
|
+
}
|
|
650
|
+
const progress = this.loops.progress;
|
|
651
|
+
if (progress === undefined || !progress.active)
|
|
652
|
+
return undefined;
|
|
653
|
+
// A paused run is still the client's work, but nothing is running: the bar must ask for the reader
|
|
654
|
+
// rather than claim a clock.
|
|
655
|
+
if (progress.phase === 'needs-human') {
|
|
656
|
+
return { kind: 'paused', title: progress.title, step: progress.step, total: progress.total };
|
|
657
|
+
}
|
|
658
|
+
if (progress.activity === undefined)
|
|
659
|
+
return undefined;
|
|
660
|
+
return { kind: 'loop', activity: progress.activity, title: progress.title,
|
|
661
|
+
step: progress.step, total: progress.total, startedAt: progress.startedAt };
|
|
662
|
+
}
|
|
270
663
|
/** @returns Sessions accounted to the selected workspace, minus archived identities. */
|
|
271
664
|
get visibleSessions() { return this.session.visibleSessions; }
|
|
665
|
+
/** Every readable output source, newest activity first.
|
|
666
|
+
*
|
|
667
|
+
* Three origins feed one list, because a reader asking "what is that session" does not care which
|
|
668
|
+
* layer knows about it: sessions this client created (the host cannot record that link), local `!`
|
|
669
|
+
* runs it already holds, and host-created subagent children, whose only lineage is their list row.
|
|
670
|
+
* A client-created source wins over the list row for the same session, since it says more.
|
|
671
|
+
* @returns Sources to offer, running ones first.
|
|
672
|
+
*/
|
|
673
|
+
outputSources() {
|
|
674
|
+
const sources = [];
|
|
675
|
+
const seen = new Set();
|
|
676
|
+
for (const [id, source] of this.createdSources) {
|
|
677
|
+
sources.push(source);
|
|
678
|
+
seen.add(id);
|
|
679
|
+
}
|
|
680
|
+
for (const block of this.state.shell.blocks) {
|
|
681
|
+
// A note runs nothing: it is a bar pointing at another source, which is listed on its own.
|
|
682
|
+
if (block.kind !== 'shell')
|
|
683
|
+
continue;
|
|
684
|
+
const id = localSourceId(block.id);
|
|
685
|
+
if (seen.has(id))
|
|
686
|
+
continue;
|
|
687
|
+
seen.add(id);
|
|
688
|
+
sources.push({
|
|
689
|
+
id, kind: 'local', label: `! ${block.command}`,
|
|
690
|
+
state: block.status === 'running' ? 'running' : 'ended',
|
|
691
|
+
startedAt: block.startedAt,
|
|
692
|
+
...(block.endedAt === undefined ? {} : { endedAt: block.endedAt }),
|
|
693
|
+
createdBy: 'shell',
|
|
694
|
+
...(this.state.sessionId === undefined ? {} : { parentSessionId: this.state.sessionId }),
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
for (const row of this.visibleSessions) {
|
|
698
|
+
const sessionId = string(row.sessionId);
|
|
699
|
+
const parent = typeof row.parentSessionId === 'string' ? row.parentSessionId : '';
|
|
700
|
+
if (sessionId === '' || parent === '' || row.origin !== 'subagent' || seen.has(sessionId))
|
|
701
|
+
continue;
|
|
702
|
+
seen.add(sessionId);
|
|
703
|
+
sources.push({
|
|
704
|
+
id: sessionId, kind: 'session', label: sessionLabel(row), state: 'ended',
|
|
705
|
+
...(typeof row.updatedAt === 'number' ? { startedAt: row.updatedAt } : {}),
|
|
706
|
+
createdBy: 'agent', parentSessionId: parent,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
return sources.sort((a, b) => a.state === b.state
|
|
710
|
+
? (b.startedAt ?? 0) - (a.startedAt ?? 0)
|
|
711
|
+
: a.state === 'running' ? -1 : 1);
|
|
712
|
+
}
|
|
713
|
+
/** Register a session this client created for a verifier, so the list can explain it. */
|
|
714
|
+
async createVerifierSession(title) {
|
|
715
|
+
const sessionId = await this.session.createNamedSession(title);
|
|
716
|
+
if (sessionId === undefined)
|
|
717
|
+
return undefined;
|
|
718
|
+
const parent = this.state.sessionId;
|
|
719
|
+
this.createdSources.set(sessionId, {
|
|
720
|
+
id: sessionId, kind: 'session', label: verifierLabel(title), state: 'running', startedAt: Date.now(),
|
|
721
|
+
createdBy: 'verifier',
|
|
722
|
+
...(parent === undefined ? {} : { parentSessionId: parent }),
|
|
723
|
+
...(this.loops.verifierName === undefined ? {} : { detail: `verifier ${this.loops.verifierName}` }),
|
|
724
|
+
});
|
|
725
|
+
// The bar the run echoed into the transcript now opens this session, which is the newest check.
|
|
726
|
+
this.shell.link(sessionId);
|
|
727
|
+
// A long-lived client runs many reviews; the registry is a convenience list, not a ledger, and a
|
|
728
|
+
// dropped session is still reachable by selecting it. Insertion order is the age order.
|
|
729
|
+
while (this.createdSources.size > SOURCE_LIMIT) {
|
|
730
|
+
const oldest = this.createdSources.keys().next().value;
|
|
731
|
+
if (oldest === undefined)
|
|
732
|
+
break;
|
|
733
|
+
this.createdSources.delete(oldest);
|
|
734
|
+
}
|
|
735
|
+
this.update({});
|
|
736
|
+
return sessionId;
|
|
737
|
+
}
|
|
738
|
+
/** Mark a source this client created as finished, keeping it readable. */
|
|
739
|
+
endSource(id, at) {
|
|
740
|
+
const source = this.createdSources.get(id);
|
|
741
|
+
if (source === undefined || source.state === 'ended')
|
|
742
|
+
return;
|
|
743
|
+
this.createdSources.set(id, { ...source, state: 'ended', endedAt: at });
|
|
744
|
+
this.update({});
|
|
745
|
+
}
|
|
746
|
+
/** Open one source read-only at full screen; a session source starts following it. */
|
|
747
|
+
openPeek(id) {
|
|
748
|
+
const source = this.outputSources().find(candidate => candidate.id === id);
|
|
749
|
+
if (source === undefined || this.peekId === id)
|
|
750
|
+
return;
|
|
751
|
+
this.peekId = id;
|
|
752
|
+
this.traceEvent('peek begin', { source: id, kind: source.kind });
|
|
753
|
+
if (source.kind === 'session') {
|
|
754
|
+
const row = this.visibleSessions.find(candidate => string(candidate.sessionId) === id);
|
|
755
|
+
// The list row is the only place that knows a child needs its parent in the address.
|
|
756
|
+
this.peek.open(row === undefined ? [{ kind: 'session', sessionId: id }] : costAddresses(row), () => this.update({}));
|
|
757
|
+
}
|
|
758
|
+
this.update({});
|
|
759
|
+
}
|
|
760
|
+
/** Close the read-only view and release whatever it followed. */
|
|
761
|
+
closePeek() {
|
|
762
|
+
if (this.peekId === undefined)
|
|
763
|
+
return;
|
|
764
|
+
this.traceEvent('peek end', { source: this.peekId });
|
|
765
|
+
this.peek.close();
|
|
766
|
+
this.peekId = undefined;
|
|
767
|
+
this.update({});
|
|
768
|
+
}
|
|
769
|
+
/** Release the view without publishing; the caller is already inside an update. */
|
|
770
|
+
dropPeek() {
|
|
771
|
+
if (this.peekId === undefined)
|
|
772
|
+
return;
|
|
773
|
+
this.traceEvent('peek end', { source: this.peekId });
|
|
774
|
+
this.peek.close();
|
|
775
|
+
this.peekId = undefined;
|
|
776
|
+
}
|
|
777
|
+
/** What the read-only view renders, or undefined while it is closed. */
|
|
778
|
+
peekSnapshot() {
|
|
779
|
+
if (this.peekId === undefined)
|
|
780
|
+
return undefined;
|
|
781
|
+
const source = this.outputSources().find(candidate => candidate.id === this.peekId);
|
|
782
|
+
if (source === undefined)
|
|
783
|
+
return undefined;
|
|
784
|
+
if (source.kind === 'local') {
|
|
785
|
+
const block = this.state.shell.blocks.find(candidate => `shell:${candidate.id}` === source.id);
|
|
786
|
+
return { source, lines: block?.lines ?? [] };
|
|
787
|
+
}
|
|
788
|
+
const followed = this.peek.snapshot;
|
|
789
|
+
if (followed === undefined)
|
|
790
|
+
return { source };
|
|
791
|
+
return { source, transcript: followed.transcript, ...(followed.error === undefined ? {} : { error: followed.error }) };
|
|
792
|
+
}
|
|
272
793
|
/** @returns Unanswered interactions by session, for the state each list row reports. */
|
|
273
794
|
pendingCounts() { return this.session.pendingCounts(); }
|
|
274
795
|
/** Load the optional preset roster once per connection. */
|
|
275
796
|
loadPresetNames() { this.catalog.loadPresetNames(); }
|
|
276
|
-
/** @returns Host model routes and adapter-owned reasoning choices. */
|
|
277
|
-
async modelCatalog() { return this.catalog.modelCatalog(); }
|
|
278
|
-
/** Select the next request's model.
|
|
279
|
-
* @param provider - Host provider route ID.
|
|
280
|
-
* @param model - Exact model ID.
|
|
281
|
-
* @param reasoningEffort - Optional adapter-owned effort ID.
|
|
282
|
-
*/
|
|
283
|
-
async selectModel(provider, model, reasoningEffort) {
|
|
284
|
-
await this.catalog.selectModel(provider, model, reasoningEffort);
|
|
285
|
-
}
|
|
286
797
|
/** Stop the selected turn, or allow exit only while idle.
|
|
287
798
|
* @param force - Send an explicit cancellation even when the cached running flag is idle.
|
|
288
799
|
* @returns True when the caller may exit.
|
|
289
800
|
*/
|
|
290
|
-
interrupt(force = false) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return this.costs?.hasSession(sessionId) ? costText(this.costs.total(sessionId)) : '?';
|
|
801
|
+
interrupt(force = false) {
|
|
802
|
+
// Esc and Ctrl+C stop the automated loop as well as the turn; otherwise it would keep sending.
|
|
803
|
+
this.loops.stop();
|
|
804
|
+
return this.session.interrupt(force);
|
|
295
805
|
}
|
|
806
|
+
/** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
|
|
296
807
|
/** Keep history stable while the user reads, searches, or expands it.
|
|
297
808
|
* @param pinned - Whether the main transcript is being read away from its tail.
|
|
298
809
|
*/
|
|
299
810
|
pinHistory(pinned) { this.session.pinHistory(pinned); }
|
|
811
|
+
/** @returns The detached history window the reader opened, if any. */
|
|
812
|
+
get window() { return this.session.window; }
|
|
813
|
+
/** Show a detached history window, releasing the one it replaces.
|
|
814
|
+
* @param window - Record to display, or undefined to return to the live transcript.
|
|
815
|
+
*/
|
|
300
816
|
/** Recall one step through the selected session's prompt index; never touches the network.
|
|
301
817
|
* @param direction - Negative for older input, positive for newer input.
|
|
302
818
|
* @param current - Composer content before recall began, restored at the newest position.
|
|
@@ -319,45 +835,17 @@ export class Controller {
|
|
|
319
835
|
* @returns Whether any older prompt was recovered.
|
|
320
836
|
*/
|
|
321
837
|
refillRecall() { return this.session.refillRecall(); }
|
|
322
|
-
/** Composer draft, caret and parked draft of the selected session. */
|
|
323
|
-
get composer() { return this.session.composer; }
|
|
324
|
-
/** Replace the composer text and caret.
|
|
325
|
-
* @param draft - New text.
|
|
326
|
-
* @param cursor - Caret column; defaults to the end of the text.
|
|
327
|
-
*/
|
|
328
|
-
setComposer(draft, cursor) { this.session.setComposer(draft, cursor); }
|
|
329
|
-
/** Move the caret without changing the text.
|
|
330
|
-
* @param cursor - Caret column.
|
|
331
|
-
*/
|
|
332
|
-
setComposerCursor(cursor) { this.session.setComposerCursor(cursor); }
|
|
333
|
-
/** Move a non-empty draft aside while a dialog owns the keyboard. */
|
|
334
|
-
parkComposer() { this.session.parkComposer(); }
|
|
335
|
-
/** Give a parked draft back once no dialog needs the keyboard. */
|
|
336
|
-
restoreComposer() { this.session.restoreComposer(); }
|
|
337
|
-
/** How the selected session's record is being read right now. */
|
|
338
|
-
get view() { return this.session.view; }
|
|
339
|
-
/** Show a detached history window, releasing the one it replaces.
|
|
340
|
-
* @param window - Record to display, or undefined to return to the live transcript.
|
|
341
|
-
*/
|
|
342
|
-
setViewWindow(window) { this.session.setViewWindow(window); }
|
|
343
|
-
/** Move the reader's position inside the displayed record.
|
|
344
|
-
* @param scroll - Rows scrolled back from the live end.
|
|
345
|
-
*/
|
|
346
|
-
setScroll(scroll) { this.session.setScroll(scroll); }
|
|
347
|
-
/** Replace the set of expanded reasoning blocks.
|
|
348
|
-
* @param folds - Sequences to expand beyond the default fold.
|
|
349
|
-
*/
|
|
350
|
-
setFolds(folds) { this.session.setFolds(folds); }
|
|
351
|
-
/** Set the fold mode of the live attempt's completed reasoning.
|
|
352
|
-
* @param reasoning - `row` to fold, `full` to keep the streamed text.
|
|
353
|
-
*/
|
|
354
|
-
setLiveReasoning(reasoning) { this.session.setLiveReasoning(reasoning); }
|
|
355
838
|
/** Local answer state for the selected session's pending waterfalls. */
|
|
356
839
|
get interaction() { return this.session.interaction; }
|
|
357
840
|
/** Replace the partly collected answers, keyed by waterfall event id.
|
|
358
841
|
* @param answers - Answers collected so far, by event id.
|
|
359
842
|
*/
|
|
360
843
|
setAnswers(answers) { this.session.setAnswers(answers); }
|
|
844
|
+
/** Clear the internal last failure; a no-op when there is none. */
|
|
845
|
+
clearFailure() {
|
|
846
|
+
if (this.state.lastFailure !== '')
|
|
847
|
+
this.update({ lastFailure: '' });
|
|
848
|
+
}
|
|
361
849
|
/** Replace the pending question's option keyboard state.
|
|
362
850
|
* @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
|
|
363
851
|
*/
|
|
@@ -366,38 +854,6 @@ export class Controller {
|
|
|
366
854
|
* @param approval - Selected approval row; undefined clears the highlight.
|
|
367
855
|
*/
|
|
368
856
|
setApproval(approval) { this.session.setApproval(approval); }
|
|
369
|
-
/** Composer-adjacent `@` reference menu state. */
|
|
370
|
-
get reference() { return this.session.reference; }
|
|
371
|
-
/** Highlight one row of the open reference menu.
|
|
372
|
-
* @param index - Row index into the current matches.
|
|
373
|
-
*/
|
|
374
|
-
setReferenceIndex(index) { this.session.setReferenceIndex(index); }
|
|
375
|
-
/** Remember the draft that dismissed the reference menu.
|
|
376
|
-
* @param draft - Composer text at dismissal, or undefined to allow the menu again.
|
|
377
|
-
*/
|
|
378
|
-
setReferenceDismissed(draft) { this.session.setReferenceDismissed(draft); }
|
|
379
|
-
/** Panels the selected session has open. */
|
|
380
|
-
get panels() { return this.session.panels; }
|
|
381
|
-
/** Show or hide the reasoning panel.
|
|
382
|
-
* @param open - Whether `/think` is open.
|
|
383
|
-
*/
|
|
384
|
-
openThoughts(open) { this.session.openThoughts(open); }
|
|
385
|
-
/** Show or hide the pending-input panel.
|
|
386
|
-
* @param open - Whether `/queue` is open.
|
|
387
|
-
*/
|
|
388
|
-
openQueue(open) { this.session.openQueue(open); }
|
|
389
|
-
/** Show the model dialog at one step, or close it.
|
|
390
|
-
* @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
|
|
391
|
-
*/
|
|
392
|
-
setModelPanel(model) { this.session.setModelPanel(model); }
|
|
393
|
-
/** Show the history or content-search dialog, or close it.
|
|
394
|
-
* @param history - Query, content-search mode and matches; undefined closes the dialog.
|
|
395
|
-
*/
|
|
396
|
-
setHistoryPanel(history) { this.session.setHistoryPanel(history); }
|
|
397
|
-
/** Show the host session-search results, or close them.
|
|
398
|
-
* @param search - Query, results and truncation flag; undefined closes the dialog.
|
|
399
|
-
*/
|
|
400
|
-
setSearchPanel(search) { this.session.setSearchPanel(search); }
|
|
401
857
|
/** Refresh all HTTP-visible sessions without changing the selected conversation.
|
|
402
858
|
* @param signal - Optional cancellation for an explicit /cost refresh.
|
|
403
859
|
*/
|
|
@@ -405,7 +861,10 @@ export class Controller {
|
|
|
405
861
|
/** Refresh both lists from the host, then show the requested picker.
|
|
406
862
|
* @param screen - Picker to display after the refresh.
|
|
407
863
|
*/
|
|
408
|
-
async showPicker(screen) {
|
|
864
|
+
async showPicker(screen, signal) {
|
|
865
|
+
this.traceEvent('action', { action: 'showPicker', screen });
|
|
866
|
+
await this.session.showPicker(screen, signal);
|
|
867
|
+
}
|
|
409
868
|
/** Resolve a removal command to one reviewable object.
|
|
410
869
|
* @param kind - Workspace registration removal or session archival.
|
|
411
870
|
* @param query - Exact name, ID, or unambiguous ID prefix.
|
|
@@ -415,31 +874,63 @@ export class Controller {
|
|
|
415
874
|
/** Apply a confirmed removal or verified empty-session archival.
|
|
416
875
|
* @param target - Exact workspace or session identity reviewed by the user.
|
|
417
876
|
*/
|
|
418
|
-
async removeTarget(target) {
|
|
877
|
+
async removeTarget(target) {
|
|
878
|
+
this.traceEvent('action', { action: 'removeTarget', kind: target.kind, id: target.id });
|
|
879
|
+
await this.session.removeTarget(target);
|
|
880
|
+
}
|
|
419
881
|
/** Pick a workspace, or use all sessions when the identity is omitted.
|
|
420
882
|
* @param workspaceId - Workspace to select, if any.
|
|
421
883
|
*/
|
|
422
|
-
pickWorkspace(workspaceId) {
|
|
884
|
+
pickWorkspace(workspaceId) {
|
|
885
|
+
this.traceEvent('action', { action: 'pickWorkspace', workspace: workspaceId ?? 'none' });
|
|
886
|
+
this.session.pickWorkspace(workspaceId);
|
|
887
|
+
}
|
|
888
|
+
/** Leave a picker and return to the selected conversation, without reloading it.
|
|
889
|
+
* @returns Whether there was a selected conversation to return to.
|
|
890
|
+
*/
|
|
891
|
+
showChat() {
|
|
892
|
+
const shown = this.session.showChat();
|
|
893
|
+
this.traceEvent('action', { action: 'showChat', shown });
|
|
894
|
+
return shown;
|
|
895
|
+
}
|
|
423
896
|
/** Open a workspace picker, or resolve a workspace target.
|
|
424
897
|
* @param query - Workspace target, if any.
|
|
425
898
|
*/
|
|
426
|
-
async switchWorkspace(query) {
|
|
899
|
+
async switchWorkspace(query, signal) {
|
|
900
|
+
this.traceEvent('action', { action: 'switchWorkspace', query: query ?? 'picker' });
|
|
901
|
+
await this.session.switchWorkspace(query, signal);
|
|
902
|
+
}
|
|
427
903
|
/** Guide session selection, list all sessions with `all`, or resolve a target.
|
|
428
904
|
* @param query - Session target, `all`, or nothing for the guided picker.
|
|
429
905
|
*/
|
|
430
|
-
async switchSession(query) {
|
|
906
|
+
async switchSession(query, signal) {
|
|
907
|
+
this.traceEvent('action', { action: 'switchSession', query: query ?? 'picker' });
|
|
908
|
+
await this.session.switchSession(query, signal);
|
|
909
|
+
}
|
|
431
910
|
/** Prompt for a host path without starting a local agent. */
|
|
432
|
-
enterPath() {
|
|
911
|
+
enterPath() {
|
|
912
|
+
this.traceEvent('action', { action: 'enterPath' });
|
|
913
|
+
this.session.enterPath();
|
|
914
|
+
}
|
|
433
915
|
/** Register a host directory and move to its session picker.
|
|
434
916
|
* @param path - Absolute directory path on the host.
|
|
435
917
|
*/
|
|
436
|
-
async createWorkspace(path) {
|
|
918
|
+
async createWorkspace(path, signal) {
|
|
919
|
+
this.traceEvent('action', { action: 'createWorkspace', path });
|
|
920
|
+
await this.session.createWorkspace(path, signal);
|
|
921
|
+
}
|
|
437
922
|
/** Create a session in the selected workspace. */
|
|
438
|
-
async createSession() {
|
|
923
|
+
async createSession(signal) {
|
|
924
|
+
this.traceEvent('action', { action: 'createSession', workspace: this.state.workspaceId ?? 'none' });
|
|
925
|
+
await this.session.createSession(signal);
|
|
926
|
+
}
|
|
439
927
|
/** Replace the selected transcript and follow the session.
|
|
440
928
|
* @param sessionId - Session to follow.
|
|
441
929
|
*/
|
|
442
|
-
async selectSession(sessionId) {
|
|
930
|
+
async selectSession(sessionId) {
|
|
931
|
+
this.traceEvent('action', { action: 'selectSession', session: sessionId });
|
|
932
|
+
await this.session.selectSession(sessionId);
|
|
933
|
+
}
|
|
443
934
|
/** Wait for the selected follow snapshot.
|
|
444
935
|
* @param signal - Cancels waiting without closing the session.
|
|
445
936
|
*/
|
|
@@ -487,11 +978,30 @@ export class Controller {
|
|
|
487
978
|
*/
|
|
488
979
|
heapSnapshot(tag) { return writeHeapSnapshot(process.cwd(), tag); }
|
|
489
980
|
/** Admit text once as steering while running, or a new turn while idle.
|
|
981
|
+
*
|
|
982
|
+
* Text the operator typed ends an automated review: the loop must not race a human for the turn,
|
|
983
|
+
* and the reply it would parse is no longer the reply to its own prompt.
|
|
490
984
|
* @param text - Composed prompt text.
|
|
491
985
|
*/
|
|
492
|
-
async prompt(text) {
|
|
986
|
+
async prompt(text) {
|
|
987
|
+
this.loops.stop();
|
|
988
|
+
await this.session.prompt(text);
|
|
989
|
+
}
|
|
990
|
+
/** Clear this client's stale handoff file, then ask the agent to write a new one.
|
|
991
|
+
*
|
|
992
|
+
* The deletion happens first and on this machine, so a handoff that never gets written cannot be
|
|
993
|
+
* mistaken for the previous one. The request itself is an ordinary turn: it steers a running
|
|
994
|
+
* agent and starts an idle one, exactly like submitted text.
|
|
995
|
+
*/
|
|
996
|
+
async handoff() {
|
|
997
|
+
await removeFile(join(this.localDirectory, HANDOFF_FILE));
|
|
998
|
+
await this.session.promptInternal(HANDOFF_PROMPT);
|
|
999
|
+
}
|
|
493
1000
|
/** Cancel the active turn; pending queue items remain host-owned. */
|
|
494
|
-
async cancelTurn() {
|
|
1001
|
+
async cancelTurn() {
|
|
1002
|
+
this.loops.stop();
|
|
1003
|
+
await this.session.cancelTurn();
|
|
1004
|
+
}
|
|
495
1005
|
/** Add a page before the retained window.
|
|
496
1006
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
497
1007
|
* @param transcript - Transcript to extend; defaults to the live one.
|
|
@@ -503,21 +1013,57 @@ export class Controller {
|
|
|
503
1013
|
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
504
1014
|
*/
|
|
505
1015
|
async searchHistory(query, signal) { return this.session.searchHistory(query, signal); }
|
|
506
|
-
/**
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
*/
|
|
511
|
-
async historyAt(target, signal) { return this.session.historyAt(target, signal); }
|
|
1016
|
+
/** Plain rows and offsets for one laid-out record; the projection stays in the session domain. */
|
|
1017
|
+
render(input) {
|
|
1018
|
+
return this.session.render(input);
|
|
1019
|
+
}
|
|
512
1020
|
/** Load the prefix required for an explicit history jump.
|
|
513
1021
|
* @param target - Visible record sequence, or first for the oldest available history.
|
|
514
1022
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
515
1023
|
*/
|
|
516
1024
|
async historyThrough(target, signal) { await this.session.historyThrough(target, signal); }
|
|
517
1025
|
/** Answer the oldest selected-session interaction, after explicit user action.
|
|
518
|
-
* @param value - Structured answer
|
|
1026
|
+
* @param value - Structured answer collected by the UI.
|
|
519
1027
|
*/
|
|
520
1028
|
async answer(value) { await this.session.answer(value); }
|
|
1029
|
+
/** Answer the current sub-question of the pending set, advancing the waterfall or sending it.
|
|
1030
|
+
*
|
|
1031
|
+
* The waterfall is interaction state of the session — which sub-question is current follows from the
|
|
1032
|
+
* answers collected so far, and the ticked labels live in the option state — so it belongs here
|
|
1033
|
+
* rather than in a front end. Both entry points then complete a question the same way: a line the
|
|
1034
|
+
* operator typed as an answer, and the option keys.
|
|
1035
|
+
* @param input - Labels chosen for this sub-question, or free text the operator typed.
|
|
1036
|
+
* @returns True once the answer was recorded or sent; a host refusal throws, leaving the collected
|
|
1037
|
+
* answers in place so the same submission can be retried.
|
|
1038
|
+
*/
|
|
1039
|
+
async answerQuestion(input = {}) {
|
|
1040
|
+
const pending = this.state.pending[0];
|
|
1041
|
+
if (pending?.kind !== 'question')
|
|
1042
|
+
throw new Error('No pending question');
|
|
1043
|
+
const interaction = this.state.session.interaction;
|
|
1044
|
+
const answers = interaction.answers[pending.eventId] ?? [];
|
|
1045
|
+
const question = pending.questions[answers.length];
|
|
1046
|
+
if (question === undefined)
|
|
1047
|
+
throw new Error('The pending question has changed');
|
|
1048
|
+
// A typed answer keeps whatever is ticked for a multi-select question; a picked option is explicit.
|
|
1049
|
+
const selected = input.selected ?? (question.multiSelect === true ? interaction.option?.selected ?? [] : []);
|
|
1050
|
+
const answer = { id: question.id, selected: [...selected], ...(input.custom ? { custom: input.custom } : {}) };
|
|
1051
|
+
const next = [...answers, answer];
|
|
1052
|
+
if (next.length < pending.questions.length) {
|
|
1053
|
+
this.setAnswers({ ...interaction.answers, [pending.eventId]: next });
|
|
1054
|
+
this.setOption(undefined);
|
|
1055
|
+
return true;
|
|
1056
|
+
}
|
|
1057
|
+
// A rejected submission keeps the collected answers and the keyboard state, so the reader can
|
|
1058
|
+
// retry the same answer instead of rebuilding it: a refusal throws out of here before the
|
|
1059
|
+
// collected answers are dropped.
|
|
1060
|
+
await this.answer({ answers: next });
|
|
1061
|
+
const rest = { ...interaction.answers };
|
|
1062
|
+
delete rest[pending.eventId];
|
|
1063
|
+
this.setAnswers(rest);
|
|
1064
|
+
this.setOption(undefined);
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
521
1067
|
/** Approve or reject the pending approval request.
|
|
522
1068
|
* @param allowed - Whether the request is approved once.
|
|
523
1069
|
*/
|