@itookit/dsht 0.3.8 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -11
  3. package/README.zh.md +30 -11
  4. package/dist/cli/dsht.js +203 -18
  5. package/dist/cli/startup.d.ts +40 -0
  6. package/dist/cli/startup.js +295 -0
  7. package/dist/cli/trace-summary.d.ts +78 -0
  8. package/dist/cli/trace-summary.js +241 -0
  9. package/dist/cli/verifier.d.ts +60 -0
  10. package/dist/cli/verifier.js +242 -0
  11. package/dist/contracts.d.ts +344 -0
  12. package/dist/contracts.js +1 -0
  13. package/dist/controller/commands.d.ts +47 -0
  14. package/dist/controller/commands.js +322 -0
  15. package/dist/controller/connection.d.ts +11 -29
  16. package/dist/controller/connection.js +26 -60
  17. package/dist/controller/controller.d.ts +616 -166
  18. package/dist/controller/controller.js +1395 -146
  19. package/dist/controller/index.d.ts +8 -1
  20. package/dist/controller/index.js +5 -0
  21. package/dist/controller/loop-contract.d.ts +136 -0
  22. package/dist/controller/loop-contract.js +308 -0
  23. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  24. package/dist/controller/loop-prompts-schema.js +144 -0
  25. package/dist/controller/loop-prompts.d.ts +55 -0
  26. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  27. package/dist/controller/loop-prompts.generated.js +185 -0
  28. package/dist/controller/loop-prompts.js +104 -0
  29. package/dist/controller/loop-protocols.d.ts +39 -0
  30. package/dist/controller/loop-protocols.js +115 -0
  31. package/dist/controller/loop.d.ts +275 -0
  32. package/dist/controller/loop.js +378 -0
  33. package/dist/controller/prompts.d.ts +54 -0
  34. package/dist/controller/prompts.js +162 -0
  35. package/dist/controller/trace-log.d.ts +45 -0
  36. package/dist/controller/trace-log.js +144 -0
  37. package/dist/controller/verifier.d.ts +126 -0
  38. package/dist/controller/verifier.js +75 -0
  39. package/dist/cost/index.d.ts +1 -1
  40. package/dist/cost/index.js +1 -1
  41. package/dist/cost/ledger.d.ts +0 -1
  42. package/dist/cost/ledger.js +0 -1
  43. package/dist/json.d.ts +18 -0
  44. package/dist/json.js +19 -0
  45. package/dist/references.d.ts +25 -0
  46. package/dist/references.js +26 -0
  47. package/dist/session/connection-view.d.ts +2 -11
  48. package/dist/session/controller.d.ts +73 -72
  49. package/dist/session/controller.js +185 -209
  50. package/dist/session/history.d.ts +6 -18
  51. package/dist/session/history.js +1 -24
  52. package/dist/session/index.d.ts +9 -4
  53. package/dist/session/index.js +7 -3
  54. package/dist/session/info.d.ts +25 -52
  55. package/dist/session/info.js +39 -25
  56. package/dist/session/markdown.js +1 -1
  57. package/dist/session/math.js +1 -1
  58. package/dist/session/mutation-gate.d.ts +51 -0
  59. package/dist/session/mutation-gate.js +73 -0
  60. package/dist/session/navigation.d.ts +2 -89
  61. package/dist/session/navigation.js +2 -129
  62. package/dist/session/peek.d.ts +38 -0
  63. package/dist/session/peek.js +103 -0
  64. package/dist/session/references.d.ts +2 -20
  65. package/dist/session/references.js +1 -26
  66. package/dist/session/runtime.d.ts +26 -0
  67. package/dist/session/runtime.js +28 -0
  68. package/dist/session/telemetry.d.ts +12 -13
  69. package/dist/session/telemetry.js +27 -58
  70. package/dist/session/transcript.d.ts +0 -6
  71. package/dist/session/transcript.js +2 -15
  72. package/dist/session/types.d.ts +25 -0
  73. package/dist/session/types.js +0 -1
  74. package/dist/session-title.d.ts +9 -0
  75. package/dist/session-title.js +21 -0
  76. package/dist/shell/controller.d.ts +31 -1
  77. package/dist/shell/controller.js +34 -2
  78. package/dist/shell/index.d.ts +3 -3
  79. package/dist/shell/index.js +2 -2
  80. package/dist/shell/runner.d.ts +10 -0
  81. package/dist/shell/runner.js +48 -9
  82. package/dist/slash/index.d.ts +10 -0
  83. package/dist/slash/index.js +7 -0
  84. package/dist/slash/parse.d.ts +166 -0
  85. package/dist/slash/parse.js +259 -0
  86. package/dist/slash/pipeline.d.ts +140 -0
  87. package/dist/slash/pipeline.js +115 -0
  88. package/dist/slash/registry.d.ts +88 -0
  89. package/dist/slash/registry.js +177 -0
  90. package/dist/state.d.ts +14 -4
  91. package/dist/state.js +3 -2
  92. package/dist/text.d.ts +28 -0
  93. package/dist/text.js +55 -0
  94. package/dist/transport/events.d.ts +104 -0
  95. package/dist/transport/events.js +149 -0
  96. package/dist/transport/wire.d.ts +9 -17
  97. package/dist/transport/wire.js +2 -27
  98. package/dist/ui/app.js +856 -441
  99. package/dist/ui/chat/header.js +1 -1
  100. package/dist/ui/chat/history-view.d.ts +1 -1
  101. package/dist/ui/chat/loop-status.d.ts +11 -0
  102. package/dist/ui/chat/loop-status.js +28 -0
  103. package/dist/ui/chat/navigation-model.d.ts +86 -0
  104. package/dist/ui/chat/navigation-model.js +107 -0
  105. package/dist/ui/chat/shell-view.d.ts +15 -2
  106. package/dist/ui/chat/shell-view.js +37 -3
  107. package/dist/ui/chat/status.d.ts +47 -3
  108. package/dist/ui/chat/status.js +65 -50
  109. package/dist/ui/chat/viewport.d.ts +1 -1
  110. package/dist/ui/dialogs/cost.d.ts +21 -4
  111. package/dist/ui/dialogs/cost.js +7 -12
  112. package/dist/ui/dialogs/index.d.ts +22 -5
  113. package/dist/ui/dialogs/index.js +19 -3
  114. package/dist/ui/dialogs/loop.d.ts +43 -0
  115. package/dist/ui/dialogs/loop.js +224 -0
  116. package/dist/ui/dialogs/peek.d.ts +25 -0
  117. package/dist/ui/dialogs/peek.js +35 -0
  118. package/dist/ui/dialogs/picker.d.ts +2 -0
  119. package/dist/ui/dialogs/picker.js +4 -2
  120. package/dist/ui/input/mouse.d.ts +12 -2
  121. package/dist/ui/input/mouse.js +20 -7
  122. package/dist/ui/input/references.d.ts +1 -1
  123. package/dist/ui/status/model.d.ts +7 -0
  124. package/dist/ui/status/model.js +5 -0
  125. package/dist/ui/theme/index.d.ts +1 -1
  126. package/package.json +6 -4
  127. package/dist/ui/commands/parse.d.ts +0 -104
  128. package/dist/ui/commands/parse.js +0 -135
  129. package/dist/ui/commands/registry.d.ts +0 -33
  130. package/dist/ui/commands/registry.js +0 -73
@@ -0,0 +1,322 @@
1
+ import { LOOP_USAGE } from "../slash/index.js";
2
+ import { errorText } from "../transport/wire.js";
3
+ import { loopProtocolFor, loopProtocolNames, loopRecordVars } from "./loop-protocols.js";
4
+ import { resolveLoop } from "./loop.js";
5
+ /** An accepted line: apply these effects in order, then clear the draft. */
6
+ function ok(effects) {
7
+ return { disposition: 'consume', outcome: 'ok', effects };
8
+ }
9
+ /** A refusal the operator can read; the draft — and any form on screen — stays where it is.
10
+ *
11
+ * A line that never ran does not rearrange the screen: closing panels is a view transition belonging
12
+ * to a command that executed, and doing it here would throw away a form the reader is still editing
13
+ * (the `/loop` parameter form is the case that found this).
14
+ * @param text - Failure line, applied last so the other effects cannot replace it.
15
+ * @param effects - What else to do first, when a caller really does want a surface closed.
16
+ */
17
+ function refused(text, effects = []) {
18
+ return { disposition: 'retain', outcome: 'rejected', effects: [...effects, { kind: 'error', text }] };
19
+ }
20
+ /** An operation the caller aborted (Esc): nothing to show, and nothing to clear. */
21
+ function cancelled() {
22
+ return { disposition: 'retain', outcome: 'cancelled', effects: [] };
23
+ }
24
+ /** One cancellable port call, reporting whether the caller aborted it.
25
+ *
26
+ * `undefined` from the port means "no value" for two different reasons, and the result type must not
27
+ * confuse them: a refusal to start is not an interruption the operator caused.
28
+ */
29
+ async function runCancellable(port, label, operation) {
30
+ let signal;
31
+ const value = await port.run(label, inner => { signal = inner; return operation(inner); });
32
+ return { value, cancelled: value === undefined && signal?.aborted === true };
33
+ }
34
+ /** Resolve a removal into either a finished removal or the confirmation the UI must show.
35
+ *
36
+ * Shared by the `/ws --delete`/`/resume --delete` commands and the pickers' `d` key, so the
37
+ * empty-session rule has one implementation.
38
+ * @param controller - Application facade the removal acts on.
39
+ * @param kind - Workspace registration or session archival.
40
+ * @param query - Exact name, ID, or unambiguous ID prefix.
41
+ * @returns The effects to apply, or undefined when the target could not be resolved.
42
+ */
43
+ export async function removalIntent(controller, kind, query) {
44
+ const target = await controller.actions.removalTarget(kind, query);
45
+ if (target === undefined)
46
+ return undefined;
47
+ // An empty session carries no history, so it is archived without a review step.
48
+ if (target.kind === 'session' && target.empty) {
49
+ if (!await controller.actions.removeTarget(target))
50
+ return undefined;
51
+ return [{ kind: 'closePanels' }];
52
+ }
53
+ return [{ kind: 'closePanels' }, { kind: 'open', panel: 'removal' }, { kind: 'removal', removal: target }];
54
+ }
55
+ /** Run one submitted line and describe what it did and what the front end must show.
56
+ *
57
+ * This is the single entry point every front end uses — the composer and the scripted startup runner
58
+ * alike — so one line has one effect and one trace span wherever it came from.
59
+ * @param controller - Application facade the command acts on.
60
+ * @param command - Executable line; the front-end modes (`ignore`, `reference`) never reach here.
61
+ * @param port - Cancellable-operation port supplied by the caller.
62
+ * @returns The result, or undefined when the application did not accept the line.
63
+ */
64
+ export async function runCommand(controller, command, port) {
65
+ // A finished run's progress line is a result the reader may still be reading; running this line means
66
+ // they are done with it. A run that is still active is never dropped here, so `/loop answer` and
67
+ // `/loop stop` keep their target — and this runs before the line's own effects, so the line that
68
+ // ends a run still leaves its terminal progress on screen to be read.
69
+ controller.actions.clearLoopResult();
70
+ // One span per executed line, with the id every other event of this line can name. The begin is
71
+ // written before any effect, so a crash mid-command still shows that the executor was entered.
72
+ const commandId = controller.nextCommandId();
73
+ controller.traceNote('command', { phase: 'begin', commandId, kind: command.kind });
74
+ // What the action envelope said before this line ran: a failure it records during the line is this
75
+ // line's own, and the result below is where the operator reads it.
76
+ const envelopeBefore = controller.state.lastFailure;
77
+ let result;
78
+ try {
79
+ result = await execute(controller, command, port);
80
+ }
81
+ catch (error) {
82
+ // The end must be written whatever happened, or one begin would never have its end.
83
+ controller.traceNote('command', { phase: 'end', commandId, kind: command.kind, outcome: 'failed',
84
+ disposition: 'retain', error: errorText(error).slice(0, 200) });
85
+ throw error;
86
+ }
87
+ // The end is the one place that answers "how did this line end", whatever the command, so no command
88
+ // has to invent its own outcome name — and the outcome here is the same fact the reader is shown.
89
+ const failure = result?.effects.find(effect => effect.kind === 'error');
90
+ controller.traceNote('command', { phase: 'end', commandId, kind: command.kind,
91
+ outcome: result?.outcome ?? 'refused', disposition: result?.disposition ?? 'retain',
92
+ ...(failure?.kind === 'error' ? { error: failure.text.slice(0, 200) } : {}) });
93
+ // One fact, one channel (13.2-D2): a failure this line already reported in its result must not stay
94
+ // in the action envelope and be shown a second time in the status bar. An unaccepted line keeps it —
95
+ // there the envelope is the only explanation the operator has.
96
+ if (result !== undefined && result.outcome !== 'ok' && controller.state.lastFailure !== envelopeBefore) {
97
+ controller.actions.clearFailure();
98
+ }
99
+ return result;
100
+ }
101
+ /** Dispatch one executable line to its application policy.
102
+ *
103
+ * Every branch is application policy: which action to call, which notice to show, and which panel the
104
+ * result belongs to. The `ui` layer only applies the returned effects to component state.
105
+ * @param controller - Application facade the command acts on.
106
+ * @param command - Executable line.
107
+ * @param port - Cancellable-operation port supplied by the caller.
108
+ * @returns The result, or undefined when the application did not accept the line.
109
+ */
110
+ async function execute(controller, command, port) {
111
+ switch (command.kind) {
112
+ case 'quit': return ok([{ kind: 'quit' }]);
113
+ // Copy mode freezes the display, so panels are deliberately left as they are.
114
+ case 'copy': return ok([{ kind: 'copy' }]);
115
+ case 'panel': {
116
+ // The cost panel is the one panel whose rows are a host request. Opening it starts that request
117
+ // here, where the effect lives, rather than in the front end: the UI only shows its loading label.
118
+ if (command.panel === 'cost') {
119
+ void port.run('Refreshing costs…', signal => controller.actions.refreshCosts(signal)).catch(() => undefined);
120
+ }
121
+ return ok([{ kind: 'closePanels' }, { kind: 'toggle', panel: command.panel }]);
122
+ }
123
+ case 'remove': {
124
+ const effects = await removalIntent(controller, command.target, command.query);
125
+ return effects === undefined ? undefined : ok(effects);
126
+ }
127
+ case 'navigate': {
128
+ const accepted = command.target === 'workspace'
129
+ ? await controller.actions.switchWorkspace(command.query)
130
+ : await controller.actions.switchSession(command.query);
131
+ return accepted ? ok([{ kind: 'closePanels' }, { kind: 'scroll', position: 0 }]) : undefined;
132
+ }
133
+ case 'path':
134
+ return await controller.actions.createWorkspace(command.value) ? ok([{ kind: 'closePanels' }]) : undefined;
135
+ case 'latest':
136
+ return ok([{ kind: 'closePanels' }, { kind: 'live' }, { kind: 'pinLive' }, { kind: 'resetFolds' }, { kind: 'scroll', position: 0 }]);
137
+ case 'models': {
138
+ if (!command.args.length) {
139
+ const catalog = await controller.actions.modelCatalog();
140
+ return catalog === undefined ? undefined : ok([{ kind: 'closePanels' }, { kind: 'model', model: { catalog } }]);
141
+ }
142
+ const accepted = await controller.actions.selectModel(command.args[0], command.args[1], command.args[2]);
143
+ return accepted ? ok([{ kind: 'closePanels' }, { kind: 'close', panel: 'model' }]) : undefined;
144
+ }
145
+ case 'queue': return ok([{ kind: 'closePanels' }, { kind: 'open', panel: 'queue' }]);
146
+ case 'prompts': return ok([{ kind: 'closePanels' }, { kind: 'open', panel: 'prompts' }]);
147
+ case 'savePrompt':
148
+ return await controller.actions.savePrompt(command.text)
149
+ ? ok([{ kind: 'closePanels' }, { kind: 'notice', text: 'Saved prompt' }]) : undefined;
150
+ case 'shell':
151
+ controller.shell.start(command.command);
152
+ return ok([{ kind: 'closePanels' }, { kind: 'scroll', position: 0 }]);
153
+ case 'newSession':
154
+ return await controller.actions.createSession() ? ok([{ kind: 'closePanels' }]) : undefined;
155
+ case 'history':
156
+ return ok([{ kind: 'closePanels' }, { kind: 'history', history: { query: command.query, contentSearch: false } }]);
157
+ case 'sessionSearch': {
158
+ const { value: result, cancelled: aborted } = await runCancellable(port, 'Searching sessions…', signal => controller.actions.searchSessions(command.query, command.command === '/ssearch', signal));
159
+ if (result === undefined)
160
+ return aborted ? cancelled() : undefined;
161
+ return ok([{ kind: 'closePanels' }, { kind: 'search', search: { query: command.query, ...result } }]);
162
+ }
163
+ case 'historySearch': {
164
+ const { value: matches, cancelled: aborted } = await runCancellable(port, 'Searching history…', signal => controller.actions.searchHistory(command.query, signal));
165
+ if (matches === undefined)
166
+ return aborted ? cancelled() : undefined;
167
+ return ok([{ kind: 'closePanels' }, { kind: 'history', history: { query: command.query, contentSearch: true, matches } }]);
168
+ }
169
+ case 'think': {
170
+ if (command.target === 'live') {
171
+ return ok([{ kind: 'closePanels' }, { kind: 'toggleLiveReasoning' }, { kind: 'scroll', position: 0 }]);
172
+ }
173
+ if (!command.target)
174
+ return ok([{ kind: 'closePanels' }, { kind: 'open', panel: 'thoughts' }]);
175
+ const seq = Number(command.target);
176
+ const transcript = controller.queries.window ?? controller.queries.record;
177
+ if (!Number.isSafeInteger(seq) || !transcript.thoughts.some(entry => entry.seq === seq)) {
178
+ throw new Error('Use /think <message sequence> for a loaded reasoning block');
179
+ }
180
+ return ok([{ kind: 'closePanels' }, { kind: 'toggleFold', seq }]);
181
+ }
182
+ case 'older': {
183
+ const accepted = await controller.actions.older(undefined, controller.queries.window ?? controller.queries.record);
184
+ return accepted ? ok([{ kind: 'closePanels' }, { kind: 'scrollBy', delta: 10 }]) : undefined;
185
+ }
186
+ case 'compact': {
187
+ const { value: text, cancelled: aborted } = await runCancellable(port, 'Compacting history…', signal => controller.actions.command('/compact', signal));
188
+ if (text === undefined)
189
+ return aborted ? cancelled() : undefined;
190
+ return ok([{ kind: 'closePanels' }, { kind: 'notice', text }]);
191
+ }
192
+ case 'cancel':
193
+ return await controller.actions.cancelTurn() ? ok([{ kind: 'closePanels' }]) : undefined;
194
+ case 'approval':
195
+ return await controller.actions.approve(command.allowed) ? ok([{ kind: 'closePanels' }]) : undefined;
196
+ case 'hostCommand': {
197
+ const { value: text, cancelled: aborted } = await runCancellable(port, 'Running command…', signal => controller.actions.command(command.line, signal));
198
+ if (text === undefined)
199
+ return aborted ? cancelled() : undefined;
200
+ return ok([{ kind: 'closePanels' }, { kind: 'notice', text }]);
201
+ }
202
+ case 'export': {
203
+ const { value: saved, cancelled: aborted } = await runCancellable(port, 'Exporting session log…', signal => controller.actions.exportLog(command.destination, signal));
204
+ if (saved === undefined)
205
+ return aborted ? cancelled() : undefined;
206
+ return ok([{ kind: 'closePanels' }, { kind: 'notice', text: `Saved session log: ${saved}` }]);
207
+ }
208
+ case 'exportHtml': {
209
+ const { value: saved, cancelled: aborted } = await runCancellable(port, 'Exporting loaded conversation…', signal => controller.actions.exportHtml(command.destination, signal));
210
+ if (saved === undefined)
211
+ return aborted ? cancelled() : undefined;
212
+ return ok([{ kind: 'closePanels' }, { kind: 'notice', text: `Saved loaded conversation: ${saved}` }]);
213
+ }
214
+ case 'coredump': {
215
+ // V8 serializes the heap synchronously, so the client stalls until the file is written.
216
+ let saved = '';
217
+ await port.run('Writing heap snapshot…', async () => { saved = controller.actions.heapSnapshot(command.tag); });
218
+ return ok([{ kind: 'closePanels' }, { kind: 'notice', text: `Heap snapshot saved: ${saved}` }]);
219
+ }
220
+ case 'answer': {
221
+ // The application completes the question waterfall itself, so a line that answers one never
222
+ // leaves the front end to call `answer` on its own.
223
+ const accepted = await controller.actions.answerQuestion({ custom: command.text });
224
+ return accepted ? ok([{ kind: 'closePanels' }]) : undefined;
225
+ }
226
+ case 'error': return refused(command.message);
227
+ case 'prompt': {
228
+ const accepted = await controller.actions.prompt(command.text);
229
+ return accepted ? ok([{ kind: 'closePanels' }, { kind: 'live' }, { kind: 'scroll', position: 0 }]) : undefined;
230
+ }
231
+ case 'handoff': {
232
+ if (!await controller.actions.handoff())
233
+ return undefined;
234
+ return ok([{ kind: 'closePanels' }, { kind: 'live' }, { kind: 'scroll', position: 0 },
235
+ { kind: 'notice', text: 'Handoff requested · local HANDOFF.md cleared' }]);
236
+ }
237
+ case 'loops':
238
+ // The record list is a composer surface the UI offers while the name is typed; a line that
239
+ // still reaches here has nobody to choose for it, so it is told to name one.
240
+ return refused(`Use /loop <name> · available: ${loopProtocolNames().join(', ')}`);
241
+ case 'loopAnswer': {
242
+ const progress = controller.queries.loop;
243
+ const interaction = progress?.interaction;
244
+ // The answer only means something to a judgment that asked for one; a host question is answered
245
+ // in its own dialog, and a rejected send retries on its own. Saying which is which beats a
246
+ // command that appears to do nothing.
247
+ if (progress === undefined || !progress.active || progress.phase !== 'needs-human' || interaction?.kind !== 'verdict') {
248
+ return refused(interaction === undefined
249
+ ? 'No loop is waiting for an answer'
250
+ : `This run is waiting for a ${interaction.kind}: ${interaction.text} · answer it, or /loop abort`);
251
+ }
252
+ const accepted = await controller.actions.answerLoop(command.text);
253
+ return accepted
254
+ ? ok([{ kind: 'closePanels' }, { kind: 'live' }, { kind: 'scroll', position: 0 },
255
+ { kind: 'notice', text: `Answer added · re-judging step ${progress.step}` }])
256
+ : undefined;
257
+ }
258
+ case 'loopStop': {
259
+ // Stopping is a control command, so it is answered from the application's own view of the run
260
+ // rather than refused when nothing runs; a no-op that says so beats silence.
261
+ const progress = controller.queries.loop;
262
+ if (progress === undefined || !progress.active)
263
+ return ok([{ kind: 'closePanels' }, { kind: 'notice', text: 'No loop is running' }]);
264
+ controller.actions.stopLoop();
265
+ return ok([{ kind: 'closePanels' }, { kind: 'live' }, { kind: 'scroll', position: 0 },
266
+ { kind: 'notice', text: `Loop stopped · ${progress.title}` }]);
267
+ }
268
+ case 'loop': {
269
+ // One command for every record: the name is looked up here, where the records are known, so
270
+ // the syntax layer never needs a table of protocols and a new record needs no code at all.
271
+ controller.traceNote('loop', { phase: 'command', name: command.name, interactive: port.interactive === true,
272
+ flags: Object.keys(command.options).filter(key => key !== 'vars'), vars: Object.keys(command.options.vars ?? {}) });
273
+ const declared = loopRecordVars(command.name);
274
+ if (declared === undefined) {
275
+ controller.traceNote('loop', { phase: 'rejected', name: command.name, reason: 'unknown-record' });
276
+ return refused(`Unknown loop record: ${command.name} · available: ${loopProtocolNames().join(', ')}`);
277
+ }
278
+ // A record variable only exists if the record declares it; retargeting is not a place to guess.
279
+ const vars = command.options.vars ?? {};
280
+ const unknown = Object.keys(vars).filter(name => !(name in declared));
281
+ if (unknown.length) {
282
+ const names = Object.keys(declared);
283
+ controller.traceNote('loop', { phase: 'rejected', name: command.name, reason: 'unknown-vars', vars: unknown });
284
+ return refused(`Unknown loop variable: ${unknown.join(', ')}`
285
+ + ` · ${command.name} accepts: ${names.length ? names.join(', ') : 'none'}`);
286
+ }
287
+ // Any value the operator already chose — a flag, or a variable the form confirmed — means the
288
+ // decision is made; only a bare known record leaves every default open for the form.
289
+ const decided = command.options.from !== undefined || command.options.to !== undefined
290
+ || command.options.score !== undefined || command.options.tries !== undefined
291
+ || Object.keys(vars).length > 0;
292
+ if (port.interactive === true && !decided) {
293
+ controller.traceNote('loop', { phase: 'form', name: command.name });
294
+ return ok([{ kind: 'closePanels' }, { kind: 'loop', loop: { name: command.name } }]);
295
+ }
296
+ const protocol = loopProtocolFor(command.name, controller.queries.forkedVerification, vars, controller.queries.selfScoring);
297
+ if (protocol === undefined)
298
+ return refused(LOOP_USAGE);
299
+ const limits = resolveLoop(protocol, command.options);
300
+ if (limits === undefined)
301
+ return refused(LOOP_USAGE);
302
+ if (!await controller.actions.startLoop(protocol, limits)) {
303
+ // `startLoop` already traced its own refusal; this turns the same fact into something the
304
+ // operator can read, instead of a form that appears to ignore Start. The form stays on screen
305
+ // so the values can be retried.
306
+ const why = controller.state.lastFailure
307
+ || (controller.state.online ? 'the host did not accept the request' : 'the client is offline');
308
+ controller.traceNote('loop', { phase: 'not-started', name: command.name, why: why.slice(0, 200) });
309
+ return refused(`Loop did not start: ${why}`);
310
+ }
311
+ // The run leaves a bar in the transcript where it started, so the loop is readable in place and
312
+ // the bar can open the verification session this run is using (`Controller.createVerifierSession`).
313
+ // A record that starts by verifying (`starts: verify`) already created that session inside
314
+ // `startLoop`, so the bar takes the newest one now and later checks re-point it through `link`.
315
+ const checking = controller.queries.sources.find(source => source.createdBy === 'verifier' && source.state === 'running');
316
+ controller.shell.note(`/loop ${command.name} ${limits.from}–${limits.to} · pass ${limits.score} · ≤${limits.tries} tries`, checking?.id);
317
+ return ok([{ kind: 'closePanels' }, { kind: 'live' }, { kind: 'scroll', position: 0 },
318
+ { kind: 'notice', text: `${protocol.title} started · steps ${limits.from}–${limits.to} · pass ${limits.score} · ≤${limits.tries} tries` }]);
319
+ }
320
+ default: return undefined;
321
+ }
322
+ }
@@ -1,7 +1,7 @@
1
1
  import { Client } from '../transport/client.ts';
2
- import { type ObjectValue } from '../transport/wire.ts';
2
+ import { type Json } from '../transport/wire.ts';
3
+ import { type HostEvent } from '../transport/events.ts';
3
4
  import type { HostAccess } from '../transport/host.ts';
4
- import { Telemetry } from '../session/telemetry.ts';
5
5
  import type { ConnectionView } from '../session/connection-view.ts';
6
6
  import type { ControllerStore } from '../state.ts';
7
7
  /** Connection inputs resolved by the CLI or a library consumer. */
@@ -22,28 +22,20 @@ export interface ConnectionListener {
22
22
  ready(): Promise<void>;
23
23
  /** The generation ended and its socket is closed. */
24
24
  ended(): Promise<void>;
25
- /** Deliver a host waterfall; return true when a domain retained it for an answer. */
26
- waterfall(frame: ObjectValue): boolean;
27
- /** The host cancelled a waterfall a domain retained. */
28
- cancelled(eventId: string): void;
29
- /** The host reported one session's running state. */
30
- status(sessionId: string, running: boolean): void;
31
- /** The host reported one session's error. */
32
- error(sessionId: unknown, error: unknown): void;
33
- /** Model catalogs may have changed. */
34
- invalidated(): void;
35
- /** A turn finished, so billing may refresh. */
36
- idle(): void;
25
+ /** Route one normalized host event, so a feature never calls another feature directly. */
26
+ event(event: HostEvent): boolean;
37
27
  }
38
- /** Owns reconnects, subscriptions and the telemetry generation. User commands remain single-attempt operations. */
28
+ /** Owns the physical connection: reconnects, subscriptions and generation lifecycle.
29
+ *
30
+ * Host running state, projections and interactions belong to the session domain; this class only
31
+ * decodes wire frames into `HostEvent` and hands them to the application, which routes them on.
32
+ * User commands remain single-attempt operations.
33
+ */
39
34
  export declare class ConnectionController implements HostAccess, ConnectionView {
40
35
  private readonly store;
41
36
  private readonly options;
42
37
  private readonly listener;
43
- telemetry: Telemetry;
44
38
  clientId: string;
45
- private readonly runningUpdates;
46
- private readonly observedRunningAt;
47
39
  private current;
48
40
  private readonly abort;
49
41
  private runTask;
@@ -61,20 +53,10 @@ export declare class ConnectionController implements HostAccess, ConnectionView
61
53
  online(): boolean;
62
54
  /** @returns The client lifetime signal. */
63
55
  signal(): AbortSignal;
64
- /** @returns The projection store of the current generation. */
65
- telemetryView(): Telemetry;
66
- /** @returns Cached host running state, or undefined when never reported. */
67
- runningFor(sessionId: string): boolean | undefined;
68
- /** @returns When this client first observed the session, for the elapsed-time fallback. */
69
- observedAt(sessionId: string): number | undefined;
70
- /** Record an observation start for a session this client just opened. */
71
- observe(sessionId: string): void;
72
56
  /** Fail the current generation, so the controller reopens a snapshot. */
73
57
  fail(error: Error): void;
74
58
  /** Answer one retained host waterfall through the event-result endpoint. */
75
- reply(frame: ObjectValue, outcome: ObjectValue): Promise<void>;
59
+ reply(eventId: string, outcome: Json): Promise<void>;
76
60
  /** Run one generation, then retry with bounded jittered backoff until stopped. */
77
61
  private run;
78
- /** Apply one `api-session/status` notification to the running maps and the session view. */
79
- private acceptStatus;
80
62
  }
@@ -2,21 +2,19 @@
2
2
  import { setTimeout as delay } from 'node:timers/promises';
3
3
  import { AuthenticationRequired } from "../transport/auth.js";
4
4
  import { HttpError, RemoteError } from "../transport/client.js";
5
- import { array, errorText, object, string } from "../transport/wire.js";
6
- import { Telemetry } from "../session/telemetry.js";
7
- /** Projection keys this client consumes; the host retains every other capability. */
8
- const RETAINED_PROJECTIONS = new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']);
9
- /** Host events that invalidate the model catalog. */
10
- const CATALOG_EVENTS = ['llm/adapters-updated', 'settings/document-updated', 'credentials/reference-updated'];
11
- /** Owns reconnects, subscriptions and the telemetry generation. User commands remain single-attempt operations. */
5
+ import { errorText, object, string } from "../transport/wire.js";
6
+ import { controlFrame, hostEvent } from "../transport/events.js";
7
+ /** Owns the physical connection: reconnects, subscriptions and generation lifecycle.
8
+ *
9
+ * Host running state, projections and interactions belong to the session domain; this class only
10
+ * decodes wire frames into `HostEvent` and hands them to the application, which routes them on.
11
+ * User commands remain single-attempt operations.
12
+ */
12
13
  export class ConnectionController {
13
14
  store;
14
15
  options;
15
16
  listener;
16
- telemetry = new Telemetry(RETAINED_PROJECTIONS);
17
17
  clientId = '';
18
- runningUpdates = new Map();
19
- observedRunningAt = new Map();
20
18
  current;
21
19
  abort = new AbortController();
22
20
  runTask;
@@ -47,28 +45,16 @@ export class ConnectionController {
47
45
  online() { return this.store.state.online; }
48
46
  /** @returns The client lifetime signal. */
49
47
  signal() { return this.abort.signal; }
50
- /** @returns The projection store of the current generation. */
51
- telemetryView() { return this.telemetry; }
52
- /** @returns Cached host running state, or undefined when never reported. */
53
- runningFor(sessionId) { return this.runningUpdates.get(sessionId); }
54
- /** @returns When this client first observed the session, for the elapsed-time fallback. */
55
- observedAt(sessionId) { return this.observedRunningAt.get(sessionId); }
56
- /** Record an observation start for a session this client just opened. */
57
- observe(sessionId) { if (!this.observedRunningAt.has(sessionId))
58
- this.observedRunningAt.set(sessionId, Date.now()); }
59
48
  /** Fail the current generation, so the controller reopens a snapshot. */
60
49
  fail(error) { this.generationFailed?.(error); }
61
50
  /** Answer one retained host waterfall through the event-result endpoint. */
62
- async reply(frame, outcome) {
63
- await this.require().call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
51
+ async reply(eventId, outcome) {
52
+ await this.require().call('$events/result', { clientId: this.clientId, eventId, outcome });
64
53
  }
65
54
  /** Run one generation, then retry with bounded jittered backoff until stopped. */
66
55
  async run() {
67
56
  let attempt = 0;
68
57
  while (!this.abort.signal.aborted) {
69
- this.runningUpdates.clear();
70
- this.observedRunningAt.clear();
71
- this.telemetry = new Telemetry(RETAINED_PROJECTIONS);
72
58
  this.listener.begin();
73
59
  const client = this.options.makeClient();
74
60
  this.current = client;
@@ -87,25 +73,17 @@ export class ConnectionController {
87
73
  this.clientId = string(frame.clientId);
88
74
  clearTimeout(timer);
89
75
  resolve();
76
+ return;
90
77
  }
91
- else if (frame.type === 'waterfall') {
92
- if (this.listener.waterfall(frame))
93
- return;
78
+ const event = hostEvent(frame);
79
+ if (!event)
80
+ return;
81
+ if (this.listener.event(event))
82
+ return;
83
+ // An unanswered waterfall would block the host's event chain, so it is always settled.
84
+ if ('eventId' in event) {
94
85
  void client.call('$events/result', { clientId: this.clientId,
95
- eventId: string(frame.eventId), outcome: { kind: 'next' } }).catch(error => fail(new Error(errorText(error))));
96
- }
97
- else if (frame.type === 'cancel') {
98
- this.listener.cancelled(string(frame.eventId));
99
- }
100
- else if (frame.type === 'emit' && frame.event === 'api-session/status') {
101
- this.acceptStatus(array(frame.args));
102
- }
103
- else if (frame.type === 'emit' && CATALOG_EVENTS.includes(String(frame.event))) {
104
- this.listener.invalidated();
105
- }
106
- else if (frame.type === 'emit' && frame.event === 'api-session/error') {
107
- const args = array(frame.args);
108
- this.listener.error(args[0], args[1]);
86
+ eventId: event.eventId, outcome: { kind: 'next' } }).catch(error => fail(new Error(errorText(error))));
109
87
  }
110
88
  },
111
89
  end: error => { clearTimeout(timer); const reason = error ?? new Error('Event stream ended'); reject(reason); fail(reason); },
@@ -117,15 +95,17 @@ export class ConnectionController {
117
95
  client.subscribe('session/control', {}, {
118
96
  item: value => {
119
97
  try {
120
- this.telemetry.accept(value);
98
+ this.listener.event({ kind: 'control', frame: controlFrame(value) });
121
99
  this.store.update({});
122
100
  clearTimeout(timer);
123
101
  resolve();
124
102
  }
125
103
  catch (error) {
104
+ // Live metrics are not worth the connection: a frame this client cannot decode is
105
+ // reported and skipped, because failing the generation would blind a running turn.
126
106
  clearTimeout(timer);
127
- reject(error);
128
- fail(new Error(errorText(error)));
107
+ this.store.update({ controlError: `Live metrics degraded: ${errorText(error)}` });
108
+ resolve();
129
109
  }
130
110
  },
131
111
  end: error => {
@@ -150,11 +130,11 @@ export class ConnectionController {
150
130
  }
151
131
  catch (error) {
152
132
  if (error instanceof AuthenticationRequired || error instanceof HttpError && [401, 403].includes(error.status)) {
153
- this.store.update({ error: `${errorText(error)}. Set DSH_TOKEN and restart to log in.`, status: 'Login required' });
133
+ this.store.update({ lastFailure: `${errorText(error)}. Set DSH_TOKEN and restart to log in.`, status: 'Login required' });
154
134
  return;
155
135
  }
156
136
  if (!this.abort.signal.aborted)
157
- this.store.update({ error: errorText(error), status: 'Reconnecting…' });
137
+ this.store.update({ lastFailure: errorText(error), status: 'Reconnecting…' });
158
138
  }
159
139
  finally {
160
140
  this.generationFailed = undefined;
@@ -173,18 +153,4 @@ export class ConnectionController {
173
153
  }
174
154
  }
175
155
  }
176
- /** Apply one `api-session/status` notification to the running maps and the session view. */
177
- acceptStatus(args) {
178
- const sessionId = string(args[0]);
179
- if (typeof args[1] !== 'boolean')
180
- throw new Error('Invalid session running state');
181
- if (args[1] && !this.runningUpdates.get(sessionId))
182
- this.observedRunningAt.set(sessionId, Date.now());
183
- if (!args[1])
184
- this.observedRunningAt.delete(sessionId);
185
- this.runningUpdates.set(sessionId, args[1]);
186
- this.listener.status(sessionId, args[1]);
187
- if (!args[1])
188
- this.listener.idle();
189
- }
190
156
  }