@itookit/dsht 0.3.7 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +31 -11
  3. package/README.zh.md +31 -11
  4. package/dist/cli/dsht.js +207 -19
  5. package/dist/cli/startup.d.ts +40 -0
  6. package/dist/cli/startup.js +295 -0
  7. package/dist/cli/trace-summary.d.ts +78 -0
  8. package/dist/cli/trace-summary.js +241 -0
  9. package/dist/cli/verifier.d.ts +60 -0
  10. package/dist/cli/verifier.js +242 -0
  11. package/dist/contracts.d.ts +344 -0
  12. package/dist/contracts.js +1 -0
  13. package/dist/controller/commands.d.ts +47 -0
  14. package/dist/controller/commands.js +322 -0
  15. package/dist/controller/connection.d.ts +11 -29
  16. package/dist/controller/connection.js +26 -60
  17. package/dist/controller/controller.d.ts +619 -164
  18. package/dist/controller/controller.js +1420 -141
  19. package/dist/controller/index.d.ts +8 -1
  20. package/dist/controller/index.js +5 -0
  21. package/dist/controller/loop-contract.d.ts +136 -0
  22. package/dist/controller/loop-contract.js +308 -0
  23. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  24. package/dist/controller/loop-prompts-schema.js +144 -0
  25. package/dist/controller/loop-prompts.d.ts +55 -0
  26. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  27. package/dist/controller/loop-prompts.generated.js +185 -0
  28. package/dist/controller/loop-prompts.js +104 -0
  29. package/dist/controller/loop-protocols.d.ts +39 -0
  30. package/dist/controller/loop-protocols.js +115 -0
  31. package/dist/controller/loop.d.ts +275 -0
  32. package/dist/controller/loop.js +378 -0
  33. package/dist/controller/prompts.d.ts +54 -0
  34. package/dist/controller/prompts.js +162 -0
  35. package/dist/controller/trace-log.d.ts +45 -0
  36. package/dist/controller/trace-log.js +144 -0
  37. package/dist/controller/verifier.d.ts +126 -0
  38. package/dist/controller/verifier.js +75 -0
  39. package/dist/cost/index.d.ts +1 -1
  40. package/dist/cost/index.js +1 -1
  41. package/dist/cost/ledger.d.ts +0 -1
  42. package/dist/cost/ledger.js +0 -1
  43. package/dist/json.d.ts +18 -0
  44. package/dist/json.js +19 -0
  45. package/dist/references.d.ts +25 -0
  46. package/dist/references.js +26 -0
  47. package/dist/session/connection-view.d.ts +2 -11
  48. package/dist/session/controller.d.ts +82 -72
  49. package/dist/session/controller.js +211 -209
  50. package/dist/session/history.d.ts +9 -1
  51. package/dist/session/history.js +1 -9
  52. package/dist/session/index.d.ts +9 -4
  53. package/dist/session/index.js +7 -3
  54. package/dist/session/info.d.ts +25 -52
  55. package/dist/session/info.js +39 -25
  56. package/dist/session/markdown.js +1 -1
  57. package/dist/session/math.js +1 -1
  58. package/dist/session/mutation-gate.d.ts +51 -0
  59. package/dist/session/mutation-gate.js +73 -0
  60. package/dist/session/navigation.d.ts +2 -89
  61. package/dist/session/navigation.js +2 -129
  62. package/dist/session/peek.d.ts +38 -0
  63. package/dist/session/peek.js +103 -0
  64. package/dist/session/references.d.ts +2 -20
  65. package/dist/session/references.js +1 -26
  66. package/dist/session/runtime.d.ts +26 -0
  67. package/dist/session/runtime.js +28 -0
  68. package/dist/session/telemetry.d.ts +12 -13
  69. package/dist/session/telemetry.js +27 -58
  70. package/dist/session/transcript.d.ts +0 -6
  71. package/dist/session/transcript.js +2 -15
  72. package/dist/session/types.d.ts +25 -0
  73. package/dist/session/types.js +0 -1
  74. package/dist/session-title.d.ts +9 -0
  75. package/dist/session-title.js +21 -0
  76. package/dist/shell/controller.d.ts +97 -0
  77. package/dist/shell/controller.js +158 -0
  78. package/dist/shell/index.d.ts +5 -0
  79. package/dist/shell/index.js +3 -0
  80. package/dist/shell/runner.d.ts +38 -0
  81. package/dist/shell/runner.js +147 -0
  82. package/dist/slash/index.d.ts +10 -0
  83. package/dist/slash/index.js +7 -0
  84. package/dist/slash/parse.d.ts +166 -0
  85. package/dist/slash/parse.js +259 -0
  86. package/dist/slash/pipeline.d.ts +140 -0
  87. package/dist/slash/pipeline.js +115 -0
  88. package/dist/slash/registry.d.ts +88 -0
  89. package/dist/slash/registry.js +177 -0
  90. package/dist/state.d.ts +14 -4
  91. package/dist/state.js +3 -2
  92. package/dist/text.d.ts +28 -0
  93. package/dist/text.js +55 -0
  94. package/dist/transport/events.d.ts +104 -0
  95. package/dist/transport/events.js +149 -0
  96. package/dist/transport/wire.d.ts +9 -17
  97. package/dist/transport/wire.js +2 -27
  98. package/dist/ui/app.js +865 -431
  99. package/dist/ui/chat/header.js +1 -1
  100. package/dist/ui/chat/history-view.d.ts +1 -1
  101. package/dist/ui/chat/history-view.js +1 -1
  102. package/dist/ui/chat/loop-status.d.ts +11 -0
  103. package/dist/ui/chat/loop-status.js +28 -0
  104. package/dist/ui/chat/navigation-model.d.ts +86 -0
  105. package/dist/ui/chat/navigation-model.js +107 -0
  106. package/dist/ui/chat/shell-view.d.ts +47 -0
  107. package/dist/ui/chat/shell-view.js +145 -0
  108. package/dist/ui/chat/status.d.ts +47 -3
  109. package/dist/ui/chat/status.js +65 -50
  110. package/dist/ui/chat/viewport.d.ts +1 -1
  111. package/dist/ui/dialogs/cost.d.ts +21 -4
  112. package/dist/ui/dialogs/cost.js +7 -12
  113. package/dist/ui/dialogs/index.d.ts +22 -5
  114. package/dist/ui/dialogs/index.js +19 -3
  115. package/dist/ui/dialogs/loop.d.ts +43 -0
  116. package/dist/ui/dialogs/loop.js +224 -0
  117. package/dist/ui/dialogs/peek.d.ts +25 -0
  118. package/dist/ui/dialogs/peek.js +35 -0
  119. package/dist/ui/dialogs/picker.d.ts +2 -0
  120. package/dist/ui/dialogs/picker.js +4 -2
  121. package/dist/ui/input/mouse.d.ts +12 -2
  122. package/dist/ui/input/mouse.js +20 -7
  123. package/dist/ui/input/references.d.ts +1 -1
  124. package/dist/ui/status/model.d.ts +7 -0
  125. package/dist/ui/status/model.js +5 -0
  126. package/dist/ui/theme/index.d.ts +6 -1
  127. package/dist/ui/theme/index.js +2 -1
  128. package/package.json +6 -4
  129. package/dist/ui/commands/parse.d.ts +0 -99
  130. package/dist/ui/commands/parse.js +0 -126
  131. package/dist/ui/commands/registry.d.ts +0 -33
  132. package/dist/ui/commands/registry.js +0 -73
@@ -1,30 +1,85 @@
1
1
  /** Application facade: composes the connection, session, catalog and cost domains. */
2
+ import { join } from 'node:path';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import { randomUUID } from 'node:crypto';
2
5
  import { Client } from "../transport/client.js";
3
- import { writeHeapSnapshot } from "../storage/index.js";
4
- import { errorText } from "../transport/wire.js";
6
+ import { listEntries, readText, removeFile, writeHeapSnapshot } from "../storage/index.js";
7
+ import { errorText, string } from "../transport/wire.js";
5
8
  import { DEFAULT_HISTORY_LIMITS } from "../session/memory.js";
6
9
  import { layoutStats } from "../session/history.js";
7
10
  import { markdownCacheStats } from "../session/markdown.js";
8
11
  import { SessionController } from "../session/controller.js";
9
12
  import { CatalogController } from "../catalog/controller.js";
10
13
  import { CostController } from "../cost/controller.js";
11
- import { costText } from "../cost/ledger.js";
12
14
  import { ConnectionController } from "./connection.js";
13
15
  import { MemoryLog } from "./memory-log.js";
16
+ import { TraceLog } from "./trace-log.js";
17
+ import { PromptStore } from "./prompts.js";
18
+ import { latestAssistantText, parseLoopResult, ScoredLoop } from "./loop.js";
19
+ import { findingsLines } from "./loop-contract.js";
20
+ import { loopRecords as listLoopRecords } from "./loop-protocols.js";
21
+ import { verificationId, verdictFile } from "./verifier.js";
22
+ import { SessionPeek } from "../session/peek.js";
23
+ import { costAddresses } from "../cost/scanner.js";
24
+ import { sessionLabel } from "../session-title.js";
14
25
  import { clearReactMeasures, measureCount } from "./perf-measures.js";
15
26
  import { initialState } from "../state.js";
27
+ import { ShellController, localSourceId } from "../shell/index.js";
28
+ /** Environment for a local `!` command: this client's variables without its credentials.
29
+ *
30
+ * `DSH_URL` is removed as well as the token, because the URL form the README documents can carry a
31
+ * token in its query string. Commands therefore run with the operator's environment, not this
32
+ * client's session.
33
+ * @returns A copy of the environment safe to hand to a child process.
34
+ */
35
+ function shellEnv() {
36
+ const env = { ...process.env };
37
+ delete env.DSH_TOKEN;
38
+ delete env.DSH_URL;
39
+ return env;
40
+ }
41
+ /** How many times a verifier outage is retried before the run reports verification unavailable. */
42
+ const VERIFIER_RETRIES = 2;
43
+ /** Sources this client registers before it forgets the oldest; a session list can still find them. */
44
+ const SOURCE_LIMIT = 50;
45
+ /** The tag every verifier session title starts with, so a reader can tell it apart in a list. */
46
+ const VERIFIER_TAG = '[dsht-verify] ';
47
+ /** What the read-only view calls a verifier's session: its title without the marker prefix. */
48
+ function verifierLabel(title) {
49
+ const stripped = title.startsWith(VERIFIER_TAG) ? title.slice(VERIFIER_TAG.length) : title;
50
+ return stripped.trim() === '' ? title : stripped.trim();
51
+ }
52
+ /** How long a finished turn may take to commit its final assistant message.
53
+ *
54
+ * The host reports the turn idle just before that message reaches the follow stream, so a single
55
+ * parse can miss the result block that decides the attempt. Waiting too long only delays a reply
56
+ * that never complies, while deciding too early spends an attempt the reviewer never got.
57
+ */
58
+ const LOOP_SETTLE_GRACE_MS = 4000;
59
+ /** File `/handoff` clears on this machine before it asks the agent to write a handoff. */
60
+ const HANDOFF_FILE = 'HANDOFF.md';
61
+ /** Instruction `/handoff` sends once this client's own copy of the file is gone.
62
+ *
63
+ * The sections are explicit because a handoff is read by whoever continues the work: why the
64
+ * session exists, the goal, and the state of every task, including the ones that cannot be done.
65
+ */
66
+ const HANDOFF_PROMPT = [
67
+ 'Write a session handoff to HANDOFF.md in the workspace root, overwriting whatever is there,',
68
+ 'in the language of this conversation. Cover, in this order:',
69
+ '(1) why this session exists and what triggered it;',
70
+ '(2) the goal it is working toward;',
71
+ '(3) every task and its state — completed, still open, or impossible, each with its reason;',
72
+ '(4) the decisions made and the files changed;',
73
+ '(5) how to verify the current state;',
74
+ '(6) the exact next steps for whoever continues this work.',
75
+ 'Base it only on this session; never invent work that did not happen.',
76
+ ].join(' ');
16
77
  /** Application facade over the domain controllers; the UI owns only this object.
17
78
  *
18
79
  * State lives here, connection generations live in `connection`, the selected session and its
19
80
  * history live in `session`, model metadata lives in `catalog`, and billing lives in `cost`.
20
81
  */
21
82
  export class Controller {
22
- base;
23
- initialSession;
24
- costs;
25
- historyLimits;
26
- memoryLogPath;
27
- localDirectory;
28
83
  state = initialState();
29
84
  /** Physical connection, retry loop and projection store. */
30
85
  connection;
@@ -36,20 +91,125 @@ export class Controller {
36
91
  cost;
37
92
  /** Bounded runtime memory samples; present only when a log path was supplied. */
38
93
  memoryLog;
94
+ /** Bounded connection, screen and selection trace; present only when a path was supplied. */
95
+ trace;
96
+ /** Shortcut prompts the operator saved; in memory for this run when no path was supplied. */
97
+ promptStore;
98
+ /** Running agent loop, if any; the loop lives here, not in the UI. */
99
+ loop;
100
+ /** Prompt the loop still has to send, when it could not be sent immediately. */
101
+ loopPrompt;
102
+ /** Finished turns of the selected session, so a waiter never has to sample a cached flag. */
103
+ completedTurns = 0;
104
+ /** Sessions the host reported busy, so a lone idle frame cannot claim a finished turn. */
105
+ busySessions = new Set();
106
+ /** When the in-flight attempt's turn ended, while its result block is still awaited. */
107
+ loopEndedAt;
108
+ /** Timer that settles an attempt whose reply never commits its result block. */
109
+ loopSettleTimer;
110
+ /** Whether an attempt is currently being judged by an independent verifier. */
111
+ loopVerifying = false;
112
+ /** A verdict is being consumed right now.
113
+ *
114
+ * Consuming one reads the artifact, so it is asynchronous; without this gate a replayed idle edge
115
+ * could start a second verification of an attempt whose verdict is already being applied.
116
+ */
117
+ loopSettling = false;
118
+ /** Cancels that verification when the loop is stopped or replaced. */
119
+ loopVerifyAbort;
120
+ /** Verdict of the previous attempt on the current step, for the retry and the next verifier. */
121
+ loopPrevious;
122
+ /** Identity of the run in flight, so its verdicts are isolated from every other run's. */
123
+ loopRunId;
124
+ /** Whether the run in flight already wrote its `loop end`; one end per begin (I9). */
125
+ loopEndTraced = false;
126
+ /** Sequence of the verification task in flight, so a retry never reuses the old task's identity. */
127
+ loopVerifySeq = 0;
128
+ /** Identity of the verification task whose result may still decide the attempt in flight. */
129
+ loopVerifyIdentity;
130
+ /** Timer that stops the whole run when its budget expires. */
131
+ loopDeadlineTimer;
132
+ /** Whole-run budget, when the operator set one. */
133
+ deadlineMs;
134
+ /** Consecutive verifier outages in this attempt, so a broken verifier is retried then reported. */
135
+ loopVerifierMisses = 0;
136
+ /** What the operator answered when a verifier abstained; consumed by the next judgment. */
137
+ loopAnswer;
138
+ /** Whether a reply block may stand in for a missing verdict; off unless asked for. */
139
+ allowSelfFallback;
140
+ /** Client-side directory verdict files are written under. */
141
+ verdictRoot;
142
+ /** Local `!` commands, run on this machine and shown inline in the transcript. */
143
+ shell;
144
+ /** Mutating surface the UI drives. */
145
+ actions;
146
+ /** Read-only surface the UI drives. */
147
+ queries;
148
+ /** Host base URL this client talks to. */
149
+ base;
150
+ /** Billing ledger supplied at construction, when any. */
151
+ costs;
152
+ /** History retention budgets in force. */
153
+ historyLimits;
154
+ /** Runtime memory log path, when one was configured. */
155
+ memoryLogPath;
156
+ /** Directory this client runs in. */
157
+ localDirectory;
158
+ /** Independent verifier for scored rounds, when one was supplied. */
159
+ verifier;
160
+ /** Session opened at startup, when one was named. */
161
+ initialSession;
39
162
  observers = new Set();
40
163
  selector = 0;
41
- constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS, memoryLogPath,
42
- /** Directory this client runs in, offered as a workspace when the host has not registered it. */
43
- localDirectory = process.cwd()) {
164
+ connectionSettled = false;
165
+ /** Counter behind `commandId`, so every executed line has one identifier in begin and end. */
166
+ commandSeq = 0;
167
+ /** The operation that owns the client right now, when one does; the single foreground slot. */
168
+ foreground;
169
+ /** Callers waiting for the slot, oldest first; woken one at a time so nobody barges in. */
170
+ foregroundWaiters = [];
171
+ /** True while a waiter has been promised the slot but has not taken it yet. */
172
+ foregroundGranted = false;
173
+ /** Set when the client stops, so a waiter never starts work on a closed connection. */
174
+ foregroundClosed = false;
175
+ foregroundSeq = 0;
176
+ /** Which operation the current async continuation belongs to, so a nested call never re-claims. */
177
+ foregroundOwner = new AsyncLocalStorage();
178
+ /** Read-only follower for the full-screen view; it borrows the connection and never selects. */
179
+ peek;
180
+ /** Sources this client created, keyed by session id; the host cannot record that lineage itself. */
181
+ createdSources = new Map();
182
+ /** The source the read-only view is showing, when one is open. */
183
+ peekId;
184
+ constructor(options) {
185
+ const { base, token, initialSession, costs } = options;
186
+ const makeClient = options.makeClient ?? (() => new Client(base));
187
+ const authenticate = options.authenticate ?? (client => client.authenticate(token ?? ''));
188
+ const historyLimits = options.historyLimits ?? DEFAULT_HISTORY_LIMITS;
189
+ const shellEnabled = options.shellEnabled ?? true;
44
190
  this.base = base;
45
- this.initialSession = initialSession;
46
191
  this.costs = costs;
47
192
  this.historyLimits = historyLimits;
48
- this.memoryLogPath = memoryLogPath;
49
- this.localDirectory = localDirectory;
50
- const options = { base, token, initialSession, makeClient, authenticate };
51
- this.connection = new ConnectionController(this, options, this);
52
- this.session = new SessionController(this, this.connection, this.connection, historyLimits);
193
+ this.memoryLogPath = options.memoryLogPath;
194
+ this.localDirectory = options.localDirectory ?? process.cwd();
195
+ this.initialSession = initialSession;
196
+ this.verifier = options.verifier;
197
+ this.allowSelfFallback = options.allowSelfFallback === true;
198
+ this.verdictRoot = options.verdictRoot ?? this.localDirectory;
199
+ this.deadlineMs = options.deadlineMs;
200
+ const connectionOptions = { base, token, initialSession, makeClient, authenticate };
201
+ this.connection = new ConnectionController(this, connectionOptions, this);
202
+ // The view follows other sessions over the same connection; it never becomes a second writer.
203
+ this.peek = new SessionPeek(this.connection);
204
+ // Every session write is admitted in order; the trace records that order, which is what answers
205
+ // "who dispatched first" when two mutations of one session compete.
206
+ this.session = new SessionController(this, this.connection, this.connection, historyLimits, admission => this.traceEvent('mutation', { session: admission.sessionId, lane: admission.lane, waited: admission.waited }));
207
+ this.shell = new ShellController({
208
+ publish: () => this.update({}),
209
+ cwd: () => this.localDirectory,
210
+ env: () => shellEnv(),
211
+ anchor: () => this.state.session.record.readThrough,
212
+ }, shellEnabled);
53
213
  this.catalog = new CatalogController(this, this.connection);
54
214
  if (costs)
55
215
  this.cost = new CostController(costs, {
@@ -62,8 +222,13 @@ export class Controller {
62
222
  scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
63
223
  scanDone: sessionId => this.session.rememberScanDone(sessionId),
64
224
  });
65
- if (memoryLogPath !== undefined)
66
- this.memoryLog = new MemoryLog(memoryLogPath, () => this.memorySample());
225
+ if (this.memoryLogPath !== undefined)
226
+ this.memoryLog = new MemoryLog(this.memoryLogPath, () => this.memorySample());
227
+ if (options.tracePath !== undefined)
228
+ this.trace = new TraceLog(options.tracePath);
229
+ this.promptStore = new PromptStore(options.promptsPath);
230
+ this.actions = this.buildActions();
231
+ this.queries = this.buildQueries();
67
232
  }
68
233
  /** React-compatible state subscription. */
69
234
  subscribe = (listener) => {
@@ -73,7 +238,7 @@ export class Controller {
73
238
  /** Snapshot identity changes only when the controller publishes. */
74
239
  snapshot = () => this.state;
75
240
  /** Current projection store; replaced at each connection generation. */
76
- get telemetry() { return this.connection.telemetry; }
241
+ get telemetry() { return this.session.telemetry; }
77
242
  /** Record of the selected session; the one strong owner lives in `State.session`. */
78
243
  get record() { return this.state.session.record; }
79
244
  /** @returns The current selector generation. */
@@ -83,23 +248,226 @@ export class Controller {
83
248
  /** Publish a state patch, re-deriving the visible pending interactions.
84
249
  * @param patch - Fields to replace on the current state.
85
250
  */
251
+ /** Whether an operation owns the client's foreground slot right now.
252
+ * @returns True while an operation is running.
253
+ */
254
+ busy() { return this.foreground !== undefined; }
86
255
  update(patch) {
256
+ const previous = this.state;
87
257
  const next = { ...this.state, ...patch, version: this.state.version + 1 };
88
258
  next.pending = next.online && next.screen === 'chat' && this.session
89
259
  ? this.session.pendingFor(next) : [];
260
+ // The shell service owns its blocks; state carries only the plain snapshot the UI renders.
261
+ if (this.shell)
262
+ next.shell = this.shell.snapshot();
263
+ // A loop belongs to one session: selecting a different session ends it, but a reconnect — which
264
+ // transiently clears the selection — must not, or a long review could never finish.
265
+ if (next.sessionId !== undefined && next.sessionId !== this.state.sessionId && this.loop?.sessionId !== next.sessionId) {
266
+ this.forgetLoop();
267
+ // The read-only view belongs to the conversation that opened it; leaving that conversation
268
+ // closes it, and the session switch that follows is never left rendering someone else's rows.
269
+ this.dropPeek();
270
+ }
90
271
  this.state = next;
272
+ this.traceTransition(previous, next);
91
273
  for (const observer of this.observers)
92
274
  observer();
275
+ // A prompt the loop could not send yet (offline, busy or answering) goes out as soon as it can.
276
+ if (this.loopPrompt !== undefined)
277
+ void this.flushLoop();
278
+ // A finished attempt whose reply is still committing gets another look on every publish.
279
+ if (this.loopEndedAt !== undefined)
280
+ this.trySettleLoop();
281
+ }
282
+ /** Record one diagnostic event; a no-op when no trace path was configured. */
283
+ traceEvent(event, detail = {}) {
284
+ this.trace?.record({ event, ...detail });
285
+ }
286
+ /** Record one diagnostic event from outside the controller, such as a UI-only decision.
287
+ *
288
+ * The composition root knows things this class cannot see — which record the operator highlighted,
289
+ * that a form was refused a name — and a start that never reaches `startLoop` is otherwise invisible
290
+ * in every log. Same no-op contract as the internal call.
291
+ * @param event - Event name, e.g. `loop-ui`.
292
+ * @param detail - Identifiers only; never prompt or session text.
293
+ */
294
+ traceNote(event, detail = {}) {
295
+ this.traceEvent(event, detail);
296
+ }
297
+ /** Mint the identifier one executed line carries through its `begin` and `end` events.
298
+ *
299
+ * Short and process-local on purpose: it exists to pair two lines of one file, not to identify a
300
+ * line across runs, so a monotonic counter beats a random id a reader would have to match by eye.
301
+ * @returns The next command id, such as `C17`.
302
+ */
303
+ nextCommandId() {
304
+ this.commandSeq += 1;
305
+ return `C${this.commandSeq}`;
306
+ }
307
+ /** Record the state fields that decide which screen the reader is looking at.
308
+ *
309
+ * Every non-user transition is written here as well as at its cause, so a screen that moved
310
+ * without an expected reason is still visible in the trace rather than silently skipped.
311
+ * @param previous - State before the patch.
312
+ * @param next - State after the patch.
313
+ */
314
+ traceTransition(previous, next) {
315
+ if (this.trace === undefined)
316
+ return;
317
+ const changed = {};
318
+ if (previous.screen !== next.screen)
319
+ changed.screen = `${previous.screen} -> ${next.screen}`;
320
+ if (previous.sessionId !== next.sessionId)
321
+ changed.session = `${previous.sessionId ?? 'none'} -> ${next.sessionId ?? 'none'}`;
322
+ if (previous.workspaceId !== next.workspaceId)
323
+ changed.workspace = `${previous.workspaceId ?? 'none'} -> ${next.workspaceId ?? 'none'}`;
324
+ if (previous.online !== next.online)
325
+ changed.online = next.online;
326
+ if (Object.keys(changed).length > 0)
327
+ this.traceEvent('state', changed);
328
+ }
329
+ /** Bind every mutating entry point to its private implementation.
330
+ *
331
+ * Each one names the kind and label of the operation it performs, because that is now the single
332
+ * answer to "what owns the client right now" (§6.2): the front end renders it and cancels it, and
333
+ * nothing else has to know which layer started the work.
334
+ */
335
+ buildActions() {
336
+ return {
337
+ foreground: (kind, label, work, wait) => this.claimForeground(kind, label, work, wait),
338
+ cancelForeground: () => this.cancelForeground(),
339
+ openPeek: id => this.openPeek(id),
340
+ closePeek: () => this.closePeek(),
341
+ switchWorkspace: id => this.runAction('navigation', 'Switching workspace…', () => this.switchWorkspace(id)),
342
+ switchSession: query => this.runAction('navigation', 'Switching session…', () => this.switchSession(query)),
343
+ selectSession: id => this.runAction('navigation', 'Loading session…', () => this.selectSession(id)),
344
+ createWorkspace: path => this.runAction('navigation', 'Registering workspace…', () => this.createWorkspace(path)),
345
+ createSession: () => this.runAction('navigation', 'Creating session…', () => this.createSession()),
346
+ createVerifierSession: title => this.runActionValue('verifier', 'Creating verifier session…', () => this.createVerifierSession(title)),
347
+ cancelVerifierSession: sessionId => this.runAction('verifier', 'Stopping verifier…', async () => {
348
+ await this.session.cancelNamedSession(sessionId);
349
+ // The run is over even though its transcript stays readable; the list should say so.
350
+ this.endSource(sessionId, Date.now());
351
+ }),
352
+ showPicker: screen => this.runAction('picker', 'Listing…', () => this.showPicker(screen)),
353
+ removeTarget: target => this.runAction('removal', 'Removing…', () => this.removeTarget(target)),
354
+ removalTarget: (kind, query) => this.runActionValue('removal', 'Reading target…', () => this.removalTarget(kind, query)),
355
+ waitForHistory: signal => this.runAction('history', 'Loading history…', () => this.waitForHistory(signal)),
356
+ searchSessions: (query, workspaceOnly, signal) => this.runActionValue('search', 'Searching sessions…', () => this.searchSessions(query, workspaceOnly, signal)),
357
+ searchHistory: (query, signal) => this.runActionValue('search', 'Searching history…', () => this.searchHistory(query, signal)),
358
+ prompt: text => this.runAction('prompt', 'Sending…', () => this.prompt(text)),
359
+ handoff: () => this.runAction('handoff', 'Requesting handoff…', () => this.handoff()),
360
+ startLoop: (protocol, limits) => this.runAction('loop', 'Starting loop…', () => this.startLoop(protocol, limits)),
361
+ stopLoop: () => this.stopLoop(),
362
+ clearLoopResult: () => this.clearLoopResult(),
363
+ answerLoop: text => this.runAction('loop', 'Answering the verifier…', () => this.answerLoop(text)),
364
+ cancelTurn: () => this.runAction('interaction', 'Cancelling…', () => this.cancelTurn()),
365
+ answer: value => this.runAction('interaction', 'Answering…', () => this.answer(value)),
366
+ answerQuestion: input => this.runAction('interaction', 'Answering…', async () => {
367
+ if (!await this.answerQuestion(input))
368
+ throw new Error('The answer could not be sent');
369
+ }),
370
+ approve: allowed => this.runAction('interaction', 'Answering…', () => this.approve(allowed)),
371
+ dismissQuestion: () => this.runAction('interaction', 'Dismissing…', () => this.dismissQuestion()),
372
+ interrupt: force => this.interrupt(force),
373
+ older: (signal, transcript) => this.runAction('history', 'Loading history…', () => this.older(signal, transcript)),
374
+ historyThrough: (target, signal) => this.runAction('history', 'Loading history…', () => this.historyThrough(target, signal)),
375
+ removeQueued: itemId => this.runAction('command', 'Removing queued input…', () => this.removeQueued(itemId)),
376
+ savePrompt: text => this.runAction('local', 'Saving prompt…', async () => {
377
+ await this.promptStore.save(text);
378
+ this.update({ lastFailure: '' });
379
+ }),
380
+ updatePrompt: (id, text) => this.runAction('local', 'Saving prompt…', async () => {
381
+ if (!await this.promptStore.update(id, text))
382
+ throw new Error('That saved prompt no longer exists');
383
+ this.update({ lastFailure: '' });
384
+ }),
385
+ deletePrompt: id => this.runAction('local', 'Deleting prompt…', async () => {
386
+ if (!await this.promptStore.remove(id))
387
+ throw new Error('That saved prompt no longer exists');
388
+ this.update({ lastFailure: '' });
389
+ }),
390
+ command: (line, signal) => this.runActionValue('command', 'Running command…', () => this.command(line, signal)),
391
+ exportLog: (path, signal) => this.runActionValue('export', 'Exporting session log…', () => this.exportLog(path, signal)),
392
+ exportHtml: (path, signal) => this.runActionValue('export', 'Exporting conversation…', () => this.exportHtml(path, signal)),
393
+ selectModel: (provider, model, effort) => this.runAction('model', 'Selecting model…', () => this.selectModel(provider, model, effort)),
394
+ modelCatalog: () => this.runActionValue('model', 'Loading models…', () => this.modelCatalog()),
395
+ refreshCosts: signal => this.runAction('cost', 'Refreshing costs…', () => this.refreshCosts(signal)),
396
+ loadPresetNames: () => this.loadPresetNames(),
397
+ clearFailure: () => this.clearFailure(),
398
+ enterPath: () => this.enterPath(),
399
+ pickWorkspace: id => this.pickWorkspace(id),
400
+ showChat: () => this.showChat(),
401
+ setViewWindow: window => this.setViewWindow(window),
402
+ pinHistory: pinned => this.pinHistory(pinned),
403
+ setAnswers: answers => this.setAnswers(answers),
404
+ setOption: option => this.setOption(option),
405
+ setApproval: approval => this.setApproval(approval),
406
+ recordRecall: value => this.recordRecall(value),
407
+ resetRecall: () => this.resetRecall(),
408
+ refillRecall: () => this.refillRecall(),
409
+ heapSnapshot: tag => this.heapSnapshot(tag),
410
+ };
411
+ }
412
+ /** Bind every read-only entry point; getters stay live because the values move between renders. */
413
+ buildQueries() {
414
+ const controller = this;
415
+ return {
416
+ get running() { return controller.running; },
417
+ get turnsCompleted() { return controller.completedTurns; },
418
+ get forkedVerification() { return controller.verifier !== undefined; },
419
+ get selfScoring() { return controller.verifier === undefined || controller.allowSelfFallback; },
420
+ get connectionSettled() { return controller.connectionSettled; },
421
+ get sessionName() { return controller.sessionName; },
422
+ get sessionMode() { return controller.sessionMode; },
423
+ get workingSince() { return controller.workingSince; },
424
+ get visibleSessions() { return controller.visibleSessions; },
425
+ get record() { return controller.record; },
426
+ get window() { return controller.window; },
427
+ get interaction() { return controller.interaction; },
428
+ get telemetry() { return controller.telemetry; },
429
+ get recallAtOldest() { return controller.recallAtOldest; },
430
+ get recallLength() { return controller.recallLength; },
431
+ get recallHasOlder() { return controller.recallHasOlder; },
432
+ get prompts() { return controller.promptStore.list; },
433
+ get promptsError() { return controller.promptStore.error; },
434
+ get loop() { return controller.loop?.progress; },
435
+ get foreground() { return controller.foreground; },
436
+ get sources() { return controller.outputSources(); },
437
+ get peek() { return controller.peekSnapshot(); },
438
+ get activity() { return controller.activity; },
439
+ get loopRecords() { return listLoopRecords(); },
440
+ pendingCounts: () => controller.pendingCounts(),
441
+ recall: (direction, current) => controller.recall(direction, current),
442
+ references: (query, signal) => controller.references(query, signal),
443
+ historyAt: (target, signal) => controller.historyAt(target, signal),
444
+ render: input => controller.render(input),
445
+ };
93
446
  }
94
447
  /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
95
- start() { this.connection.start(); this.memoryLog?.start(); }
448
+ start() {
449
+ this.connection.start();
450
+ this.memoryLog?.start();
451
+ // Reading the shortcuts file is local and may finish after the first paint. Nothing is
452
+ // republished for an empty list: the picker reads the live list when it opens, so a load that
453
+ // found nothing must not add a render to an unrelated interaction.
454
+ void this.promptStore.load().then(changed => { if (changed)
455
+ this.update({}); });
456
+ }
96
457
  /** Cancel retries and HTTP, close the socket, and release session and catalog work. */
97
458
  async stop() {
459
+ // Nobody waits forever for a slot that will never be handed out again.
460
+ this.foregroundClosed = true;
461
+ for (const wake of this.foregroundWaiters.splice(0))
462
+ wake();
463
+ this.dropPeek();
464
+ await this.shell.stop();
98
465
  await this.connection.stop();
99
466
  await this.session.settle();
100
467
  await this.catalog.settle();
101
468
  await this.cost?.stop();
102
469
  await this.memoryLog?.stop();
470
+ await this.trace?.settle();
103
471
  this.session.release();
104
472
  }
105
473
  /** Stop the selected turn and then close, so quitting does not leave host work running.
@@ -110,25 +478,133 @@ export class Controller {
110
478
  await this.session.interrupt(true);
111
479
  await this.stop();
112
480
  }
113
- /** Run a UI operation and expose errors without destroying the current input.
481
+ /** Run one mutating operation inside the client's single foreground slot.
482
+ *
483
+ * Private on purpose: the application owns the slot, so a caller never hands the controller a
484
+ * closure to orchestrate. Every entry of `actions` uses it, and `actions.foreground` is the one
485
+ * door left open for work the front end orchestrates itself.
486
+ * @param kind - What the operation is, for the label and the trace.
487
+ * @param label - Human label shown while it runs.
114
488
  * @param operation - Operation to run while the client is busy.
115
- * @returns Whether the operation completed.
489
+ * @returns Whether the operation ran to completion.
490
+ */
491
+ async runAction(kind, label, operation) {
492
+ if (!this.state.online)
493
+ return false;
494
+ if (!this.ownsForeground() && this.foreground !== undefined)
495
+ return false;
496
+ return await this.claimForeground(kind, label, async () => {
497
+ try {
498
+ await operation();
499
+ return true;
500
+ }
501
+ catch (error) {
502
+ this.update({ lastFailure: errorText(error) });
503
+ return false;
504
+ }
505
+ }) === true;
506
+ }
507
+ /** Same slot, for an operation that produces a value the caller needs. */
508
+ async runActionValue(kind, label, operation) {
509
+ if (!this.state.online)
510
+ return undefined;
511
+ if (!this.ownsForeground() && this.foreground !== undefined)
512
+ return undefined;
513
+ return await this.claimForeground(kind, label, async () => {
514
+ try {
515
+ return await operation();
516
+ }
517
+ catch (error) {
518
+ this.update({ lastFailure: errorText(error) });
519
+ return undefined;
520
+ }
521
+ });
522
+ }
523
+ /** Whether the current async continuation is already inside the foreground operation.
524
+ *
525
+ * An action invoked *by* a running operation (a paging loop calling `older`, a command's policy
526
+ * calling an action) belongs to that operation: refusing it for being busy would deadlock the very
527
+ * work that owns the slot. An async-local owner answers that exactly, where a plain flag could not
528
+ * tell a nested call from a second, unrelated one.
529
+ * @returns True when this call runs inside the operation that owns the slot.
530
+ */
531
+ ownsForeground() { return this.foregroundOwner.getStore() !== undefined; }
532
+ /** Run one operation in the foreground slot, claiming it when the caller does not already own it.
533
+ *
534
+ * The slot serializes what the operator is doing — one thing at a time — which is a different
535
+ * question from the session write order (§6.3): a read takes this slot too. The controller owns the
536
+ * slot, the abort controller and the identity, so the front end only renders `queries.foreground`
537
+ * and cancels it in one call.
538
+ * @param kind - What the operation is.
539
+ * @param label - Human label shown while it runs.
540
+ * @param work - The work, handed the signal that `cancelForeground` aborts.
541
+ * @returns What the work returned, or undefined when the slot was taken or the work was cancelled.
542
+ */
543
+ async claimForeground(kind, label, work, wait = false) {
544
+ const owner = this.foreground;
545
+ // Nested: the operation that owns the slot supplies the signal and keeps the identity.
546
+ if (owner !== undefined && this.ownsForeground())
547
+ return await work(owner.abort.signal);
548
+ if (owner !== undefined || this.foregroundGranted) {
549
+ // A caller that does not want to wait is told no; one that does waits its turn in arrival order.
550
+ if (!wait)
551
+ return undefined;
552
+ this.traceEvent('foreground', { phase: 'queued', kind, label });
553
+ await new Promise(resolve => this.foregroundWaiters.push(resolve));
554
+ // The release handed this caller the slot; clearing the flag and taking it is one synchronous
555
+ // step, so a later arrival cannot slip in between.
556
+ this.foregroundGranted = false;
557
+ if (this.foregroundClosed)
558
+ return undefined;
559
+ }
560
+ const operation = { id: ++this.foregroundSeq, kind, label, startedAt: Date.now(), abort: new AbortController() };
561
+ this.foreground = operation;
562
+ this.update({ lastFailure: '' });
563
+ this.traceEvent('foreground', { phase: 'begin', id: operation.id, kind, label });
564
+ try {
565
+ return await this.foregroundOwner.run(operation.id, () => work(operation.abort.signal));
566
+ }
567
+ finally {
568
+ // Only the owner releases the slot; a nested claim never reaches this branch.
569
+ if (this.foreground === operation) {
570
+ this.foreground = undefined;
571
+ this.update({});
572
+ this.traceEvent('foreground', { phase: 'end', id: operation.id, kind, cancelled: operation.abort.signal.aborted });
573
+ // Hand the slot to the oldest waiter, if any: the occupant is gone before the next one takes
574
+ // it, and `foregroundGranted` keeps a fresh arrival from overtaking the promise already made.
575
+ const next = this.foregroundWaiters.shift();
576
+ if (next !== undefined) {
577
+ this.foregroundGranted = true;
578
+ next();
579
+ }
580
+ }
581
+ }
582
+ }
583
+ /** Cancel the operation that owns the foreground slot; false when none is running.
584
+ * @returns Whether an operation was there to cancel.
116
585
  */
117
- async perform(operation) {
118
- if (this.state.busy || !this.state.online)
586
+ cancelForeground() {
587
+ if (this.foreground === undefined)
119
588
  return false;
120
- this.update({ busy: true, error: '' });
589
+ this.foreground.abort.abort();
590
+ return true;
591
+ }
592
+ /** Run one local operation that needs no connection, reporting failure the way `runAction` does.
593
+ *
594
+ * Shortcut prompts are the operator's own file, so they must keep working while the host is
595
+ * disconnected; only the failure line is shared with the remote operations.
596
+ * @param operation - Operation to run against local storage.
597
+ * @returns Whether it ran to completion.
598
+ */
599
+ async runLocalAction(operation) {
121
600
  try {
122
601
  await operation();
123
602
  return true;
124
603
  }
125
604
  catch (error) {
126
- this.update({ error: errorText(error) });
605
+ this.update({ lastFailure: errorText(error) });
127
606
  return false;
128
607
  }
129
- finally {
130
- this.update({ busy: false });
131
- }
132
608
  }
133
609
  /** Read the counters one memory sample records; content never leaves as text.
134
610
  *
@@ -186,49 +662,102 @@ export class Controller {
186
662
  }
187
663
  /** A new generation starts; drop generation-scoped domain state. */
188
664
  begin() {
665
+ this.traceEvent('generation', { phase: 'begin', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
189
666
  this.session.beginGeneration();
190
667
  this.catalog.reset();
668
+ this.connectionSettled = false;
669
+ // A busy state does not survive the connection it was observed on.
670
+ this.busySessions.clear();
191
671
  this.update({ controlError: undefined });
192
672
  }
193
673
  /** The event stream is ready and the control baseline is applied. */
194
674
  async ready() {
195
675
  this.catalog.refresh();
196
- this.update({ online: true, status: 'Connected', error: '', pending: [] });
676
+ this.update({ online: true, status: 'Connected', pending: [], lastFailure: '' });
197
677
  const screen = this.state.screen;
198
- await this.session.showPicker(screen === 'sessions' ? 'sessions' : 'workspaces');
199
- const sessionId = this.state.sessionId ?? this.initialSession;
200
- if (sessionId && (screen === 'chat' || this.initialSession && !this.state.sessionId))
678
+ this.traceEvent('generation', { phase: 'ready', screen });
679
+ const picker = screen === 'sessions' ? 'sessions' : 'workspaces';
680
+ this.traceEvent('picker', { requested: picker });
681
+ await this.session.showPicker(picker);
682
+ // Starting inside a registered workspace's directory already answers the first question, so the
683
+ // reader lands on that workspace's sessions instead of a list they would pick from by hand.
684
+ // The guard only excludes the `sessions` screen, so it also runs when the captured screen is
685
+ // `chat`: a reconnect mid-conversation then adopts the local workspace, `pickWorkspace` clears
686
+ // the selection, and the reader is returned to `/resume`. The trace records the `adopt` event
687
+ // and the `state` transition that follow, which is how that jump is told from a user action.
688
+ const mayAdopt = screen !== 'sessions' && !this.initialSession;
689
+ if (mayAdopt) {
690
+ const adopted = this.session.adoptLocalWorkspace(this.localDirectory);
691
+ this.traceEvent('adopt', { directory: this.localDirectory, workspace: adopted ?? 'none' });
692
+ if (adopted !== undefined)
693
+ this.update({ status: 'Workspace from this directory · ← to switch' });
694
+ }
695
+ // A loop outlives a reconnect, so it re-attaches to its own session even when the picker
696
+ // replaced the selection; without that the loop would keep settling against an empty transcript.
697
+ const looping = this.loop?.sessionId;
698
+ const sessionId = this.state.sessionId ?? looping ?? this.initialSession;
699
+ const reselect = Boolean(sessionId && (screen === 'chat' || looping !== undefined || this.initialSession && !this.state.sessionId));
700
+ this.traceEvent('resolve', { session: sessionId ?? 'none', looping: looping ?? 'none', reselect });
701
+ if (sessionId && reselect) {
201
702
  await this.session.selectSession(sessionId);
703
+ }
704
+ // `online` means the socket works; `connectionSettled` means the picker and the selection are
705
+ // done, which is what an automation caller must wait for or it races `showPicker`.
706
+ this.connectionSettled = true;
707
+ this.update({});
708
+ this.traceEvent('generation', { phase: 'settled', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
202
709
  this.cost?.start();
203
710
  }
204
- /** The generation ended; invalidate session work and stop the scan. */
711
+ /** The generation ended; invalidate session work and stop the scan.
712
+ *
713
+ * A running loop is deliberately left alone: the host keeps running the review, and the client
714
+ * re-selects the same session after reconnecting, so only a switch to another session ends it.
715
+ */
205
716
  async ended() {
717
+ this.traceEvent('generation', { phase: 'ended', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
206
718
  this.session.endGeneration();
207
719
  await this.cost?.stop();
208
720
  }
209
- /** Deliver a host waterfall to the session domain.
210
- * @param frame - One decoded waterfall frame.
211
- * @returns Whether the session domain retained it.
212
- */
213
- waterfall(frame) { return this.session.waterfall(frame); }
214
- /** Drop a waterfall the host cancelled.
215
- * @param eventId - Correlation id previously retained.
216
- */
217
- cancelled(eventId) { this.session.cancelled(eventId); }
218
- /** Apply one host running-state notification.
219
- * @param sessionId - Session whose state changed.
220
- * @param running - Whether the host still runs that session.
221
- */
222
- status(sessionId, running) { this.session.status(sessionId, running); }
223
- /** Surface a host-reported session error.
224
- * @param sessionId - Session the host reported on.
225
- * @param error - Error payload as delivered by the host.
226
- */
227
- error(sessionId, error) { this.session.reportError(sessionId, error); }
228
- /** Reload the model catalog after a host settings, credential or adapter change. */
229
- invalidated() { this.catalog.refresh(); }
230
- /** Refresh billing after a turn finished. */
231
- idle() { this.cost?.onTurnIdle(); }
721
+ /** Route one normalized host event to the domain that owns it.
722
+ *
723
+ * This is the composition point: the connection knows only that an event arrived, so a feature
724
+ * never has to hold another feature. `false` means the host's waterfall is still unsettled.
725
+ * @param event - Normalized host event.
726
+ * @returns Whether a retained waterfall was consumed.
727
+ */
728
+ event(event) {
729
+ switch (event.kind) {
730
+ case 'approval-request':
731
+ case 'question-request': return this.session.accept(event);
732
+ case 'waterfall-delegate': return false;
733
+ case 'cancel':
734
+ this.session.cancelled(event.eventId);
735
+ return true;
736
+ case 'agent-status':
737
+ this.session.status(event.sessionId, event.running);
738
+ if (event.running)
739
+ this.busySessions.add(event.sessionId);
740
+ else {
741
+ // Only a turn this client watched start counts as finished. A replayed or duplicated idle
742
+ // frame would otherwise look like a prompt completing, which `--wait` would trust.
743
+ const started = this.busySessions.delete(event.sessionId);
744
+ if (started && event.sessionId === this.state.sessionId)
745
+ this.completedTurns += 1;
746
+ this.cost?.onTurnIdle();
747
+ this.settleLoop(event.sessionId);
748
+ }
749
+ return true;
750
+ case 'catalog-invalidated':
751
+ this.catalog.refresh();
752
+ return true;
753
+ case 'session-error':
754
+ this.session.reportError(event.sessionId, event.error);
755
+ return true;
756
+ case 'control':
757
+ this.session.acceptControl(event.frame);
758
+ return true;
759
+ }
760
+ }
232
761
  /** @returns Host running state of the selected session. */
233
762
  get running() { return this.session.running; }
234
763
  /** @returns Current session title, falling back to the list title and then the ID. */
@@ -237,8 +766,159 @@ export class Controller {
237
766
  get sessionMode() { return this.session.sessionMode; }
238
767
  /** @returns Epoch start of the active turn, when known. */
239
768
  get workingSince() { return this.session.workingSince; }
769
+ /** What the client is doing right now, merged across the host turn and any running loop.
770
+ *
771
+ * This is the controller's answer, not a view's guess: a turn speaks for the session while it runs,
772
+ * otherwise a live loop speaks for itself with its own sub-state and clock, and neither means idle.
773
+ * @returns The activity, or undefined when nothing is in flight.
774
+ */
775
+ get activity() {
776
+ if (this.running) {
777
+ return { kind: 'turn', ...(this.workingSince === undefined ? {} : { since: this.workingSince }) };
778
+ }
779
+ const progress = this.loop?.progress;
780
+ if (progress === undefined || !progress.active)
781
+ return undefined;
782
+ // A paused run is still the client's work, but nothing is running: the bar must ask for the reader
783
+ // rather than claim a clock.
784
+ if (progress.phase === 'needs-human') {
785
+ return { kind: 'paused', title: progress.title, step: progress.step, total: progress.total };
786
+ }
787
+ if (progress.activity === undefined)
788
+ return undefined;
789
+ return { kind: 'loop', activity: progress.activity, title: progress.title,
790
+ step: progress.step, total: progress.total, startedAt: progress.startedAt };
791
+ }
240
792
  /** @returns Sessions accounted to the selected workspace, minus archived identities. */
241
793
  get visibleSessions() { return this.session.visibleSessions; }
794
+ /** Every readable output source, newest activity first.
795
+ *
796
+ * Three origins feed one list, because a reader asking "what is that session" does not care which
797
+ * layer knows about it: sessions this client created (the host cannot record that link), local `!`
798
+ * runs it already holds, and host-created subagent children, whose only lineage is their list row.
799
+ * A client-created source wins over the list row for the same session, since it says more.
800
+ * @returns Sources to offer, running ones first.
801
+ */
802
+ outputSources() {
803
+ const sources = [];
804
+ const seen = new Set();
805
+ for (const [id, source] of this.createdSources) {
806
+ sources.push(source);
807
+ seen.add(id);
808
+ }
809
+ for (const block of this.state.shell.blocks) {
810
+ // A note runs nothing: it is a bar pointing at another source, which is listed on its own.
811
+ if (block.kind !== 'shell')
812
+ continue;
813
+ const id = localSourceId(block.id);
814
+ if (seen.has(id))
815
+ continue;
816
+ seen.add(id);
817
+ sources.push({
818
+ id, kind: 'local', label: `! ${block.command}`,
819
+ state: block.status === 'running' ? 'running' : 'ended',
820
+ startedAt: block.startedAt,
821
+ ...(block.endedAt === undefined ? {} : { endedAt: block.endedAt }),
822
+ createdBy: 'shell',
823
+ ...(this.state.sessionId === undefined ? {} : { parentSessionId: this.state.sessionId }),
824
+ });
825
+ }
826
+ for (const row of this.visibleSessions) {
827
+ const sessionId = string(row.sessionId);
828
+ const parent = typeof row.parentSessionId === 'string' ? row.parentSessionId : '';
829
+ if (sessionId === '' || parent === '' || row.origin !== 'subagent' || seen.has(sessionId))
830
+ continue;
831
+ seen.add(sessionId);
832
+ sources.push({
833
+ id: sessionId, kind: 'session', label: sessionLabel(row), state: 'ended',
834
+ ...(typeof row.updatedAt === 'number' ? { startedAt: row.updatedAt } : {}),
835
+ createdBy: 'agent', parentSessionId: parent,
836
+ });
837
+ }
838
+ return sources.sort((a, b) => a.state === b.state
839
+ ? (b.startedAt ?? 0) - (a.startedAt ?? 0)
840
+ : a.state === 'running' ? -1 : 1);
841
+ }
842
+ /** Register a session this client created for a verifier, so the list can explain it. */
843
+ async createVerifierSession(title) {
844
+ const sessionId = await this.session.createNamedSession(title);
845
+ if (sessionId === undefined)
846
+ return undefined;
847
+ const parent = this.state.sessionId;
848
+ this.createdSources.set(sessionId, {
849
+ id: sessionId, kind: 'session', label: verifierLabel(title), state: 'running', startedAt: Date.now(),
850
+ createdBy: 'verifier',
851
+ ...(parent === undefined ? {} : { parentSessionId: parent }),
852
+ ...(this.verifier === undefined ? {} : { detail: `verifier ${this.verifier.name}` }),
853
+ });
854
+ // The bar the run echoed into the transcript now opens this session, which is the newest check.
855
+ this.shell.link(sessionId);
856
+ // A long-lived client runs many reviews; the registry is a convenience list, not a ledger, and a
857
+ // dropped session is still reachable by selecting it. Insertion order is the age order.
858
+ while (this.createdSources.size > SOURCE_LIMIT) {
859
+ const oldest = this.createdSources.keys().next().value;
860
+ if (oldest === undefined)
861
+ break;
862
+ this.createdSources.delete(oldest);
863
+ }
864
+ this.update({});
865
+ return sessionId;
866
+ }
867
+ /** Mark a source this client created as finished, keeping it readable. */
868
+ endSource(id, at) {
869
+ const source = this.createdSources.get(id);
870
+ if (source === undefined || source.state === 'ended')
871
+ return;
872
+ this.createdSources.set(id, { ...source, state: 'ended', endedAt: at });
873
+ this.update({});
874
+ }
875
+ /** Open one source read-only at full screen; a session source starts following it. */
876
+ openPeek(id) {
877
+ const source = this.outputSources().find(candidate => candidate.id === id);
878
+ if (source === undefined || this.peekId === id)
879
+ return;
880
+ this.peekId = id;
881
+ this.traceEvent('peek begin', { source: id, kind: source.kind });
882
+ if (source.kind === 'session') {
883
+ const row = this.visibleSessions.find(candidate => string(candidate.sessionId) === id);
884
+ // The list row is the only place that knows a child needs its parent in the address.
885
+ this.peek.open(row === undefined ? [{ kind: 'session', sessionId: id }] : costAddresses(row), () => this.update({}));
886
+ }
887
+ this.update({});
888
+ }
889
+ /** Close the read-only view and release whatever it followed. */
890
+ closePeek() {
891
+ if (this.peekId === undefined)
892
+ return;
893
+ this.traceEvent('peek end', { source: this.peekId });
894
+ this.peek.close();
895
+ this.peekId = undefined;
896
+ this.update({});
897
+ }
898
+ /** Release the view without publishing; the caller is already inside an update. */
899
+ dropPeek() {
900
+ if (this.peekId === undefined)
901
+ return;
902
+ this.traceEvent('peek end', { source: this.peekId });
903
+ this.peek.close();
904
+ this.peekId = undefined;
905
+ }
906
+ /** What the read-only view renders, or undefined while it is closed. */
907
+ peekSnapshot() {
908
+ if (this.peekId === undefined)
909
+ return undefined;
910
+ const source = this.outputSources().find(candidate => candidate.id === this.peekId);
911
+ if (source === undefined)
912
+ return undefined;
913
+ if (source.kind === 'local') {
914
+ const block = this.state.shell.blocks.find(candidate => `shell:${candidate.id}` === source.id);
915
+ return { source, lines: block?.lines ?? [] };
916
+ }
917
+ const followed = this.peek.snapshot;
918
+ if (followed === undefined)
919
+ return { source };
920
+ return { source, transcript: followed.transcript, ...(followed.error === undefined ? {} : { error: followed.error }) };
921
+ }
242
922
  /** @returns Unanswered interactions by session, for the state each list row reports. */
243
923
  pendingCounts() { return this.session.pendingCounts(); }
244
924
  /** Load the optional preset roster once per connection. */
@@ -257,16 +937,22 @@ export class Controller {
257
937
  * @param force - Send an explicit cancellation even when the cached running flag is idle.
258
938
  * @returns True when the caller may exit.
259
939
  */
260
- interrupt(force = false) { return this.session.interrupt(force); }
261
- /** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
262
- get sessionCostText() {
263
- const sessionId = this.state.sessionId;
264
- return this.costs?.hasSession(sessionId) ? costText(this.costs.total(sessionId)) : '?';
940
+ interrupt(force = false) {
941
+ // Esc and Ctrl+C stop the automated loop as well as the turn; otherwise it would keep sending.
942
+ this.stopLoop();
943
+ return this.session.interrupt(force);
265
944
  }
945
+ /** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
266
946
  /** Keep history stable while the user reads, searches, or expands it.
267
947
  * @param pinned - Whether the main transcript is being read away from its tail.
268
948
  */
269
949
  pinHistory(pinned) { this.session.pinHistory(pinned); }
950
+ /** @returns The detached history window the reader opened, if any. */
951
+ get window() { return this.session.window; }
952
+ /** Show a detached history window, releasing the one it replaces.
953
+ * @param window - Record to display, or undefined to return to the live transcript.
954
+ */
955
+ setViewWindow(window) { this.session.setViewWindow(window); }
270
956
  /** Recall one step through the selected session's prompt index; never touches the network.
271
957
  * @param direction - Negative for older input, positive for newer input.
272
958
  * @param current - Composer content before recall began, restored at the newest position.
@@ -289,45 +975,17 @@ export class Controller {
289
975
  * @returns Whether any older prompt was recovered.
290
976
  */
291
977
  refillRecall() { return this.session.refillRecall(); }
292
- /** Composer draft, caret and parked draft of the selected session. */
293
- get composer() { return this.session.composer; }
294
- /** Replace the composer text and caret.
295
- * @param draft - New text.
296
- * @param cursor - Caret column; defaults to the end of the text.
297
- */
298
- setComposer(draft, cursor) { this.session.setComposer(draft, cursor); }
299
- /** Move the caret without changing the text.
300
- * @param cursor - Caret column.
301
- */
302
- setComposerCursor(cursor) { this.session.setComposerCursor(cursor); }
303
- /** Move a non-empty draft aside while a dialog owns the keyboard. */
304
- parkComposer() { this.session.parkComposer(); }
305
- /** Give a parked draft back once no dialog needs the keyboard. */
306
- restoreComposer() { this.session.restoreComposer(); }
307
- /** How the selected session's record is being read right now. */
308
- get view() { return this.session.view; }
309
- /** Show a detached history window, releasing the one it replaces.
310
- * @param window - Record to display, or undefined to return to the live transcript.
311
- */
312
- setViewWindow(window) { this.session.setViewWindow(window); }
313
- /** Move the reader's position inside the displayed record.
314
- * @param scroll - Rows scrolled back from the live end.
315
- */
316
- setScroll(scroll) { this.session.setScroll(scroll); }
317
- /** Replace the set of expanded reasoning blocks.
318
- * @param folds - Sequences to expand beyond the default fold.
319
- */
320
- setFolds(folds) { this.session.setFolds(folds); }
321
- /** Set the fold mode of the live attempt's completed reasoning.
322
- * @param reasoning - `row` to fold, `full` to keep the streamed text.
323
- */
324
- setLiveReasoning(reasoning) { this.session.setLiveReasoning(reasoning); }
325
978
  /** Local answer state for the selected session's pending waterfalls. */
326
979
  get interaction() { return this.session.interaction; }
327
980
  /** Replace the partly collected answers, keyed by waterfall event id.
328
981
  * @param answers - Answers collected so far, by event id.
329
982
  */
330
983
  setAnswers(answers) { this.session.setAnswers(answers); }
984
+ /** Clear the internal last failure; a no-op when there is none. */
985
+ clearFailure() {
986
+ if (this.state.lastFailure !== '')
987
+ this.update({ lastFailure: '' });
988
+ }
331
989
  /** Replace the pending question's option keyboard state.
332
990
  * @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
333
991
  */
@@ -336,38 +994,6 @@ export class Controller {
336
994
  * @param approval - Selected approval row; undefined clears the highlight.
337
995
  */
338
996
  setApproval(approval) { this.session.setApproval(approval); }
339
- /** Composer-adjacent `@` reference menu state. */
340
- get reference() { return this.session.reference; }
341
- /** Highlight one row of the open reference menu.
342
- * @param index - Row index into the current matches.
343
- */
344
- setReferenceIndex(index) { this.session.setReferenceIndex(index); }
345
- /** Remember the draft that dismissed the reference menu.
346
- * @param draft - Composer text at dismissal, or undefined to allow the menu again.
347
- */
348
- setReferenceDismissed(draft) { this.session.setReferenceDismissed(draft); }
349
- /** Panels the selected session has open. */
350
- get panels() { return this.session.panels; }
351
- /** Show or hide the reasoning panel.
352
- * @param open - Whether `/think` is open.
353
- */
354
- openThoughts(open) { this.session.openThoughts(open); }
355
- /** Show or hide the pending-input panel.
356
- * @param open - Whether `/queue` is open.
357
- */
358
- openQueue(open) { this.session.openQueue(open); }
359
- /** Show the model dialog at one step, or close it.
360
- * @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
361
- */
362
- setModelPanel(model) { this.session.setModelPanel(model); }
363
- /** Show the history or content-search dialog, or close it.
364
- * @param history - Query, content-search mode and matches; undefined closes the dialog.
365
- */
366
- setHistoryPanel(history) { this.session.setHistoryPanel(history); }
367
- /** Show the host session-search results, or close them.
368
- * @param search - Query, results and truncation flag; undefined closes the dialog.
369
- */
370
- setSearchPanel(search) { this.session.setSearchPanel(search); }
371
997
  /** Refresh all HTTP-visible sessions without changing the selected conversation.
372
998
  * @param signal - Optional cancellation for an explicit /cost refresh.
373
999
  */
@@ -375,7 +1001,10 @@ export class Controller {
375
1001
  /** Refresh both lists from the host, then show the requested picker.
376
1002
  * @param screen - Picker to display after the refresh.
377
1003
  */
378
- async showPicker(screen) { await this.session.showPicker(screen); }
1004
+ async showPicker(screen) {
1005
+ this.traceEvent('action', { action: 'showPicker', screen });
1006
+ await this.session.showPicker(screen);
1007
+ }
379
1008
  /** Resolve a removal command to one reviewable object.
380
1009
  * @param kind - Workspace registration removal or session archival.
381
1010
  * @param query - Exact name, ID, or unambiguous ID prefix.
@@ -385,31 +1014,63 @@ export class Controller {
385
1014
  /** Apply a confirmed removal or verified empty-session archival.
386
1015
  * @param target - Exact workspace or session identity reviewed by the user.
387
1016
  */
388
- async removeTarget(target) { await this.session.removeTarget(target); }
1017
+ async removeTarget(target) {
1018
+ this.traceEvent('action', { action: 'removeTarget', kind: target.kind, id: target.id });
1019
+ await this.session.removeTarget(target);
1020
+ }
389
1021
  /** Pick a workspace, or use all sessions when the identity is omitted.
390
1022
  * @param workspaceId - Workspace to select, if any.
391
1023
  */
392
- pickWorkspace(workspaceId) { this.session.pickWorkspace(workspaceId); }
1024
+ pickWorkspace(workspaceId) {
1025
+ this.traceEvent('action', { action: 'pickWorkspace', workspace: workspaceId ?? 'none' });
1026
+ this.session.pickWorkspace(workspaceId);
1027
+ }
1028
+ /** Leave a picker and return to the selected conversation, without reloading it.
1029
+ * @returns Whether there was a selected conversation to return to.
1030
+ */
1031
+ showChat() {
1032
+ const shown = this.session.showChat();
1033
+ this.traceEvent('action', { action: 'showChat', shown });
1034
+ return shown;
1035
+ }
393
1036
  /** Open a workspace picker, or resolve a workspace target.
394
1037
  * @param query - Workspace target, if any.
395
1038
  */
396
- async switchWorkspace(query) { await this.session.switchWorkspace(query); }
1039
+ async switchWorkspace(query) {
1040
+ this.traceEvent('action', { action: 'switchWorkspace', query: query ?? 'picker' });
1041
+ await this.session.switchWorkspace(query);
1042
+ }
397
1043
  /** Guide session selection, list all sessions with `all`, or resolve a target.
398
1044
  * @param query - Session target, `all`, or nothing for the guided picker.
399
1045
  */
400
- async switchSession(query) { await this.session.switchSession(query); }
1046
+ async switchSession(query) {
1047
+ this.traceEvent('action', { action: 'switchSession', query: query ?? 'picker' });
1048
+ await this.session.switchSession(query);
1049
+ }
401
1050
  /** Prompt for a host path without starting a local agent. */
402
- enterPath() { this.session.enterPath(); }
1051
+ enterPath() {
1052
+ this.traceEvent('action', { action: 'enterPath' });
1053
+ this.session.enterPath();
1054
+ }
403
1055
  /** Register a host directory and move to its session picker.
404
1056
  * @param path - Absolute directory path on the host.
405
1057
  */
406
- async createWorkspace(path) { await this.session.createWorkspace(path); }
1058
+ async createWorkspace(path) {
1059
+ this.traceEvent('action', { action: 'createWorkspace', path });
1060
+ await this.session.createWorkspace(path);
1061
+ }
407
1062
  /** Create a session in the selected workspace. */
408
- async createSession() { await this.session.createSession(); }
1063
+ async createSession() {
1064
+ this.traceEvent('action', { action: 'createSession', workspace: this.state.workspaceId ?? 'none' });
1065
+ await this.session.createSession();
1066
+ }
409
1067
  /** Replace the selected transcript and follow the session.
410
1068
  * @param sessionId - Session to follow.
411
1069
  */
412
- async selectSession(sessionId) { await this.session.selectSession(sessionId); }
1070
+ async selectSession(sessionId) {
1071
+ this.traceEvent('action', { action: 'selectSession', session: sessionId });
1072
+ await this.session.selectSession(sessionId);
1073
+ }
413
1074
  /** Wait for the selected follow snapshot.
414
1075
  * @param signal - Cancels waiting without closing the session.
415
1076
  */
@@ -457,11 +1118,587 @@ export class Controller {
457
1118
  */
458
1119
  heapSnapshot(tag) { return writeHeapSnapshot(process.cwd(), tag); }
459
1120
  /** Admit text once as steering while running, or a new turn while idle.
1121
+ *
1122
+ * Text the operator typed ends an automated review: the loop must not race a human for the turn,
1123
+ * and the reply it would parse is no longer the reply to its own prompt.
460
1124
  * @param text - Composed prompt text.
461
1125
  */
462
- async prompt(text) { await this.session.prompt(text); }
1126
+ async prompt(text) {
1127
+ this.stopLoop();
1128
+ await this.session.prompt(text);
1129
+ }
1130
+ /** Clear this client's stale handoff file, then ask the agent to write a new one.
1131
+ *
1132
+ * The deletion happens first and on this machine, so a handoff that never gets written cannot be
1133
+ * mistaken for the previous one. The request itself is an ordinary turn: it steers a running
1134
+ * agent and starts an idle one, exactly like submitted text.
1135
+ */
1136
+ async handoff() {
1137
+ await removeFile(join(this.localDirectory, HANDOFF_FILE));
1138
+ await this.session.promptInternal(HANDOFF_PROMPT);
1139
+ }
1140
+ /** Start a scored loop and send its opening step.
1141
+ * @param protocol - Prompt text and step count the loop follows.
1142
+ * @param limits - Resolved `--from/--to/--score/--tries`.
1143
+ */
1144
+ async startLoop(protocol, limits) {
1145
+ const sessionId = this.state.sessionId;
1146
+ // Every branch that can leave a start invisible is traced with its reason, so "it did nothing"
1147
+ // is answerable from the trace instead of guessed at.
1148
+ if (sessionId === undefined) {
1149
+ this.traceEvent('loop', { phase: 'refused', kind: protocol.kind, reason: 'no-session' });
1150
+ throw new Error('Select a session first');
1151
+ }
1152
+ // A new run replaces whatever was there; the old one is closed before the new identity exists.
1153
+ this.forgetLoop('replaced');
1154
+ const runId = randomUUID();
1155
+ this.loopRunId = runId;
1156
+ this.loopEndTraced = false;
1157
+ this.traceEvent('loop', { phase: 'begin', runId, kind: protocol.kind, session: sessionId,
1158
+ from: limits.from, to: limits.to, score: limits.score, tries: limits.tries, forked: this.verifier !== undefined });
1159
+ const loop = new ScoredLoop(runId, sessionId, protocol, limits);
1160
+ this.loop = loop;
1161
+ // A protocol that reviews something already on disk verifies first: a round that passes costs no
1162
+ // work turn at all, and only a failing verdict asks the agent to change anything.
1163
+ if (this.startsByVerifying(loop)) {
1164
+ this.update({});
1165
+ this.armLoopDeadline();
1166
+ this.traceEvent('loop', { phase: 'verify-first', runId, kind: protocol.kind, step: limits.from });
1167
+ this.verifyStep(loop);
1168
+ return;
1169
+ }
1170
+ const prompt = loop.start();
1171
+ loop.sent();
1172
+ this.update({});
1173
+ this.armLoopDeadline();
1174
+ try {
1175
+ await this.session.promptInternal(prompt);
1176
+ this.traceEvent('loop', { phase: 'sent', runId, kind: protocol.kind, step: limits.from, attempt: 1 });
1177
+ }
1178
+ catch (error) {
1179
+ // The run never got its first turn. It ends needing a person rather than silently vanishing:
1180
+ // the reason says the send was rejected, and the snapshot stays readable.
1181
+ this.rejectLoopSend(loop, error);
1182
+ throw error;
1183
+ }
1184
+ }
1185
+ /** End a run whose next prompt could not be sent, keeping the reason visible.
1186
+ *
1187
+ * A rejected send is not a verdict and not an operator cancellation, so the phase is `needs-human`
1188
+ * and `terminalReason` says why — this is what keeps `phase=cancelled` meaning "a person stopped it".
1189
+ * @param loop - Run that could not send.
1190
+ * @param error - What the host or the session layer rejected with.
1191
+ */
1192
+ rejectLoopSend(loop, error) {
1193
+ const text = errorText(error).slice(0, 200);
1194
+ this.forgetSettleTimer();
1195
+ this.clearLoopDeadline();
1196
+ this.abortVerification();
1197
+ loop.human({ kind: 'send', text }, 'send-rejected');
1198
+ this.traceLoopEnd(loop);
1199
+ this.loopPrompt = undefined;
1200
+ this.update({ lastFailure: text });
1201
+ }
1202
+ /** Record the end of one run once, with the phase it stopped in and why.
1203
+ *
1204
+ * I9 needs every `begin` to be paired inside the trace window; this is the only writer of `loop end`.
1205
+ * @param loop - Run whose terminal phase was just published.
1206
+ */
1207
+ traceLoopEnd(loop) {
1208
+ if (this.loopEndTraced)
1209
+ return;
1210
+ this.loopEndTraced = true;
1211
+ const progress = loop.progress;
1212
+ this.traceEvent('loop', { phase: 'end', runId: progress.runId, kind: loop.protocol.kind,
1213
+ result: progress.phase, step: progress.step, attempt: progress.attempt,
1214
+ reason: progress.terminalReason ?? 'unknown' });
1215
+ }
1216
+ /** Whether a step begins with verification rather than with a work prompt.
1217
+ *
1218
+ * Needs all three: the record asks for it, the record has a verifier prompt, and this client was
1219
+ * given a verifier. Otherwise there is nobody to verify first, and the step asks for work.
1220
+ * @param loop - Loop about to start a step.
1221
+ * @returns True when the step starts by verifying.
1222
+ */
1223
+ startsByVerifying(loop) {
1224
+ return loop.protocol.starts === 'verify' && loop.protocol.verify !== undefined && this.verifier !== undefined;
1225
+ }
1226
+ /** Verify the step in flight without a work turn before it.
1227
+ *
1228
+ * The activity is set inside `verifyRound`, so the first verification, a work-turn verdict and a
1229
+ * retry all publish the same state without this caller having to remember it.
1230
+ * @param loop - Loop whose step and attempt are already set.
1231
+ */
1232
+ verifyStep(loop) {
1233
+ void this.verifySection(loop);
1234
+ }
1235
+ /** Check the round's own section before spending a verifier on it, then verify or ask for work.
1236
+ *
1237
+ * Verify-first exists so a round that already passes costs no work turn. When the section is not in
1238
+ * the artifact at all the round *cannot* pass, and this client can read that itself — the same check
1239
+ * it applies to a verdict before accepting one. Sending a verifier to discover "the file is missing"
1240
+ * costs a session and, because a failed attempt consumes one, also the round's first attempt: a run
1241
+ * over a document with no review yet would start working at attempt 2/10. An artifact this client
1242
+ * cannot read stays a boundary: the verification runs and its verdict decides, as before.
1243
+ * @param loop - Loop whose step and attempt are already set.
1244
+ */
1245
+ async verifySection(loop) {
1246
+ const artifact = loop.protocol.artifact;
1247
+ const marker = loop.protocol.artifactMarker?.(loop.progress.step);
1248
+ if (artifact === undefined || marker === undefined) {
1249
+ void this.verifyRound(loop);
1250
+ return;
1251
+ }
1252
+ // `readText` answers undefined only for a file that is not there and throws for one that cannot be
1253
+ // read, so the two cases are told apart here: a missing file has no section either, while an
1254
+ // unreadable one is a boundary this client cannot decide.
1255
+ let text;
1256
+ try {
1257
+ text = await readText(join(this.localDirectory, artifact));
1258
+ }
1259
+ catch {
1260
+ void this.verifyRound(loop);
1261
+ return;
1262
+ }
1263
+ // Reading the artifact is I/O, so the run may have moved on before it came back.
1264
+ if (this.loop !== loop || !loop.active)
1265
+ return;
1266
+ if (text !== undefined && text.includes(marker)) {
1267
+ void this.verifyRound(loop);
1268
+ return;
1269
+ }
1270
+ // A missing file only proves the producer never wrote it when this client can see the workspace at
1271
+ // all. A workspace this machine does not have is a boundary — the same boundary the artifact check
1272
+ // after a verdict already respects — so the verifier's reading decides instead.
1273
+ if (text === undefined && !await this.workspaceVisible()) {
1274
+ void this.verifyRound(loop);
1275
+ return;
1276
+ }
1277
+ this.traceEvent('loop', { phase: 'work-first', runId: this.loopRunId ?? '', kind: loop.protocol.kind,
1278
+ step: loop.progress.step, artifact, reason: 'section-missing' });
1279
+ loop.note(`⚠ artifact check · ${artifact} 还没有本轮小节,直接开始工作`);
1280
+ // No verdict was consumed, so the attempt is untouched: the first work turn is attempt 1, exactly
1281
+ // as it is for a record that asks for work first.
1282
+ this.loopPrompt = loop.workPrompt();
1283
+ this.update({});
1284
+ void this.flushLoop();
1285
+ }
1286
+ /** Whether the directory this client runs in is readable here at all.
1287
+ *
1288
+ * `readText` answers undefined for a missing file and for a file inside a directory this machine does
1289
+ * not have, and the two mean opposite things: the first is a producer that wrote nothing, the second
1290
+ * is a workspace whose verdict cannot be checked from here.
1291
+ * @returns True when the directory can be listed.
1292
+ */
1293
+ async workspaceVisible() {
1294
+ try {
1295
+ await listEntries(this.localDirectory);
1296
+ return true;
1297
+ }
1298
+ catch {
1299
+ return false;
1300
+ }
1301
+ }
1302
+ /** Arm the whole-run budget, when the operator set one. */
1303
+ armLoopDeadline() {
1304
+ if (this.deadlineMs === undefined)
1305
+ return;
1306
+ this.loopDeadlineTimer = setTimeout(() => this.expireLoopDeadline(), this.deadlineMs);
1307
+ this.loopDeadlineTimer.unref();
1308
+ }
1309
+ /** Answer a paused run, so the current artifact is judged again with what the operator supplied.
1310
+ *
1311
+ * The answer is not a work order: it only adds a condition to the judgment, so nothing is sent to the
1312
+ * agent and no attempt is consumed. The verification that follows is a new task with a new identity,
1313
+ * which is what keeps a late verdict from the paused one from deciding the attempt.
1314
+ * @param text - What the operator added; never written to the trace.
1315
+ */
1316
+ async answerLoop(text) {
1317
+ const loop = this.loop;
1318
+ if (loop === undefined || !loop.active)
1319
+ throw new Error('No loop is waiting for an answer');
1320
+ this.loopAnswer = text;
1321
+ const judged = this.verifier !== undefined && loop.protocol.verify !== undefined;
1322
+ loop.resume(judged ? 'verify' : 'turn');
1323
+ this.update({});
1324
+ this.traceEvent('loop', { phase: 'answered', runId: loop.progress.runId, judged, chars: text.length });
1325
+ if (judged) {
1326
+ this.verifyStep(loop);
1327
+ return;
1328
+ }
1329
+ // Without a forked verifier the answer is the next attempt's instruction: the agent is asked again
1330
+ // with the addition, and the attempt budget still decides how many times that may happen.
1331
+ this.loopPrompt = loop.answerPrompt(text);
1332
+ void this.flushLoop();
1333
+ }
1334
+ /** Stop a running review; the terminal progress stays visible for the reader.
1335
+ * @param reason - Why it stopped; the default is an operator action.
1336
+ */
1337
+ stopLoop(reason = 'user-cancelled') {
1338
+ if (this.loop === undefined)
1339
+ return;
1340
+ this.forgetSettleTimer();
1341
+ this.clearLoopDeadline();
1342
+ this.abortVerification();
1343
+ this.loopEndedAt = undefined;
1344
+ this.loop.cancel(reason);
1345
+ this.traceLoopEnd(this.loop);
1346
+ this.loopPrompt = undefined;
1347
+ this.update({});
1348
+ }
1349
+ /** Drop a finished run's progress line, keeping an active one.
1350
+ *
1351
+ * D3 keeps a terminal result on screen because it is what the reader most needs to see — but it is a
1352
+ * result, not a status: the next line the operator runs means they have read it. An active run is
1353
+ * left alone, so `/loop answer` and `/loop stop` keep the target they were typed for.
1354
+ */
1355
+ clearLoopResult() {
1356
+ const loop = this.loop;
1357
+ if (loop === undefined || loop.active)
1358
+ return;
1359
+ this.forgetLoop();
1360
+ this.update({});
1361
+ }
1362
+ /** Drop the loop entirely, without publishing a cancelled phase.
1363
+ * @param reason - Why it was dropped, recorded when it never reached a terminal phase itself.
1364
+ */
1365
+ forgetLoop(reason = 'replaced') {
1366
+ // A run that is dropped while still active never published a terminal phase: close its trace span
1367
+ // here so one begin still has one end, then let the snapshot go.
1368
+ if (this.loop !== undefined && this.loop.active) {
1369
+ this.loop.cancel(reason);
1370
+ this.traceLoopEnd(this.loop);
1371
+ }
1372
+ this.forgetSettleTimer();
1373
+ this.clearLoopDeadline();
1374
+ this.abortVerification();
1375
+ this.loopPrevious = undefined;
1376
+ this.loopRunId = undefined;
1377
+ this.loopEndTraced = false;
1378
+ this.loopVerifySeq = 0;
1379
+ this.loopVerifyIdentity = undefined;
1380
+ this.loopVerifierMisses = 0;
1381
+ this.loopAnswer = undefined;
1382
+ this.loop = undefined;
1383
+ this.loopPrompt = undefined;
1384
+ this.loopEndedAt = undefined;
1385
+ }
1386
+ /** Note that an attempt's turn ended; its block may still be arriving.
1387
+ * @param sessionId - Session the host reported idle.
1388
+ */
1389
+ settleLoop(sessionId) {
1390
+ const loop = this.loop;
1391
+ if (loop === undefined || !loop.active || !loop.settled || loop.sessionId !== sessionId)
1392
+ return;
1393
+ this.loopEndedAt ??= Date.now();
1394
+ this.trySettleLoop();
1395
+ }
1396
+ /** Consume the attempt once its result block is committed, or the grace period expires.
1397
+ *
1398
+ * The final assistant message can land a moment after the idle event, so an empty parse is not yet
1399
+ * a failed attempt: the next publish retries, and one timer covers a transcript that never grows.
1400
+ */
1401
+ trySettleLoop() {
1402
+ const loop = this.loop;
1403
+ if (loop === undefined || !loop.active || !loop.settled) {
1404
+ this.forgetSettleTimer();
1405
+ this.loopEndedAt = undefined;
1406
+ return;
1407
+ }
1408
+ // Only a turn that actually ended may be consumed: a timer that fired just before the attempt
1409
+ // settled must not decide the attempt that replaced it.
1410
+ const endedAt = this.loopEndedAt;
1411
+ if (endedAt === undefined) {
1412
+ this.forgetSettleTimer();
1413
+ return;
1414
+ }
1415
+ // An independent verifier decides the round; the reply block below stays as its fallback. Its
1416
+ // branch keeps its own `verify` sub-state, so this must not announce settling over it.
1417
+ if (loop.protocol.verify !== undefined && this.verifier !== undefined) {
1418
+ if (!this.loopVerifying && !this.loopSettling)
1419
+ void this.verifyRound(loop);
1420
+ return;
1421
+ }
1422
+ // No forked verifier: reading the reply block, including the grace wait for it, is settling.
1423
+ loop.settling();
1424
+ const result = parseLoopResult(latestAssistantText(this.state.session.record.messages), loop.protocol);
1425
+ if (result === undefined && Date.now() - endedAt < LOOP_SETTLE_GRACE_MS) {
1426
+ if (this.loopSettleTimer === undefined) {
1427
+ this.loopSettleTimer = setTimeout(() => { this.loopSettleTimer = undefined; this.trySettleLoop(); }, LOOP_SETTLE_GRACE_MS);
1428
+ this.loopSettleTimer.unref();
1429
+ }
1430
+ return;
1431
+ }
1432
+ this.forgetSettleTimer();
1433
+ this.loopEndedAt = undefined;
1434
+ void this.settleChecked(loop, result);
1435
+ }
1436
+ /** Score one finished round out of band, in a process of its own.
1437
+ *
1438
+ * The child writes its verdict to a file, so a slow or failed verifier delays the attempt instead
1439
+ * of corrupting it: with no verdict the reply block decides, and with neither the attempt fails.
1440
+ * @param loop - The run whose attempt just finished.
1441
+ */
1442
+ async verifyRound(loop) {
1443
+ const verifier = this.verifier;
1444
+ if (verifier === undefined)
1445
+ return;
1446
+ const { step, attempt } = loop.progress;
1447
+ const { kind } = loop.protocol;
1448
+ const runId = this.loopRunId ?? (this.loopRunId = randomUUID());
1449
+ // Every started verification is its own task: a failure retry or an operator answer must not
1450
+ // reuse the identity or the file of the task it replaced, or a late verdict could decide it.
1451
+ const seq = this.loopVerifySeq += 1;
1452
+ const file = verdictFile(this.verdictRoot, runId, kind, step, attempt, seq);
1453
+ const identity = verificationId(runId, kind, step, attempt, seq);
1454
+ const prompt = loop.protocol.verify(loop.progress, step, attempt, { file, verificationId: identity }, this.loopPrevious);
1455
+ // An answer the operator gave after an abstention only adds a condition: the same artifact is
1456
+ // judged again, under a fresh identity, and no attempt is consumed for it.
1457
+ const answered = this.loopAnswer === undefined ? prompt : `${prompt}\n\n## 操作者的补充判断\n${this.loopAnswer}`;
1458
+ this.loopVerifying = true;
1459
+ this.loopVerifyIdentity = identity;
1460
+ // The sub-state is a fact on the loop, not a note: the progress line and the status bar both read
1461
+ // it, and a retry's warning note stays beside it instead of being overwritten by a string.
1462
+ loop.verifying();
1463
+ this.update({});
1464
+ this.traceEvent('verify', { runId, phase: 'begin', kind, step, attempt, seq, file });
1465
+ const abort = new AbortController();
1466
+ this.loopVerifyAbort = abort;
1467
+ let outcome;
1468
+ try {
1469
+ // The reviewed workspace is declared here, where it is known, so the verifier's artifact check
1470
+ // never has to infer it from whatever directory the process happens to run in.
1471
+ outcome = await verifier.verify({ verificationId: identity, kind, step, attempt, prompt: answered, file,
1472
+ workspace: this.localDirectory,
1473
+ ...(loop.protocol.artifact === undefined ? {} : { artifact: loop.protocol.artifact }),
1474
+ title: `[dsht-verify] ${loop.protocol.title} · ${step}/${attempt}` }, abort.signal);
1475
+ }
1476
+ catch (error) {
1477
+ outcome = { type: 'unavailable', reason: `verifier failed: ${errorText(error)}` };
1478
+ }
1479
+ finally {
1480
+ if (this.loopVerifyAbort === abort)
1481
+ this.loopVerifyAbort = undefined;
1482
+ this.loopVerifying = false;
1483
+ }
1484
+ // A newer verification task owns this attempt now: this task's verdict is stale by definition.
1485
+ if (this.loopVerifyIdentity !== identity) {
1486
+ this.traceEvent('verify', { runId, phase: 'stale', kind, step, attempt, seq });
1487
+ return;
1488
+ }
1489
+ // Verification outlives nothing: a cancelled or replaced loop must not be settled by its result.
1490
+ if (this.loop !== loop || !loop.active || !loop.settled) {
1491
+ this.traceEvent('verify', { runId, phase: 'abandoned', kind, step, attempt, seq });
1492
+ return;
1493
+ }
1494
+ this.forgetSettleTimer();
1495
+ this.loopEndedAt = undefined;
1496
+ // The review was cancelled: nothing to decide, and nothing to report as a verdict.
1497
+ if (outcome.type === 'cancelled') {
1498
+ this.traceEvent('verify', { runId, phase: 'cancelled', kind, step, attempt, seq });
1499
+ return;
1500
+ }
1501
+ // The host is waiting for a human the verifier cannot answer: stop with the request attached.
1502
+ // The verifier is blocked, not broken, so retrying would only hit the same wall three times.
1503
+ if (outcome.type === 'needs-human') {
1504
+ this.traceEvent('verify', { runId, phase: 'needs-human', kind, step, attempt, seq, request: outcome.request.kind });
1505
+ this.clearLoopDeadline();
1506
+ loop.human(outcome.request);
1507
+ this.traceLoopEnd(loop);
1508
+ this.update({});
1509
+ return;
1510
+ }
1511
+ if (outcome.type === 'verified') {
1512
+ this.traceEvent('verify', { runId, phase: 'verified', kind, step, attempt, seq,
1513
+ score: outcome.result.score ?? -1, blocked: outcome.result.blocked === true, status: outcome.result.status ?? 'none' });
1514
+ this.loopVerifierMisses = 0;
1515
+ await this.settleChecked(loop, outcome.result);
1516
+ return;
1517
+ }
1518
+ // Unavailable is not a verdict, so it never consumes the attempt: retry the verifier, and only
1519
+ // then stop the run. Self-scoring is opt-in and is always visible in the progress line.
1520
+ // A failure that says it is not retryable (an unconfirmed remote cancel, a bad configuration)
1521
+ // would only repeat itself, so it is reported instead of spending the retry budget.
1522
+ if (outcome.retryable === false) {
1523
+ this.traceEvent('verify', { runId, phase: 'unavailable', kind, step, attempt, seq, retryable: false, reason: outcome.reason.slice(0, 200) });
1524
+ loop.note(`⚠ verification unavailable · ${outcome.reason}`);
1525
+ this.clearLoopDeadline();
1526
+ loop.unavailable();
1527
+ this.traceLoopEnd(loop);
1528
+ this.update({});
1529
+ return;
1530
+ }
1531
+ this.loopVerifierMisses += 1;
1532
+ if (this.loopVerifierMisses <= VERIFIER_RETRIES) {
1533
+ this.traceEvent('verify', { runId, phase: 'retry', kind, step, attempt, seq, misses: this.loopVerifierMisses, reason: outcome.reason.slice(0, 200) });
1534
+ loop.note(`⚠ verification unavailable · retrying (${outcome.reason})`);
1535
+ this.update({});
1536
+ void this.verifyRound(loop);
1537
+ return;
1538
+ }
1539
+ if (this.allowSelfFallback) {
1540
+ const fallback = parseLoopResult(latestAssistantText(this.state.session.record.messages), loop.protocol);
1541
+ this.traceEvent('verify', { runId, phase: 'fallback', kind, step, attempt, seq, block: fallback !== undefined });
1542
+ await this.settleChecked(loop, fallback, fallback === undefined
1543
+ ? `⚠ verification fallback · self-reported (and no reply block: ${outcome.reason})`
1544
+ : '⚠ verification fallback · self-reported');
1545
+ return;
1546
+ }
1547
+ this.traceEvent('verify', { runId, phase: 'unavailable', kind, step, attempt, seq, retryable: true, misses: this.loopVerifierMisses, reason: outcome.reason.slice(0, 200) });
1548
+ loop.note(`⚠ verification unavailable · ${outcome.reason}`);
1549
+ this.clearLoopDeadline();
1550
+ loop.unavailable();
1551
+ this.traceLoopEnd(loop);
1552
+ this.update({});
1553
+ }
1554
+ /** Apply a protocol's own artifact requirement, then settle the attempt.
1555
+ *
1556
+ * The score is the verifier's judgement; whether the round's conclusion actually reached the
1557
+ * artifact is a fact this client checks itself. A hard condition may not be overridden by a score:
1558
+ * a round whose section is missing fails even at 10/10, and the missing line is fed back to the
1559
+ * next attempt like any other finding. An artifact this client cannot read cannot be checked, so
1560
+ * the verdict stands rather than being failed on a boundary.
1561
+ * @param loop - The run whose attempt just finished.
1562
+ * @param result - Verdict to consume.
1563
+ * @param note - Note to show instead, when the check accepted the verdict unchanged.
1564
+ */
1565
+ async settleChecked(loop, result, note = '') {
1566
+ if (this.loopSettling)
1567
+ return;
1568
+ this.loopSettling = true;
1569
+ try {
1570
+ const marker = result === undefined || result.blocked === true || result.abstained === true
1571
+ ? undefined : loop.protocol.artifactMarker?.(loop.progress.step);
1572
+ const artifact = loop.protocol.artifact;
1573
+ if (marker === undefined || artifact === undefined || result === undefined) {
1574
+ this.settleWith(loop, result, note);
1575
+ return;
1576
+ }
1577
+ const text = await readText(join(this.localDirectory, artifact));
1578
+ // Reading the artifact is I/O, so the run may have moved on before it came back.
1579
+ if (this.loop !== loop || !loop.active || !loop.settled)
1580
+ return;
1581
+ if (text === undefined || text.includes(marker)) {
1582
+ this.settleWith(loop, result, note);
1583
+ return;
1584
+ }
1585
+ this.traceEvent('artifact', { phase: 'missing', artifact, step: loop.progress.step, score: result.score ?? -1 });
1586
+ const reported = result.score === undefined ? '没有分数' : `${result.score} 分`;
1587
+ this.settleWith(loop, { ...result, score: undefined,
1588
+ findings: [...(result.findings ?? []), `工作区文件 ${artifact} 缺少本轮小节「${marker}」`] }, `⚠ artifact check · ${artifact} 缺少本轮小节「${marker}」(验证者给了 ${reported},本轮不通过)`);
1589
+ }
1590
+ finally {
1591
+ this.loopSettling = false;
1592
+ }
1593
+ }
1594
+ /** Apply one attempt's verdict, and continue the run when it has a next step.
1595
+ * @param loop - The run being settled.
1596
+ * @param result - Verdict to consume, or undefined when the attempt produced none.
1597
+ */
1598
+ settleWith(loop, result, note = '') {
1599
+ const before = loop.progress;
1600
+ const step = loop.settle(result);
1601
+ // The answer was the condition for this judgment, so it is spent once the verdict is in.
1602
+ this.loopAnswer = undefined;
1603
+ // A pause is not an end: the run keeps its span (and its deadline) until it is answered or ended.
1604
+ if (step.kind !== 'continue' && !loop.active) {
1605
+ this.clearLoopDeadline();
1606
+ // A decision ended the run: close its trace span with the phase and reason it stopped in.
1607
+ this.traceLoopEnd(loop);
1608
+ }
1609
+ // The note describes the attempt just decided, so it is applied after the state moved on.
1610
+ loop.note(note);
1611
+ if (step.kind === 'continue') {
1612
+ // A retry on the same step carries the verdict that caused it; a new step starts clean.
1613
+ this.loopPrevious = before.step === loop.progress.step && result !== undefined
1614
+ ? { step: before.step, attempt: before.attempt, result } : undefined;
1615
+ // A passing verdict advanced the step. When the record verifies first, the new round is
1616
+ // verified against the artifact as it stands before anyone is asked to change it.
1617
+ if (loop.progress.step !== before.step && this.startsByVerifying(loop)) {
1618
+ this.update({});
1619
+ this.verifyStep(loop);
1620
+ return;
1621
+ }
1622
+ const findings = result === undefined ? [] : findingsLines(result);
1623
+ // `step.prompt` is already brief-aware: a run that began by verifying has sent no prompt yet, so
1624
+ // its first work message is the brief rather than a follow-up that describes nothing.
1625
+ this.loopPrompt = [step.prompt, ...findings].join('\n');
1626
+ }
1627
+ this.update({});
1628
+ if (step.kind === 'continue')
1629
+ void this.flushLoop();
1630
+ }
1631
+ /** Stop an in-flight verification, if any; its result can no longer decide anything. */
1632
+ abortVerification() {
1633
+ this.loopVerifyAbort?.abort();
1634
+ this.loopVerifyAbort = undefined;
1635
+ this.loopVerifying = false;
1636
+ }
1637
+ /** Stop the run because the whole-run budget expired; a budget stop, not a verdict.
1638
+ *
1639
+ * Any verification in flight is cancelled (bounded, as everywhere else) and its late result can
1640
+ * no longer decide anything, because the loop is no longer active.
1641
+ */
1642
+ expireLoopDeadline() {
1643
+ this.loopDeadlineTimer = undefined;
1644
+ const loop = this.loop;
1645
+ if (loop === undefined || !loop.active)
1646
+ return;
1647
+ this.forgetSettleTimer();
1648
+ this.abortVerification();
1649
+ this.loopEndedAt = undefined;
1650
+ loop.note('⚠ deadline reached');
1651
+ loop.deadline();
1652
+ this.traceLoopEnd(loop);
1653
+ this.update({});
1654
+ }
1655
+ /** Drop the run deadline, if one is armed. */
1656
+ clearLoopDeadline() {
1657
+ if (this.loopDeadlineTimer === undefined)
1658
+ return;
1659
+ clearTimeout(this.loopDeadlineTimer);
1660
+ this.loopDeadlineTimer = undefined;
1661
+ }
1662
+ /** Drop a pending settle timer, if any. */
1663
+ forgetSettleTimer() {
1664
+ if (this.loopSettleTimer === undefined)
1665
+ return;
1666
+ clearTimeout(this.loopSettleTimer);
1667
+ this.loopSettleTimer = undefined;
1668
+ }
1669
+ /** Send the prompt the loop is holding, once the client can actually send it. */
1670
+ async flushLoop() {
1671
+ const loop = this.loop;
1672
+ const prompt = this.loopPrompt;
1673
+ if (loop === undefined || prompt === undefined || !loop.active)
1674
+ return;
1675
+ // Nothing of this loop writes while an independent verifier is judging it: the round under
1676
+ // verification must be the round that was scored. A prompt that appears meanwhile is sent when
1677
+ // the verdict lands (or dropped with the run), never interleaved with the verification.
1678
+ if (this.loopVerifying)
1679
+ return;
1680
+ if (this.state.sessionId !== loop.sessionId)
1681
+ return;
1682
+ if (!this.state.online || this.foreground !== undefined || this.state.pending.length)
1683
+ return;
1684
+ // Consume before awaiting, so a re-entrant update cannot send the same prompt twice.
1685
+ this.loopPrompt = undefined;
1686
+ loop.sent();
1687
+ this.update({});
1688
+ try {
1689
+ await this.session.promptInternal(prompt);
1690
+ }
1691
+ catch (error) {
1692
+ // The next prompt was rejected (a waiting interaction, a lost snapshot, offline): the run ends
1693
+ // with that reason recorded instead of vanishing without a trace.
1694
+ this.rejectLoopSend(loop, error);
1695
+ }
1696
+ }
463
1697
  /** Cancel the active turn; pending queue items remain host-owned. */
464
- async cancelTurn() { await this.session.cancelTurn(); }
1698
+ async cancelTurn() {
1699
+ this.stopLoop();
1700
+ await this.session.cancelTurn();
1701
+ }
465
1702
  /** Add a page before the retained window.
466
1703
  * @param signal - Cancels local paging without interrupting the remote agent.
467
1704
  * @param transcript - Transcript to extend; defaults to the live one.
@@ -479,15 +1716,57 @@ export class Controller {
479
1716
  * @returns A caller-owned historical window that must be disposed when closed.
480
1717
  */
481
1718
  async historyAt(target, signal) { return this.session.historyAt(target, signal); }
1719
+ /** Plain rows and offsets for one laid-out record; the projection stays in the session domain. */
1720
+ render(input) {
1721
+ return this.session.render(input);
1722
+ }
482
1723
  /** Load the prefix required for an explicit history jump.
483
1724
  * @param target - Visible record sequence, or first for the oldest available history.
484
1725
  * @param signal - Cancels local paging without interrupting the remote agent.
485
1726
  */
486
1727
  async historyThrough(target, signal) { await this.session.historyThrough(target, signal); }
487
1728
  /** Answer the oldest selected-session interaction, after explicit user action.
488
- * @param value - Structured answer value or approval outcome.
1729
+ * @param value - Structured answer collected by the UI.
489
1730
  */
490
1731
  async answer(value) { await this.session.answer(value); }
1732
+ /** Answer the current sub-question of the pending set, advancing the waterfall or sending it.
1733
+ *
1734
+ * The waterfall is interaction state of the session — which sub-question is current follows from the
1735
+ * answers collected so far, and the ticked labels live in the option state — so it belongs here
1736
+ * rather than in a front end. Both entry points then complete a question the same way: a line the
1737
+ * operator typed as an answer, and the option keys.
1738
+ * @param input - Labels chosen for this sub-question, or free text the operator typed.
1739
+ * @returns True once the answer was recorded or sent; a host refusal throws, leaving the collected
1740
+ * answers in place so the same submission can be retried.
1741
+ */
1742
+ async answerQuestion(input = {}) {
1743
+ const pending = this.state.pending[0];
1744
+ if (pending?.kind !== 'question')
1745
+ throw new Error('No pending question');
1746
+ const interaction = this.state.session.interaction;
1747
+ const answers = interaction.answers[pending.eventId] ?? [];
1748
+ const question = pending.questions[answers.length];
1749
+ if (question === undefined)
1750
+ throw new Error('The pending question has changed');
1751
+ // A typed answer keeps whatever is ticked for a multi-select question; a picked option is explicit.
1752
+ const selected = input.selected ?? (question.multiSelect === true ? interaction.option?.selected ?? [] : []);
1753
+ const answer = { id: question.id, selected: [...selected], ...(input.custom ? { custom: input.custom } : {}) };
1754
+ const next = [...answers, answer];
1755
+ if (next.length < pending.questions.length) {
1756
+ this.setAnswers({ ...interaction.answers, [pending.eventId]: next });
1757
+ this.setOption(undefined);
1758
+ return true;
1759
+ }
1760
+ // A rejected submission keeps the collected answers and the keyboard state, so the reader can
1761
+ // retry the same answer instead of rebuilding it: a refusal throws out of here before the
1762
+ // collected answers are dropped.
1763
+ await this.answer({ answers: next });
1764
+ const rest = { ...interaction.answers };
1765
+ delete rest[pending.eventId];
1766
+ this.setAnswers(rest);
1767
+ this.setOption(undefined);
1768
+ return true;
1769
+ }
491
1770
  /** Approve or reject the pending approval request.
492
1771
  * @param allowed - Whether the request is approved once.
493
1772
  */