@itookit/dsht 0.3.8 → 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 (130) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -11
  3. package/README.zh.md +30 -11
  4. package/dist/cli/dsht.js +203 -18
  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 +616 -166
  18. package/dist/controller/controller.js +1395 -146
  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 +73 -72
  49. package/dist/session/controller.js +185 -209
  50. package/dist/session/history.d.ts +6 -18
  51. package/dist/session/history.js +1 -24
  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 +31 -1
  77. package/dist/shell/controller.js +34 -2
  78. package/dist/shell/index.d.ts +3 -3
  79. package/dist/shell/index.js +2 -2
  80. package/dist/shell/runner.d.ts +10 -0
  81. package/dist/shell/runner.js +48 -9
  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 +856 -441
  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/loop-status.d.ts +11 -0
  102. package/dist/ui/chat/loop-status.js +28 -0
  103. package/dist/ui/chat/navigation-model.d.ts +86 -0
  104. package/dist/ui/chat/navigation-model.js +107 -0
  105. package/dist/ui/chat/shell-view.d.ts +15 -2
  106. package/dist/ui/chat/shell-view.js +37 -3
  107. package/dist/ui/chat/status.d.ts +47 -3
  108. package/dist/ui/chat/status.js +65 -50
  109. package/dist/ui/chat/viewport.d.ts +1 -1
  110. package/dist/ui/dialogs/cost.d.ts +21 -4
  111. package/dist/ui/dialogs/cost.js +7 -12
  112. package/dist/ui/dialogs/index.d.ts +22 -5
  113. package/dist/ui/dialogs/index.js +19 -3
  114. package/dist/ui/dialogs/loop.d.ts +43 -0
  115. package/dist/ui/dialogs/loop.js +224 -0
  116. package/dist/ui/dialogs/peek.d.ts +25 -0
  117. package/dist/ui/dialogs/peek.js +35 -0
  118. package/dist/ui/dialogs/picker.d.ts +2 -0
  119. package/dist/ui/dialogs/picker.js +4 -2
  120. package/dist/ui/input/mouse.d.ts +12 -2
  121. package/dist/ui/input/mouse.js +20 -7
  122. package/dist/ui/input/references.d.ts +1 -1
  123. package/dist/ui/status/model.d.ts +7 -0
  124. package/dist/ui/status/model.js +5 -0
  125. package/dist/ui/theme/index.d.ts +1 -1
  126. package/package.json +6 -4
  127. package/dist/ui/commands/parse.d.ts +0 -104
  128. package/dist/ui/commands/parse.js +0 -135
  129. package/dist/ui/commands/registry.d.ts +0 -33
  130. package/dist/ui/commands/registry.js +0 -73
@@ -1,19 +1,30 @@
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";
16
- import { ShellController } from "../shell/index.js";
27
+ import { ShellController, localSourceId } from "../shell/index.js";
17
28
  /** Environment for a local `!` command: this client's variables without its credentials.
18
29
  *
19
30
  * `DSH_URL` is removed as well as the token, because the URL form the README documents can carry a
@@ -27,18 +38,48 @@ function shellEnv() {
27
38
  delete env.DSH_URL;
28
39
  return env;
29
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(' ');
30
77
  /** Application facade over the domain controllers; the UI owns only this object.
31
78
  *
32
79
  * State lives here, connection generations live in `connection`, the selected session and its
33
80
  * history live in `session`, model metadata lives in `catalog`, and billing lives in `cost`.
34
81
  */
35
82
  export class Controller {
36
- base;
37
- initialSession;
38
- costs;
39
- historyLimits;
40
- memoryLogPath;
41
- localDirectory;
42
83
  state = initialState();
43
84
  /** Physical connection, retry loop and projection store. */
44
85
  connection;
@@ -50,24 +91,119 @@ export class Controller {
50
91
  cost;
51
92
  /** Bounded runtime memory samples; present only when a log path was supplied. */
52
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;
53
142
  /** Local `!` commands, run on this machine and shown inline in the transcript. */
54
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;
55
162
  observers = new Set();
56
163
  selector = 0;
57
- constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS, memoryLogPath,
58
- /** Directory this client runs in, offered as a workspace when the host has not registered it. */
59
- localDirectory = process.cwd(),
60
- /** Whether `!` may run local commands; the CLI disables it with `--no-shell`. */
61
- shellEnabled = true) {
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;
62
190
  this.base = base;
63
- this.initialSession = initialSession;
64
191
  this.costs = costs;
65
192
  this.historyLimits = historyLimits;
66
- this.memoryLogPath = memoryLogPath;
67
- this.localDirectory = localDirectory;
68
- const options = { base, token, initialSession, makeClient, authenticate };
69
- this.connection = new ConnectionController(this, options, this);
70
- 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 }));
71
207
  this.shell = new ShellController({
72
208
  publish: () => this.update({}),
73
209
  cwd: () => this.localDirectory,
@@ -86,8 +222,13 @@ export class Controller {
86
222
  scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
87
223
  scanDone: sessionId => this.session.rememberScanDone(sessionId),
88
224
  });
89
- if (memoryLogPath !== undefined)
90
- 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();
91
232
  }
92
233
  /** React-compatible state subscription. */
93
234
  subscribe = (listener) => {
@@ -97,7 +238,7 @@ export class Controller {
97
238
  /** Snapshot identity changes only when the controller publishes. */
98
239
  snapshot = () => this.state;
99
240
  /** Current projection store; replaced at each connection generation. */
100
- get telemetry() { return this.connection.telemetry; }
241
+ get telemetry() { return this.session.telemetry; }
101
242
  /** Record of the selected session; the one strong owner lives in `State.session`. */
102
243
  get record() { return this.state.session.record; }
103
244
  /** @returns The current selector generation. */
@@ -107,24 +248,226 @@ export class Controller {
107
248
  /** Publish a state patch, re-deriving the visible pending interactions.
108
249
  * @param patch - Fields to replace on the current state.
109
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; }
110
255
  update(patch) {
256
+ const previous = this.state;
111
257
  const next = { ...this.state, ...patch, version: this.state.version + 1 };
112
258
  next.pending = next.online && next.screen === 'chat' && this.session
113
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
+ }
114
271
  this.state = next;
272
+ this.traceTransition(previous, next);
115
273
  for (const observer of this.observers)
116
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
+ };
117
446
  }
118
447
  /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
119
- 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
+ }
120
457
  /** Cancel retries and HTTP, close the socket, and release session and catalog work. */
121
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();
122
464
  await this.shell.stop();
123
465
  await this.connection.stop();
124
466
  await this.session.settle();
125
467
  await this.catalog.settle();
126
468
  await this.cost?.stop();
127
469
  await this.memoryLog?.stop();
470
+ await this.trace?.settle();
128
471
  this.session.release();
129
472
  }
130
473
  /** Stop the selected turn and then close, so quitting does not leave host work running.
@@ -135,25 +478,133 @@ export class Controller {
135
478
  await this.session.interrupt(true);
136
479
  await this.stop();
137
480
  }
138
- /** 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.
139
488
  * @param operation - Operation to run while the client is busy.
140
- * @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.
141
585
  */
142
- async perform(operation) {
143
- if (this.state.busy || !this.state.online)
586
+ cancelForeground() {
587
+ if (this.foreground === undefined)
144
588
  return false;
145
- 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) {
146
600
  try {
147
601
  await operation();
148
602
  return true;
149
603
  }
150
604
  catch (error) {
151
- this.update({ error: errorText(error) });
605
+ this.update({ lastFailure: errorText(error) });
152
606
  return false;
153
607
  }
154
- finally {
155
- this.update({ busy: false });
156
- }
157
608
  }
158
609
  /** Read the counters one memory sample records; content never leaves as text.
159
610
  *
@@ -211,54 +662,102 @@ export class Controller {
211
662
  }
212
663
  /** A new generation starts; drop generation-scoped domain state. */
213
664
  begin() {
665
+ this.traceEvent('generation', { phase: 'begin', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
214
666
  this.session.beginGeneration();
215
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();
216
671
  this.update({ controlError: undefined });
217
672
  }
218
673
  /** The event stream is ready and the control baseline is applied. */
219
674
  async ready() {
220
675
  this.catalog.refresh();
221
- this.update({ online: true, status: 'Connected', error: '', pending: [] });
676
+ this.update({ online: true, status: 'Connected', pending: [], lastFailure: '' });
222
677
  const screen = this.state.screen;
223
- await this.session.showPicker(screen === 'sessions' ? 'sessions' : 'workspaces');
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);
224
682
  // Starting inside a registered workspace's directory already answers the first question, so the
225
683
  // reader lands on that workspace's sessions instead of a list they would pick from by hand.
226
- if (screen !== 'sessions' && !this.initialSession && this.session.adoptLocalWorkspace(this.localDirectory) !== undefined) {
227
- this.update({ status: 'Workspace from this directory · ← to switch' });
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' });
228
694
  }
229
- const sessionId = this.state.sessionId ?? this.initialSession;
230
- if (sessionId && (screen === 'chat' || this.initialSession && !this.state.sessionId))
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) {
231
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' });
232
709
  this.cost?.start();
233
710
  }
234
- /** 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
+ */
235
716
  async ended() {
717
+ this.traceEvent('generation', { phase: 'ended', screen: this.state.screen, session: this.state.sessionId ?? 'none' });
236
718
  this.session.endGeneration();
237
719
  await this.cost?.stop();
238
720
  }
239
- /** Deliver a host waterfall to the session domain.
240
- * @param frame - One decoded waterfall frame.
241
- * @returns Whether the session domain retained it.
242
- */
243
- waterfall(frame) { return this.session.waterfall(frame); }
244
- /** Drop a waterfall the host cancelled.
245
- * @param eventId - Correlation id previously retained.
246
- */
247
- cancelled(eventId) { this.session.cancelled(eventId); }
248
- /** Apply one host running-state notification.
249
- * @param sessionId - Session whose state changed.
250
- * @param running - Whether the host still runs that session.
251
- */
252
- status(sessionId, running) { this.session.status(sessionId, running); }
253
- /** Surface a host-reported session error.
254
- * @param sessionId - Session the host reported on.
255
- * @param error - Error payload as delivered by the host.
256
- */
257
- error(sessionId, error) { this.session.reportError(sessionId, error); }
258
- /** Reload the model catalog after a host settings, credential or adapter change. */
259
- invalidated() { this.catalog.refresh(); }
260
- /** Refresh billing after a turn finished. */
261
- 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
+ }
262
761
  /** @returns Host running state of the selected session. */
263
762
  get running() { return this.session.running; }
264
763
  /** @returns Current session title, falling back to the list title and then the ID. */
@@ -267,8 +766,159 @@ export class Controller {
267
766
  get sessionMode() { return this.session.sessionMode; }
268
767
  /** @returns Epoch start of the active turn, when known. */
269
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
+ }
270
792
  /** @returns Sessions accounted to the selected workspace, minus archived identities. */
271
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
+ }
272
922
  /** @returns Unanswered interactions by session, for the state each list row reports. */
273
923
  pendingCounts() { return this.session.pendingCounts(); }
274
924
  /** Load the optional preset roster once per connection. */
@@ -287,16 +937,22 @@ export class Controller {
287
937
  * @param force - Send an explicit cancellation even when the cached running flag is idle.
288
938
  * @returns True when the caller may exit.
289
939
  */
290
- interrupt(force = false) { return this.session.interrupt(force); }
291
- /** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
292
- get sessionCostText() {
293
- const sessionId = this.state.sessionId;
294
- 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);
295
944
  }
945
+ /** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
296
946
  /** Keep history stable while the user reads, searches, or expands it.
297
947
  * @param pinned - Whether the main transcript is being read away from its tail.
298
948
  */
299
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); }
300
956
  /** Recall one step through the selected session's prompt index; never touches the network.
301
957
  * @param direction - Negative for older input, positive for newer input.
302
958
  * @param current - Composer content before recall began, restored at the newest position.
@@ -319,45 +975,17 @@ export class Controller {
319
975
  * @returns Whether any older prompt was recovered.
320
976
  */
321
977
  refillRecall() { return this.session.refillRecall(); }
322
- /** Composer draft, caret and parked draft of the selected session. */
323
- get composer() { return this.session.composer; }
324
- /** Replace the composer text and caret.
325
- * @param draft - New text.
326
- * @param cursor - Caret column; defaults to the end of the text.
327
- */
328
- setComposer(draft, cursor) { this.session.setComposer(draft, cursor); }
329
- /** Move the caret without changing the text.
330
- * @param cursor - Caret column.
331
- */
332
- setComposerCursor(cursor) { this.session.setComposerCursor(cursor); }
333
- /** Move a non-empty draft aside while a dialog owns the keyboard. */
334
- parkComposer() { this.session.parkComposer(); }
335
- /** Give a parked draft back once no dialog needs the keyboard. */
336
- restoreComposer() { this.session.restoreComposer(); }
337
- /** How the selected session's record is being read right now. */
338
- get view() { return this.session.view; }
339
- /** Show a detached history window, releasing the one it replaces.
340
- * @param window - Record to display, or undefined to return to the live transcript.
341
- */
342
- setViewWindow(window) { this.session.setViewWindow(window); }
343
- /** Move the reader's position inside the displayed record.
344
- * @param scroll - Rows scrolled back from the live end.
345
- */
346
- setScroll(scroll) { this.session.setScroll(scroll); }
347
- /** Replace the set of expanded reasoning blocks.
348
- * @param folds - Sequences to expand beyond the default fold.
349
- */
350
- setFolds(folds) { this.session.setFolds(folds); }
351
- /** Set the fold mode of the live attempt's completed reasoning.
352
- * @param reasoning - `row` to fold, `full` to keep the streamed text.
353
- */
354
- setLiveReasoning(reasoning) { this.session.setLiveReasoning(reasoning); }
355
978
  /** Local answer state for the selected session's pending waterfalls. */
356
979
  get interaction() { return this.session.interaction; }
357
980
  /** Replace the partly collected answers, keyed by waterfall event id.
358
981
  * @param answers - Answers collected so far, by event id.
359
982
  */
360
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
+ }
361
989
  /** Replace the pending question's option keyboard state.
362
990
  * @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
363
991
  */
@@ -366,38 +994,6 @@ export class Controller {
366
994
  * @param approval - Selected approval row; undefined clears the highlight.
367
995
  */
368
996
  setApproval(approval) { this.session.setApproval(approval); }
369
- /** Composer-adjacent `@` reference menu state. */
370
- get reference() { return this.session.reference; }
371
- /** Highlight one row of the open reference menu.
372
- * @param index - Row index into the current matches.
373
- */
374
- setReferenceIndex(index) { this.session.setReferenceIndex(index); }
375
- /** Remember the draft that dismissed the reference menu.
376
- * @param draft - Composer text at dismissal, or undefined to allow the menu again.
377
- */
378
- setReferenceDismissed(draft) { this.session.setReferenceDismissed(draft); }
379
- /** Panels the selected session has open. */
380
- get panels() { return this.session.panels; }
381
- /** Show or hide the reasoning panel.
382
- * @param open - Whether `/think` is open.
383
- */
384
- openThoughts(open) { this.session.openThoughts(open); }
385
- /** Show or hide the pending-input panel.
386
- * @param open - Whether `/queue` is open.
387
- */
388
- openQueue(open) { this.session.openQueue(open); }
389
- /** Show the model dialog at one step, or close it.
390
- * @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
391
- */
392
- setModelPanel(model) { this.session.setModelPanel(model); }
393
- /** Show the history or content-search dialog, or close it.
394
- * @param history - Query, content-search mode and matches; undefined closes the dialog.
395
- */
396
- setHistoryPanel(history) { this.session.setHistoryPanel(history); }
397
- /** Show the host session-search results, or close them.
398
- * @param search - Query, results and truncation flag; undefined closes the dialog.
399
- */
400
- setSearchPanel(search) { this.session.setSearchPanel(search); }
401
997
  /** Refresh all HTTP-visible sessions without changing the selected conversation.
402
998
  * @param signal - Optional cancellation for an explicit /cost refresh.
403
999
  */
@@ -405,7 +1001,10 @@ export class Controller {
405
1001
  /** Refresh both lists from the host, then show the requested picker.
406
1002
  * @param screen - Picker to display after the refresh.
407
1003
  */
408
- 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
+ }
409
1008
  /** Resolve a removal command to one reviewable object.
410
1009
  * @param kind - Workspace registration removal or session archival.
411
1010
  * @param query - Exact name, ID, or unambiguous ID prefix.
@@ -415,31 +1014,63 @@ export class Controller {
415
1014
  /** Apply a confirmed removal or verified empty-session archival.
416
1015
  * @param target - Exact workspace or session identity reviewed by the user.
417
1016
  */
418
- 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
+ }
419
1021
  /** Pick a workspace, or use all sessions when the identity is omitted.
420
1022
  * @param workspaceId - Workspace to select, if any.
421
1023
  */
422
- 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
+ }
423
1036
  /** Open a workspace picker, or resolve a workspace target.
424
1037
  * @param query - Workspace target, if any.
425
1038
  */
426
- 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
+ }
427
1043
  /** Guide session selection, list all sessions with `all`, or resolve a target.
428
1044
  * @param query - Session target, `all`, or nothing for the guided picker.
429
1045
  */
430
- 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
+ }
431
1050
  /** Prompt for a host path without starting a local agent. */
432
- enterPath() { this.session.enterPath(); }
1051
+ enterPath() {
1052
+ this.traceEvent('action', { action: 'enterPath' });
1053
+ this.session.enterPath();
1054
+ }
433
1055
  /** Register a host directory and move to its session picker.
434
1056
  * @param path - Absolute directory path on the host.
435
1057
  */
436
- 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
+ }
437
1062
  /** Create a session in the selected workspace. */
438
- 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
+ }
439
1067
  /** Replace the selected transcript and follow the session.
440
1068
  * @param sessionId - Session to follow.
441
1069
  */
442
- 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
+ }
443
1074
  /** Wait for the selected follow snapshot.
444
1075
  * @param signal - Cancels waiting without closing the session.
445
1076
  */
@@ -487,11 +1118,587 @@ export class Controller {
487
1118
  */
488
1119
  heapSnapshot(tag) { return writeHeapSnapshot(process.cwd(), tag); }
489
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.
490
1124
  * @param text - Composed prompt text.
491
1125
  */
492
- 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
+ }
493
1697
  /** Cancel the active turn; pending queue items remain host-owned. */
494
- async cancelTurn() { await this.session.cancelTurn(); }
1698
+ async cancelTurn() {
1699
+ this.stopLoop();
1700
+ await this.session.cancelTurn();
1701
+ }
495
1702
  /** Add a page before the retained window.
496
1703
  * @param signal - Cancels local paging without interrupting the remote agent.
497
1704
  * @param transcript - Transcript to extend; defaults to the live one.
@@ -509,15 +1716,57 @@ export class Controller {
509
1716
  * @returns A caller-owned historical window that must be disposed when closed.
510
1717
  */
511
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
+ }
512
1723
  /** Load the prefix required for an explicit history jump.
513
1724
  * @param target - Visible record sequence, or first for the oldest available history.
514
1725
  * @param signal - Cancels local paging without interrupting the remote agent.
515
1726
  */
516
1727
  async historyThrough(target, signal) { await this.session.historyThrough(target, signal); }
517
1728
  /** Answer the oldest selected-session interaction, after explicit user action.
518
- * @param value - Structured answer value or approval outcome.
1729
+ * @param value - Structured answer collected by the UI.
519
1730
  */
520
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
+ }
521
1770
  /** Approve or reject the pending approval request.
522
1771
  * @param allowed - Whether the request is approved once.
523
1772
  */