@itookit/dsht 0.5.1 → 0.5.2

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