@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.
- package/README.i18n.yaml +2 -2
- package/README.md +30 -11
- package/README.zh.md +30 -11
- package/dist/cli/dsht.js +203 -18
- package/dist/cli/startup.d.ts +40 -0
- package/dist/cli/startup.js +295 -0
- package/dist/cli/trace-summary.d.ts +78 -0
- package/dist/cli/trace-summary.js +241 -0
- package/dist/cli/verifier.d.ts +60 -0
- package/dist/cli/verifier.js +242 -0
- package/dist/contracts.d.ts +344 -0
- package/dist/contracts.js +1 -0
- package/dist/controller/commands.d.ts +47 -0
- package/dist/controller/commands.js +322 -0
- package/dist/controller/connection.d.ts +11 -29
- package/dist/controller/connection.js +26 -60
- package/dist/controller/controller.d.ts +616 -166
- package/dist/controller/controller.js +1395 -146
- package/dist/controller/index.d.ts +8 -1
- package/dist/controller/index.js +5 -0
- package/dist/controller/loop-contract.d.ts +136 -0
- package/dist/controller/loop-contract.js +308 -0
- package/dist/controller/loop-prompts-schema.d.ts +56 -0
- package/dist/controller/loop-prompts-schema.js +144 -0
- package/dist/controller/loop-prompts.d.ts +55 -0
- package/dist/controller/loop-prompts.generated.d.ts +104 -0
- package/dist/controller/loop-prompts.generated.js +185 -0
- package/dist/controller/loop-prompts.js +104 -0
- package/dist/controller/loop-protocols.d.ts +39 -0
- package/dist/controller/loop-protocols.js +115 -0
- package/dist/controller/loop.d.ts +275 -0
- package/dist/controller/loop.js +378 -0
- package/dist/controller/prompts.d.ts +54 -0
- package/dist/controller/prompts.js +162 -0
- package/dist/controller/trace-log.d.ts +45 -0
- package/dist/controller/trace-log.js +144 -0
- package/dist/controller/verifier.d.ts +126 -0
- package/dist/controller/verifier.js +75 -0
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger.d.ts +0 -1
- package/dist/cost/ledger.js +0 -1
- package/dist/json.d.ts +18 -0
- package/dist/json.js +19 -0
- package/dist/references.d.ts +25 -0
- package/dist/references.js +26 -0
- package/dist/session/connection-view.d.ts +2 -11
- package/dist/session/controller.d.ts +73 -72
- package/dist/session/controller.js +185 -209
- package/dist/session/history.d.ts +6 -18
- package/dist/session/history.js +1 -24
- package/dist/session/index.d.ts +9 -4
- package/dist/session/index.js +7 -3
- package/dist/session/info.d.ts +25 -52
- package/dist/session/info.js +39 -25
- package/dist/session/markdown.js +1 -1
- package/dist/session/math.js +1 -1
- package/dist/session/mutation-gate.d.ts +51 -0
- package/dist/session/mutation-gate.js +73 -0
- package/dist/session/navigation.d.ts +2 -89
- package/dist/session/navigation.js +2 -129
- package/dist/session/peek.d.ts +38 -0
- package/dist/session/peek.js +103 -0
- package/dist/session/references.d.ts +2 -20
- package/dist/session/references.js +1 -26
- package/dist/session/runtime.d.ts +26 -0
- package/dist/session/runtime.js +28 -0
- package/dist/session/telemetry.d.ts +12 -13
- package/dist/session/telemetry.js +27 -58
- package/dist/session/transcript.d.ts +0 -6
- package/dist/session/transcript.js +2 -15
- package/dist/session/types.d.ts +25 -0
- package/dist/session/types.js +0 -1
- package/dist/session-title.d.ts +9 -0
- package/dist/session-title.js +21 -0
- package/dist/shell/controller.d.ts +31 -1
- package/dist/shell/controller.js +34 -2
- package/dist/shell/index.d.ts +3 -3
- package/dist/shell/index.js +2 -2
- package/dist/shell/runner.d.ts +10 -0
- package/dist/shell/runner.js +48 -9
- package/dist/slash/index.d.ts +10 -0
- package/dist/slash/index.js +7 -0
- package/dist/slash/parse.d.ts +166 -0
- package/dist/slash/parse.js +259 -0
- package/dist/slash/pipeline.d.ts +140 -0
- package/dist/slash/pipeline.js +115 -0
- package/dist/slash/registry.d.ts +88 -0
- package/dist/slash/registry.js +177 -0
- package/dist/state.d.ts +14 -4
- package/dist/state.js +3 -2
- package/dist/text.d.ts +28 -0
- package/dist/text.js +55 -0
- package/dist/transport/events.d.ts +104 -0
- package/dist/transport/events.js +149 -0
- package/dist/transport/wire.d.ts +9 -17
- package/dist/transport/wire.js +2 -27
- package/dist/ui/app.js +856 -441
- package/dist/ui/chat/header.js +1 -1
- package/dist/ui/chat/history-view.d.ts +1 -1
- package/dist/ui/chat/loop-status.d.ts +11 -0
- package/dist/ui/chat/loop-status.js +28 -0
- package/dist/ui/chat/navigation-model.d.ts +86 -0
- package/dist/ui/chat/navigation-model.js +107 -0
- package/dist/ui/chat/shell-view.d.ts +15 -2
- package/dist/ui/chat/shell-view.js +37 -3
- package/dist/ui/chat/status.d.ts +47 -3
- package/dist/ui/chat/status.js +65 -50
- package/dist/ui/chat/viewport.d.ts +1 -1
- package/dist/ui/dialogs/cost.d.ts +21 -4
- package/dist/ui/dialogs/cost.js +7 -12
- package/dist/ui/dialogs/index.d.ts +22 -5
- package/dist/ui/dialogs/index.js +19 -3
- package/dist/ui/dialogs/loop.d.ts +43 -0
- package/dist/ui/dialogs/loop.js +224 -0
- package/dist/ui/dialogs/peek.d.ts +25 -0
- package/dist/ui/dialogs/peek.js +35 -0
- package/dist/ui/dialogs/picker.d.ts +2 -0
- package/dist/ui/dialogs/picker.js +4 -2
- package/dist/ui/input/mouse.d.ts +12 -2
- package/dist/ui/input/mouse.js +20 -7
- package/dist/ui/input/references.d.ts +1 -1
- package/dist/ui/status/model.d.ts +7 -0
- package/dist/ui/status/model.js +5 -0
- package/dist/ui/theme/index.d.ts +1 -1
- package/package.json +6 -4
- package/dist/ui/commands/parse.d.ts +0 -104
- package/dist/ui/commands/parse.js +0 -135
- package/dist/ui/commands/registry.d.ts +0 -33
- 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
|
|
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
|
-
/**
|
|
26
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
63
|
-
await this.require().call('$events/result', { clientId: this.clientId, eventId
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
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({
|
|
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({
|
|
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
|
}
|