@itookit/dsht 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +10 -4
  3. package/README.zh.md +12 -6
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +22 -2
  8. package/dist/cli/startup.js +30 -11
  9. package/dist/cli/verifier.d.ts +4 -0
  10. package/dist/cli/verifier.js +28 -5
  11. package/dist/contracts.d.ts +42 -5
  12. package/dist/controller/connection-streams.d.ts +22 -0
  13. package/dist/controller/connection-streams.js +105 -0
  14. package/dist/controller/connection.d.ts +14 -3
  15. package/dist/controller/connection.js +40 -69
  16. package/dist/controller/controller.d.ts +20 -234
  17. package/dist/controller/controller.js +113 -811
  18. package/dist/controller/foreground.d.ts +44 -0
  19. package/dist/controller/foreground.js +79 -0
  20. package/dist/controller/loop-coordinator.d.ts +48 -0
  21. package/dist/controller/loop-coordinator.js +647 -0
  22. package/dist/controller/loop-prompts-schema.d.ts +16 -2
  23. package/dist/controller/loop-prompts-schema.js +106 -27
  24. package/dist/controller/loop-prompts.d.ts +17 -2
  25. package/dist/controller/loop-prompts.generated.js +2 -1
  26. package/dist/controller/loop-prompts.js +35 -9
  27. package/dist/controller/loop-protocols.d.ts +3 -1
  28. package/dist/controller/loop-protocols.js +8 -3
  29. package/dist/controller/loop-source.d.ts +74 -0
  30. package/dist/controller/loop-source.js +224 -0
  31. package/dist/controller/verifier.d.ts +4 -0
  32. package/dist/cost/controller.d.ts +3 -1
  33. package/dist/cost/controller.js +26 -7
  34. package/dist/cost/index.d.ts +1 -1
  35. package/dist/cost/index.js +1 -1
  36. package/dist/cost/ledger-files.d.ts +20 -0
  37. package/dist/cost/ledger-files.js +115 -15
  38. package/dist/cost/ledger.d.ts +31 -6
  39. package/dist/cost/ledger.js +74 -22
  40. package/dist/cost/pricing.d.ts +39 -0
  41. package/dist/cost/pricing.js +46 -0
  42. package/dist/cost/scanner.js +1 -0
  43. package/dist/cost/types.d.ts +9 -3
  44. package/dist/session/controller.d.ts +23 -35
  45. package/dist/session/controller.js +113 -363
  46. package/dist/session/history-reader.d.ts +32 -0
  47. package/dist/session/history-reader.js +170 -0
  48. package/dist/session/index.d.ts +1 -1
  49. package/dist/session/info.d.ts +3 -38
  50. package/dist/session/info.js +14 -1
  51. package/dist/session/interactions.d.ts +26 -0
  52. package/dist/session/interactions.js +75 -0
  53. package/dist/session/navigator.d.ts +47 -0
  54. package/dist/session/navigator.js +158 -0
  55. package/dist/session/prompt-backfill.d.ts +23 -0
  56. package/dist/session/prompt-backfill.js +88 -0
  57. package/dist/session/state.d.ts +20 -0
  58. package/dist/session/state.js +1 -0
  59. package/dist/session/telemetry.d.ts +15 -6
  60. package/dist/session/telemetry.js +44 -7
  61. package/dist/session/transcript.d.ts +5 -1
  62. package/dist/slash/index.d.ts +1 -1
  63. package/dist/slash/parse.d.ts +2 -126
  64. package/dist/slash/registry.d.ts +1 -1
  65. package/dist/slash/types.d.ts +126 -0
  66. package/dist/slash/types.js +1 -0
  67. package/dist/state.d.ts +5 -17
  68. package/dist/state.js +1 -1
  69. package/dist/storage/files.d.ts +8 -0
  70. package/dist/storage/files.js +18 -1
  71. package/dist/storage/index.d.ts +1 -1
  72. package/dist/storage/index.js +1 -1
  73. package/dist/transport/client.d.ts +4 -3
  74. package/dist/transport/client.js +71 -25
  75. package/dist/ui/app.js +88 -301
  76. package/dist/ui/chat/shell-view.d.ts +2 -0
  77. package/dist/ui/chat/shell-view.js +8 -0
  78. package/dist/ui/chat/use-history-view.d.ts +69 -0
  79. package/dist/ui/chat/use-history-view.js +123 -0
  80. package/dist/ui/dialogs/cost.d.ts +6 -0
  81. package/dist/ui/dialogs/cost.js +5 -1
  82. package/dist/ui/dialogs/loop.d.ts +5 -4
  83. package/dist/ui/dialogs/loop.js +14 -6
  84. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  85. package/dist/ui/dialogs/use-panels.js +51 -0
  86. package/dist/ui/input/use-composer.d.ts +35 -0
  87. package/dist/ui/input/use-composer.js +109 -0
  88. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  89. package/dist/ui/input/use-deferred-lines.js +54 -0
  90. package/dist/ui/input/use-history-recall.d.ts +20 -0
  91. package/dist/ui/input/use-history-recall.js +47 -0
  92. package/loop.yaml +230 -0
  93. package/package.json +5 -4
@@ -1,9 +1,7 @@
1
1
  /** Application facade: composes the connection, session, catalog and cost domains. */
2
2
  import { join } from 'node:path';
3
- import { AsyncLocalStorage } from 'node:async_hooks';
4
- import { randomUUID } from 'node:crypto';
5
3
  import { Client } from "../transport/client.js";
6
- import { listEntries, readText, removeFile, writeHeapSnapshot } from "../storage/index.js";
4
+ import { removeFile, writeHeapSnapshot } from "../storage/index.js";
7
5
  import { errorText, string } from "../transport/wire.js";
8
6
  import { DEFAULT_HISTORY_LIMITS } from "../session/memory.js";
9
7
  import { layoutStats } from "../session/history.js";
@@ -13,12 +11,13 @@ import { CatalogController } from "../catalog/controller.js";
13
11
  import { CostController } from "../cost/controller.js";
14
12
  import { ConnectionController } from "./connection.js";
15
13
  import { MemoryLog } from "./memory-log.js";
14
+ import { ForegroundSlot } from "./foreground.js";
16
15
  import { TraceLog } from "./trace-log.js";
17
16
  import { PromptStore } from "./prompts.js";
18
- import { latestAssistantText, parseLoopResult, ScoredLoop } from "./loop.js";
19
- import { findingsLines } from "./loop-contract.js";
17
+ import { latestAssistantText } from "./loop.js";
18
+ import { LoopCoordinator } from "./loop-coordinator.js";
20
19
  import { loopRecords as listLoopRecords } from "./loop-protocols.js";
21
- import { verificationId, verdictFile } from "./verifier.js";
20
+ import { loopSourceInfo } from "./loop-prompts.js";
22
21
  import { SessionPeek } from "../session/peek.js";
23
22
  import { costAddresses } from "../cost/scanner.js";
24
23
  import { sessionLabel } from "../session-title.js";
@@ -38,8 +37,6 @@ function shellEnv() {
38
37
  delete env.DSH_URL;
39
38
  return env;
40
39
  }
41
- /** How many times a verifier outage is retried before the run reports verification unavailable. */
42
- const VERIFIER_RETRIES = 2;
43
40
  /** Sources this client registers before it forgets the oldest; a session list can still find them. */
44
41
  const SOURCE_LIMIT = 50;
45
42
  /** The tag every verifier session title starts with, so a reader can tell it apart in a list. */
@@ -49,13 +46,6 @@ function verifierLabel(title) {
49
46
  const stripped = title.startsWith(VERIFIER_TAG) ? title.slice(VERIFIER_TAG.length) : title;
50
47
  return stripped.trim() === '' ? title : stripped.trim();
51
48
  }
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
49
  /** File `/handoff` clears on this machine before it asks the agent to write a handoff. */
60
50
  const HANDOFF_FILE = 'HANDOFF.md';
61
51
  /** Instruction `/handoff` sends once this client's own copy of the file is gone.
@@ -95,50 +85,12 @@ export class Controller {
95
85
  trace;
96
86
  /** Shortcut prompts the operator saved; in memory for this run when no path was supplied. */
97
87
  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
88
  /** Finished turns of the selected session, so a waiter never has to sample a cached flag. */
103
89
  completedTurns = 0;
104
90
  /** Sessions the host reported busy, so a lone idle frame cannot claim a finished turn. */
105
91
  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;
92
+ /** Loop execution owns all verifier, settling and deadline state. */
93
+ loops;
142
94
  /** Local `!` commands, run on this machine and shown inline in the transcript. */
143
95
  shell;
144
96
  /** Mutating surface the UI drives. */
@@ -155,26 +107,20 @@ export class Controller {
155
107
  memoryLogPath;
156
108
  /** Directory this client runs in. */
157
109
  localDirectory;
158
- /** Independent verifier for scored rounds, when one was supplied. */
159
- verifier;
160
110
  /** Session opened at startup, when one was named. */
161
111
  initialSession;
162
112
  observers = new Set();
163
113
  selector = 0;
164
114
  connectionSettled = false;
115
+ /** Startup defaults apply once; subsequent generations restore the operator's selection. */
116
+ initialized = false;
165
117
  /** Counter behind `commandId`, so every executed line has one identifier in begin and end. */
166
118
  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();
119
+ /** Scheduling and cancellation are owned independently of domain operations. */
120
+ foregroundSlot = new ForegroundSlot({
121
+ changed: started => this.update(started ? { lastFailure: '' } : {}),
122
+ trace: event => this.traceEvent('foreground', event),
123
+ });
178
124
  /** Read-only follower for the full-screen view; it borrows the connection and never selects. */
179
125
  peek;
180
126
  /** Sources this client created, keyed by session id; the host cannot record that lineage itself. */
@@ -193,10 +139,15 @@ export class Controller {
193
139
  this.memoryLogPath = options.memoryLogPath;
194
140
  this.localDirectory = options.localDirectory ?? process.cwd();
195
141
  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;
142
+ this.loops = new LoopCoordinator({
143
+ facts: () => ({ sessionId: this.state.sessionId, online: this.state.online,
144
+ ready: this.state.session.record.ready, busy: this.foregroundSlot.snapshot !== undefined, pending: this.state.pending.length > 0 }),
145
+ reply: () => latestAssistantText(this.state.session.record.messages),
146
+ send: prompt => this.session.promptInternal(prompt),
147
+ publish: failure => this.update(failure === undefined ? {} : { lastFailure: failure }),
148
+ trace: (event, detail) => this.traceEvent(event, detail),
149
+ }, { directory: this.localDirectory, verifier: options.verifier, verdictRoot: options.verdictRoot,
150
+ deadlineMs: options.deadlineMs, allowSelfFallback: options.allowSelfFallback });
200
151
  const connectionOptions = { base, token, initialSession, makeClient, authenticate };
201
152
  this.connection = new ConnectionController(this, connectionOptions, this);
202
153
  // The view follows other sessions over the same connection; it never becomes a second writer.
@@ -210,13 +161,21 @@ export class Controller {
210
161
  env: () => shellEnv(),
211
162
  anchor: () => this.state.session.record.readThrough,
212
163
  }, shellEnabled);
213
- this.catalog = new CatalogController(this, this.connection);
164
+ this.catalog = new CatalogController({
165
+ client: () => this.connection.client(), require: () => this.connection.require(),
166
+ online: () => this.state.online, signal: () => this.connection.signal(),
167
+ selection: () => ({ revision: this.selection(), sessionId: this.state.sessionId }),
168
+ publish: patch => this.update(patch),
169
+ });
214
170
  if (costs)
215
171
  this.cost = new CostController(costs, {
216
172
  client: () => this.connection.client(),
217
173
  online: () => this.state.online,
218
174
  signal: () => this.connection.signal(),
219
175
  publish: () => this.update({}),
176
+ // The session on screen is scanned whatever its age: `/cost` reports its own total, and the
177
+ // window filter exists to skip sessions nobody is looking at.
178
+ selectedSessionId: () => this.state.sessionId,
220
179
  // The scan already reads every session's whole history; handing its pages to the session
221
180
  // domain lets the prompt cache pick them up, so one open does not pay for a second walk.
222
181
  scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
@@ -251,19 +210,19 @@ export class Controller {
251
210
  /** Whether an operation owns the client's foreground slot right now.
252
211
  * @returns True while an operation is running.
253
212
  */
254
- busy() { return this.foreground !== undefined; }
213
+ busy() { return this.foregroundSlot.snapshot !== undefined; }
255
214
  update(patch) {
256
215
  const previous = this.state;
257
216
  const next = { ...this.state, ...patch, version: this.state.version + 1 };
258
217
  next.pending = next.online && next.screen === 'chat' && this.session
259
- ? this.session.pendingFor(next) : [];
218
+ ? this.session.pendingFor(next.sessionId) : [];
260
219
  // The shell service owns its blocks; state carries only the plain snapshot the UI renders.
261
220
  if (this.shell)
262
221
  next.shell = this.shell.snapshot();
263
222
  // A loop belongs to one session: selecting a different session ends it, but a reconnect — which
264
223
  // 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();
224
+ if (next.sessionId !== undefined && next.sessionId !== this.state.sessionId && this.loops.sessionId !== next.sessionId) {
225
+ this.loops.forget();
267
226
  // The read-only view belongs to the conversation that opened it; leaving that conversation
268
227
  // closes it, and the session switch that follows is never left rendering someone else's rows.
269
228
  this.dropPeek();
@@ -272,12 +231,7 @@ export class Controller {
272
231
  this.traceTransition(previous, next);
273
232
  for (const observer of this.observers)
274
233
  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();
234
+ this.loops.changed();
281
235
  }
282
236
  /** Record one diagnostic event; a no-op when no trace path was configured. */
283
237
  traceEvent(event, detail = {}) {
@@ -334,33 +288,34 @@ export class Controller {
334
288
  */
335
289
  buildActions() {
336
290
  return {
337
- foreground: (kind, label, work, wait) => this.claimForeground(kind, label, work, wait),
338
- cancelForeground: () => this.cancelForeground(),
291
+ foreground: (kind, label, work, wait) => this.foregroundSlot.run(kind, label, work, wait),
292
+ cancelForeground: () => this.foregroundSlot.cancel(),
339
293
  openPeek: id => this.openPeek(id),
340
294
  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)),
295
+ switchWorkspace: id => this.runAction('navigation', 'Switching workspace…', signal => this.switchWorkspace(id, signal)),
296
+ switchSession: query => this.runAction('navigation', 'Switching session…', signal => this.switchSession(query, signal)),
343
297
  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()),
298
+ createWorkspace: path => this.runAction('navigation', 'Registering workspace…', signal => this.createWorkspace(path, signal)),
299
+ createSession: () => this.runAction('navigation', 'Creating session…', signal => this.createSession(signal)),
346
300
  createVerifierSession: title => this.runActionValue('verifier', 'Creating verifier session…', () => this.createVerifierSession(title)),
347
- cancelVerifierSession: sessionId => this.runAction('verifier', 'Stopping verifier…', async () => {
301
+ cancelVerifierSession: sessionId => this.runImmediateAction(async () => {
348
302
  await this.session.cancelNamedSession(sessionId);
349
303
  // The run is over even though its transcript stays readable; the list should say so.
350
304
  this.endSource(sessionId, Date.now());
351
305
  }),
352
- showPicker: screen => this.runAction('picker', 'Listing…', () => this.showPicker(screen)),
306
+ showPicker: screen => this.runAction('picker', 'Listing…', signal => this.showPicker(screen, signal)),
353
307
  removeTarget: target => this.runAction('removal', 'Removing…', () => this.removeTarget(target)),
354
308
  removalTarget: (kind, query) => this.runActionValue('removal', 'Reading target…', () => this.removalTarget(kind, query)),
355
309
  waitForHistory: signal => this.runAction('history', 'Loading history…', () => this.waitForHistory(signal)),
356
310
  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)),
311
+ openHistory: (target, signal) => this.runAction('history', 'Opening history…', operation => this.session.openHistory(target, AbortSignal.any([signal, operation]))),
312
+ searchHistory: (query, signal) => this.runActionValue('search', 'Searching history…', operation => this.searchHistory(query, AbortSignal.any([signal, operation]))),
358
313
  prompt: text => this.runAction('prompt', 'Sending…', () => this.prompt(text)),
359
314
  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)),
315
+ startLoop: (protocol, limits) => this.runAction('loop', 'Starting loop…', () => this.loops.start(protocol, limits)),
316
+ stopLoop: () => this.loops.stop(),
317
+ clearLoopResult: () => this.loops.clearResult(),
318
+ answerLoop: text => this.runAction('loop', 'Answering the verifier…', () => this.loops.answer(text)),
364
319
  cancelTurn: () => this.runAction('interaction', 'Cancelling…', () => this.cancelTurn()),
365
320
  answer: value => this.runAction('interaction', 'Answering…', () => this.answer(value)),
366
321
  answerQuestion: input => this.runAction('interaction', 'Answering…', async () => {
@@ -370,19 +325,19 @@ export class Controller {
370
325
  approve: allowed => this.runAction('interaction', 'Answering…', () => this.approve(allowed)),
371
326
  dismissQuestion: () => this.runAction('interaction', 'Dismissing…', () => this.dismissQuestion()),
372
327
  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)),
328
+ older: (signal, transcript) => this.runAction('history', 'Loading history…', operation => this.older(signal === undefined ? operation : AbortSignal.any([signal, operation]), transcript)),
329
+ historyThrough: (target, signal) => this.runAction('history', 'Loading history…', operation => this.historyThrough(target, AbortSignal.any([signal, operation]))),
375
330
  removeQueued: itemId => this.runAction('command', 'Removing queued input…', () => this.removeQueued(itemId)),
376
- savePrompt: text => this.runAction('local', 'Saving prompt…', async () => {
331
+ savePrompt: text => this.runImmediateAction(async () => {
377
332
  await this.promptStore.save(text);
378
333
  this.update({ lastFailure: '' });
379
334
  }),
380
- updatePrompt: (id, text) => this.runAction('local', 'Saving prompt…', async () => {
335
+ updatePrompt: (id, text) => this.runImmediateAction(async () => {
381
336
  if (!await this.promptStore.update(id, text))
382
337
  throw new Error('That saved prompt no longer exists');
383
338
  this.update({ lastFailure: '' });
384
339
  }),
385
- deletePrompt: id => this.runAction('local', 'Deleting prompt…', async () => {
340
+ deletePrompt: id => this.runImmediateAction(async () => {
386
341
  if (!await this.promptStore.remove(id))
387
342
  throw new Error('That saved prompt no longer exists');
388
343
  this.update({ lastFailure: '' });
@@ -390,15 +345,15 @@ export class Controller {
390
345
  command: (line, signal) => this.runActionValue('command', 'Running command…', () => this.command(line, signal)),
391
346
  exportLog: (path, signal) => this.runActionValue('export', 'Exporting session log…', () => this.exportLog(path, signal)),
392
347
  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)),
348
+ selectModel: (provider, model, effort) => this.runAction('model', 'Selecting model…', signal => this.catalog.selectModel(provider, model, effort, signal)),
349
+ modelCatalog: () => this.runActionValue('model', 'Loading models…', signal => this.catalog.modelCatalog(signal)),
350
+ refreshCosts: signal => this.runAction('cost', 'Refreshing costs…', operation => this.refreshCosts(signal === undefined ? operation : AbortSignal.any([signal, operation]))),
396
351
  loadPresetNames: () => this.loadPresetNames(),
397
352
  clearFailure: () => this.clearFailure(),
398
353
  enterPath: () => this.enterPath(),
399
354
  pickWorkspace: id => this.pickWorkspace(id),
400
355
  showChat: () => this.showChat(),
401
- setViewWindow: window => this.setViewWindow(window),
356
+ showLatest: () => this.session.setViewWindow(undefined),
402
357
  pinHistory: pinned => this.pinHistory(pinned),
403
358
  setAnswers: answers => this.setAnswers(answers),
404
359
  setOption: option => this.setOption(option),
@@ -415,8 +370,8 @@ export class Controller {
415
370
  return {
416
371
  get running() { return controller.running; },
417
372
  get turnsCompleted() { return controller.completedTurns; },
418
- get forkedVerification() { return controller.verifier !== undefined; },
419
- get selfScoring() { return controller.verifier === undefined || controller.allowSelfFallback; },
373
+ get forkedVerification() { return controller.loops.forkedVerification; },
374
+ get selfScoring() { return controller.loops.selfScoring; },
420
375
  get connectionSettled() { return controller.connectionSettled; },
421
376
  get sessionName() { return controller.sessionName; },
422
377
  get sessionMode() { return controller.sessionMode; },
@@ -425,22 +380,22 @@ export class Controller {
425
380
  get record() { return controller.record; },
426
381
  get window() { return controller.window; },
427
382
  get interaction() { return controller.interaction; },
428
- get telemetry() { return controller.telemetry; },
383
+ get telemetry() { return controller.telemetry.reader; },
429
384
  get recallAtOldest() { return controller.recallAtOldest; },
430
385
  get recallLength() { return controller.recallLength; },
431
386
  get recallHasOlder() { return controller.recallHasOlder; },
432
387
  get prompts() { return controller.promptStore.list; },
433
388
  get promptsError() { return controller.promptStore.error; },
434
- get loop() { return controller.loop?.progress; },
435
- get foreground() { return controller.foreground; },
389
+ get loop() { return controller.loops.progress; },
390
+ get foreground() { return controller.foregroundSlot.snapshot; },
436
391
  get sources() { return controller.outputSources(); },
437
392
  get peek() { return controller.peekSnapshot(); },
438
393
  get activity() { return controller.activity; },
439
394
  get loopRecords() { return listLoopRecords(); },
395
+ get loopSource() { return loopSourceInfo(); },
440
396
  pendingCounts: () => controller.pendingCounts(),
441
397
  recall: (direction, current) => controller.recall(direction, current),
442
398
  references: (query, signal) => controller.references(query, signal),
443
- historyAt: (target, signal) => controller.historyAt(target, signal),
444
399
  render: input => controller.render(input),
445
400
  };
446
401
  }
@@ -457,9 +412,9 @@ export class Controller {
457
412
  /** Cancel retries and HTTP, close the socket, and release session and catalog work. */
458
413
  async stop() {
459
414
  // 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();
415
+ this.foregroundSlot.close();
416
+ this.catalog.close();
417
+ await this.loops.close();
463
418
  this.dropPeek();
464
419
  await this.shell.stop();
465
420
  await this.connection.stop();
@@ -489,13 +444,13 @@ export class Controller {
489
444
  * @returns Whether the operation ran to completion.
490
445
  */
491
446
  async runAction(kind, label, operation) {
492
- if (!this.state.online)
493
- return false;
494
- if (!this.ownsForeground() && this.foreground !== undefined)
447
+ if (!this.state.online) {
448
+ this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
495
449
  return false;
496
- return await this.claimForeground(kind, label, async () => {
450
+ }
451
+ return await this.foregroundSlot.run(kind, label, async (signal) => {
497
452
  try {
498
- await operation();
453
+ await operation(signal);
499
454
  return true;
500
455
  }
501
456
  catch (error) {
@@ -506,13 +461,13 @@ export class Controller {
506
461
  }
507
462
  /** Same slot, for an operation that produces a value the caller needs. */
508
463
  async runActionValue(kind, label, operation) {
509
- if (!this.state.online)
510
- return undefined;
511
- if (!this.ownsForeground() && this.foreground !== undefined)
464
+ if (!this.state.online) {
465
+ this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
512
466
  return undefined;
513
- return await this.claimForeground(kind, label, async () => {
467
+ }
468
+ return await this.foregroundSlot.run(kind, label, async (signal) => {
514
469
  try {
515
- return await operation();
470
+ return await operation(signal);
516
471
  }
517
472
  catch (error) {
518
473
  this.update({ lastFailure: errorText(error) });
@@ -520,83 +475,11 @@ export class Controller {
520
475
  }
521
476
  });
522
477
  }
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.
478
+ /** Run outside the foreground slot, with the same application failure reporting.
479
+ * Local prompt edits need no connection; verifier cancellation uses the session control lane and
480
+ * must reach the host even while the foreground is busy or closed for shutdown.
530
481
  */
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.
585
- */
586
- cancelForeground() {
587
- if (this.foreground === undefined)
588
- return false;
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) {
482
+ async runImmediateAction(operation) {
600
483
  try {
601
484
  await operation();
602
485
  return true;
@@ -672,35 +555,28 @@ export class Controller {
672
555
  }
673
556
  /** The event stream is ready and the control baseline is applied. */
674
557
  async ready() {
675
- this.catalog.refresh();
676
558
  this.update({ online: true, status: 'Connected', pending: [], lastFailure: '' });
559
+ this.catalog.refresh();
677
560
  const screen = this.state.screen;
678
561
  this.traceEvent('generation', { phase: 'ready', screen });
679
- const picker = screen === 'sessions' ? 'sessions' : 'workspaces';
680
- this.traceEvent('picker', { requested: picker });
681
- await this.session.showPicker(picker);
682
- // Starting inside a registered workspace's directory already answers the first question, so the
683
- // reader lands on that workspace's sessions instead of a list they would pick from by hand.
684
- // The guard only excludes the `sessions` screen, so it also runs when the captured screen is
685
- // `chat`: a reconnect mid-conversation then adopts the local workspace, `pickWorkspace` clears
686
- // the selection, and the reader is returned to `/resume`. The trace records the `adopt` event
687
- // and the `state` transition that follow, which is how that jump is told from a user action.
688
- const mayAdopt = screen !== 'sessions' && !this.initialSession;
689
- if (mayAdopt) {
562
+ this.traceEvent('navigation-refresh', { screen });
563
+ await this.session.refreshLists();
564
+ // Startup may choose the local workspace. Reconnect only refreshes data: it must not reinterpret
565
+ // the reader's current screen as a request to navigate or discard a selected conversation.
566
+ if (!this.initialized && this.state.screen === 'workspaces' && !this.state.sessionId && !this.initialSession) {
690
567
  const adopted = this.session.adoptLocalWorkspace(this.localDirectory);
691
568
  this.traceEvent('adopt', { directory: this.localDirectory, workspace: adopted ?? 'none' });
692
569
  if (adopted !== undefined)
693
570
  this.update({ status: 'Workspace from this directory · ← to switch' });
694
571
  }
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) {
572
+ const selected = this.state.sessionId;
573
+ const sessionId = selected ?? this.loops.sessionId ?? (!this.initialized ? this.initialSession : undefined);
574
+ this.traceEvent('resolve', { session: sessionId ?? 'none', looping: this.loops.sessionId ?? 'none', reselect: sessionId !== undefined });
575
+ if (selected !== undefined)
576
+ this.session.restoreSelectedSession();
577
+ else if (sessionId !== undefined)
702
578
  await this.session.selectSession(sessionId);
703
- }
579
+ this.initialized = true;
704
580
  // `online` means the socket works; `connectionSettled` means the picker and the selection are
705
581
  // done, which is what an automation caller must wait for or it races `showPicker`.
706
582
  this.connectionSettled = true;
@@ -744,7 +620,7 @@ export class Controller {
744
620
  if (started && event.sessionId === this.state.sessionId)
745
621
  this.completedTurns += 1;
746
622
  this.cost?.onTurnIdle();
747
- this.settleLoop(event.sessionId);
623
+ this.loops.idle(event.sessionId);
748
624
  }
749
625
  return true;
750
626
  case 'catalog-invalidated':
@@ -763,7 +639,7 @@ export class Controller {
763
639
  /** @returns Current session title, falling back to the list title and then the ID. */
764
640
  get sessionName() { return this.session.sessionName; }
765
641
  /** @returns Current agent-preset label. */
766
- get sessionMode() { return this.session.sessionMode; }
642
+ get sessionMode() { return this.session.sessionMode(this.state.presets); }
767
643
  /** @returns Epoch start of the active turn, when known. */
768
644
  get workingSince() { return this.session.workingSince; }
769
645
  /** What the client is doing right now, merged across the host turn and any running loop.
@@ -776,7 +652,7 @@ export class Controller {
776
652
  if (this.running) {
777
653
  return { kind: 'turn', ...(this.workingSince === undefined ? {} : { since: this.workingSince }) };
778
654
  }
779
- const progress = this.loop?.progress;
655
+ const progress = this.loops.progress;
780
656
  if (progress === undefined || !progress.active)
781
657
  return undefined;
782
658
  // A paused run is still the client's work, but nothing is running: the bar must ask for the reader
@@ -849,7 +725,7 @@ export class Controller {
849
725
  id: sessionId, kind: 'session', label: verifierLabel(title), state: 'running', startedAt: Date.now(),
850
726
  createdBy: 'verifier',
851
727
  ...(parent === undefined ? {} : { parentSessionId: parent }),
852
- ...(this.verifier === undefined ? {} : { detail: `verifier ${this.verifier.name}` }),
728
+ ...(this.loops.verifierName === undefined ? {} : { detail: `verifier ${this.loops.verifierName}` }),
853
729
  });
854
730
  // The bar the run echoed into the transcript now opens this session, which is the newest check.
855
731
  this.shell.link(sessionId);
@@ -923,23 +799,13 @@ export class Controller {
923
799
  pendingCounts() { return this.session.pendingCounts(); }
924
800
  /** Load the optional preset roster once per connection. */
925
801
  loadPresetNames() { this.catalog.loadPresetNames(); }
926
- /** @returns Host model routes and adapter-owned reasoning choices. */
927
- async modelCatalog() { return this.catalog.modelCatalog(); }
928
- /** Select the next request's model.
929
- * @param provider - Host provider route ID.
930
- * @param model - Exact model ID.
931
- * @param reasoningEffort - Optional adapter-owned effort ID.
932
- */
933
- async selectModel(provider, model, reasoningEffort) {
934
- await this.catalog.selectModel(provider, model, reasoningEffort);
935
- }
936
802
  /** Stop the selected turn, or allow exit only while idle.
937
803
  * @param force - Send an explicit cancellation even when the cached running flag is idle.
938
804
  * @returns True when the caller may exit.
939
805
  */
940
806
  interrupt(force = false) {
941
807
  // Esc and Ctrl+C stop the automated loop as well as the turn; otherwise it would keep sending.
942
- this.stopLoop();
808
+ this.loops.stop();
943
809
  return this.session.interrupt(force);
944
810
  }
945
811
  /** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
@@ -952,7 +818,6 @@ export class Controller {
952
818
  /** Show a detached history window, releasing the one it replaces.
953
819
  * @param window - Record to display, or undefined to return to the live transcript.
954
820
  */
955
- setViewWindow(window) { this.session.setViewWindow(window); }
956
821
  /** Recall one step through the selected session's prompt index; never touches the network.
957
822
  * @param direction - Negative for older input, positive for newer input.
958
823
  * @param current - Composer content before recall began, restored at the newest position.
@@ -1001,9 +866,9 @@ export class Controller {
1001
866
  /** Refresh both lists from the host, then show the requested picker.
1002
867
  * @param screen - Picker to display after the refresh.
1003
868
  */
1004
- async showPicker(screen) {
869
+ async showPicker(screen, signal) {
1005
870
  this.traceEvent('action', { action: 'showPicker', screen });
1006
- await this.session.showPicker(screen);
871
+ await this.session.showPicker(screen, signal);
1007
872
  }
1008
873
  /** Resolve a removal command to one reviewable object.
1009
874
  * @param kind - Workspace registration removal or session archival.
@@ -1036,16 +901,16 @@ export class Controller {
1036
901
  /** Open a workspace picker, or resolve a workspace target.
1037
902
  * @param query - Workspace target, if any.
1038
903
  */
1039
- async switchWorkspace(query) {
904
+ async switchWorkspace(query, signal) {
1040
905
  this.traceEvent('action', { action: 'switchWorkspace', query: query ?? 'picker' });
1041
- await this.session.switchWorkspace(query);
906
+ await this.session.switchWorkspace(query, signal);
1042
907
  }
1043
908
  /** Guide session selection, list all sessions with `all`, or resolve a target.
1044
909
  * @param query - Session target, `all`, or nothing for the guided picker.
1045
910
  */
1046
- async switchSession(query) {
911
+ async switchSession(query, signal) {
1047
912
  this.traceEvent('action', { action: 'switchSession', query: query ?? 'picker' });
1048
- await this.session.switchSession(query);
913
+ await this.session.switchSession(query, signal);
1049
914
  }
1050
915
  /** Prompt for a host path without starting a local agent. */
1051
916
  enterPath() {
@@ -1055,14 +920,14 @@ export class Controller {
1055
920
  /** Register a host directory and move to its session picker.
1056
921
  * @param path - Absolute directory path on the host.
1057
922
  */
1058
- async createWorkspace(path) {
923
+ async createWorkspace(path, signal) {
1059
924
  this.traceEvent('action', { action: 'createWorkspace', path });
1060
- await this.session.createWorkspace(path);
925
+ await this.session.createWorkspace(path, signal);
1061
926
  }
1062
927
  /** Create a session in the selected workspace. */
1063
- async createSession() {
928
+ async createSession(signal) {
1064
929
  this.traceEvent('action', { action: 'createSession', workspace: this.state.workspaceId ?? 'none' });
1065
- await this.session.createSession();
930
+ await this.session.createSession(signal);
1066
931
  }
1067
932
  /** Replace the selected transcript and follow the session.
1068
933
  * @param sessionId - Session to follow.
@@ -1124,7 +989,7 @@ export class Controller {
1124
989
  * @param text - Composed prompt text.
1125
990
  */
1126
991
  async prompt(text) {
1127
- this.stopLoop();
992
+ this.loops.stop();
1128
993
  await this.session.prompt(text);
1129
994
  }
1130
995
  /** Clear this client's stale handoff file, then ask the agent to write a new one.
@@ -1137,566 +1002,9 @@ export class Controller {
1137
1002
  await removeFile(join(this.localDirectory, HANDOFF_FILE));
1138
1003
  await this.session.promptInternal(HANDOFF_PROMPT);
1139
1004
  }
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
- }
1697
1005
  /** Cancel the active turn; pending queue items remain host-owned. */
1698
1006
  async cancelTurn() {
1699
- this.stopLoop();
1007
+ this.loops.stop();
1700
1008
  await this.session.cancelTurn();
1701
1009
  }
1702
1010
  /** Add a page before the retained window.
@@ -1710,12 +1018,6 @@ export class Controller {
1710
1018
  * @returns Newest-first bounded summaries and an explicit truncation flag.
1711
1019
  */
1712
1020
  async searchHistory(query, signal) { return this.session.searchHistory(query, signal); }
1713
- /** Load a separate small window ending at a search target.
1714
- * @param target - Durable message sequence to display.
1715
- * @param signal - Cancels the target-page request.
1716
- * @returns A caller-owned historical window that must be disposed when closed.
1717
- */
1718
- async historyAt(target, signal) { return this.session.historyAt(target, signal); }
1719
1021
  /** Plain rows and offsets for one laid-out record; the projection stays in the session domain. */
1720
1022
  render(input) {
1721
1023
  return this.session.render(input);