@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,78 @@
1
+ /** Reading a trace back as a few lines: what ran, what was refused, and what never finished.
2
+ *
3
+ * The trace is written for a machine (`slash.md` §7) and a reader only ever asks a handful of
4
+ * questions of it: which commands ran and how they ended, which session writes were serialized, what
5
+ * owned the client and for how long, how the loops and verifications went, and whether any span is
6
+ * still open. This module answers exactly those, from the lines alone, so it can be tested without a
7
+ * file and reused by a future `--json` consumer.
8
+ *
9
+ * It never re-derives a fact the trace did not record: an event it does not know is counted as
10
+ * skipped rather than guessed at.
11
+ */
12
+ /** One loop run as the trace describes it. */
13
+ export interface TraceLoopRun {
14
+ readonly runId: string;
15
+ readonly kind: string;
16
+ /** Steps the run sent, counted from its `sent` events. */
17
+ readonly sent: number;
18
+ /** Phase the run ended in, when it reached one. */
19
+ readonly result?: string;
20
+ /** Why it ended, when the end recorded a reason. */
21
+ readonly reason?: string;
22
+ }
23
+ /** Everything `dsht trace` reports about one trace file. */
24
+ export interface TraceSummary {
25
+ readonly path: string;
26
+ /** Events read, and lines that were not events (unparsable or foreign). */
27
+ readonly events: number;
28
+ readonly skipped: number;
29
+ /** First and last event timestamps, when the trace has any. */
30
+ readonly from?: string;
31
+ readonly to?: string;
32
+ /** Submissions: how they ended and which command kinds they were. */
33
+ readonly commands: {
34
+ readonly total: number;
35
+ readonly outcomes: Record<string, number>;
36
+ readonly kinds: Record<string, number>;
37
+ };
38
+ /** Session writes: total, by lane and by target session. */
39
+ readonly mutations: {
40
+ readonly total: number;
41
+ readonly lanes: Record<string, number>;
42
+ readonly sessions: Record<string, number>;
43
+ };
44
+ /** Foreground slots: total, cancelled, the longest one, and any left open. */
45
+ readonly operations: {
46
+ readonly total: number;
47
+ readonly cancelled: number;
48
+ readonly longestMs?: number;
49
+ readonly longest?: string;
50
+ readonly open: number;
51
+ };
52
+ readonly loops: readonly TraceLoopRun[];
53
+ /** Verification tasks: how many started, and how they concluded.
54
+ *
55
+ * `begin` is not derivable from the outcomes — a task whose process died mid-flight has neither —
56
+ * so both are reported and the formatter points out the gap.
57
+ */
58
+ readonly verifiers: {
59
+ readonly begin: number;
60
+ readonly verified: number;
61
+ readonly unavailable: number;
62
+ readonly cancelled: number;
63
+ readonly byClass: Record<string, number>;
64
+ };
65
+ /** Spans that began and never ended, oldest first. */
66
+ readonly anomalies: readonly string[];
67
+ }
68
+ /** Summarize one trace file's lines.
69
+ * @param lines - Raw trace lines, oldest first, header and blanks included.
70
+ * @param path - File the lines came from, for the report.
71
+ * @returns The summary a reader wants.
72
+ */
73
+ export declare function summarizeTrace(lines: readonly string[], path?: string): TraceSummary;
74
+ /** Render a summary as the lines `dsht trace` prints.
75
+ * @param summary - Result of `summarizeTrace`.
76
+ * @returns Plain lines, one fact per line, without a trailing newline.
77
+ */
78
+ export declare function formatTraceSummary(summary: TraceSummary): string[];
@@ -0,0 +1,241 @@
1
+ /** Reading a trace back as a few lines: what ran, what was refused, and what never finished.
2
+ *
3
+ * The trace is written for a machine (`slash.md` §7) and a reader only ever asks a handful of
4
+ * questions of it: which commands ran and how they ended, which session writes were serialized, what
5
+ * owned the client and for how long, how the loops and verifications went, and whether any span is
6
+ * still open. This module answers exactly those, from the lines alone, so it can be tested without a
7
+ * file and reused by a future `--json` consumer.
8
+ *
9
+ * It never re-derives a fact the trace did not record: an event it does not know is counted as
10
+ * skipped rather than guessed at.
11
+ */
12
+ /** Count one key in a tally. */
13
+ function count(tally, key) {
14
+ tally[key] = (tally[key] ?? 0) + 1;
15
+ }
16
+ /** Name one loop run in a report; a pre-`runId` trace has no identity to show. */
17
+ function label(runId) { return runId === '' ? '<no-run-id>' : runId; }
18
+ /** Read the identifier a span pairs on, or undefined when the event carries none. */
19
+ function spanId(record) {
20
+ for (const field of ['commandId', 'id', 'runId']) {
21
+ const value = record[field];
22
+ if (typeof value === 'string' || typeof value === 'number')
23
+ return String(value);
24
+ }
25
+ return undefined;
26
+ }
27
+ /** Summarize one trace file's lines.
28
+ * @param lines - Raw trace lines, oldest first, header and blanks included.
29
+ * @param path - File the lines came from, for the report.
30
+ * @returns The summary a reader wants.
31
+ */
32
+ export function summarizeTrace(lines, path = '') {
33
+ const outcomes = {};
34
+ const kinds = {};
35
+ const lanes = {};
36
+ const sessions = {};
37
+ const byClass = {};
38
+ const runs = new Map();
39
+ const openCommands = new Map();
40
+ const openOperations = new Map();
41
+ const openLoops = new Map();
42
+ const orphans = [];
43
+ const anomalies = [];
44
+ let events = 0;
45
+ let skipped = 0;
46
+ let from;
47
+ let to;
48
+ let commandEnds = 0;
49
+ let operations = 0;
50
+ let cancelled = 0;
51
+ let longestMs;
52
+ let longest;
53
+ let verifierBegins = 0;
54
+ let verified = 0;
55
+ let unavailable = 0;
56
+ let verifierCancelled = 0;
57
+ for (const line of lines) {
58
+ if (line === '' || line.startsWith('#'))
59
+ continue;
60
+ let record;
61
+ try {
62
+ record = JSON.parse(line);
63
+ }
64
+ catch {
65
+ skipped++;
66
+ continue;
67
+ }
68
+ if (typeof record.event !== 'string') {
69
+ skipped++;
70
+ continue;
71
+ }
72
+ events++;
73
+ const time = typeof record.time === 'string' ? record.time : undefined;
74
+ if (time !== undefined) {
75
+ from ??= time;
76
+ to = time;
77
+ }
78
+ const id = spanId(record);
79
+ switch (record.event) {
80
+ case 'command': {
81
+ if (record.phase === 'begin') {
82
+ if (id !== undefined)
83
+ openCommands.set(id, String(record.kind ?? 'unknown'));
84
+ break;
85
+ }
86
+ // A held line was accepted but not run; only a `begin`/`end` pair is an execution.
87
+ if (record.phase === 'queued')
88
+ break;
89
+ commandEnds++;
90
+ const paired = id !== undefined && openCommands.delete(id);
91
+ // A trace written before `command begin/end` has one phaseless event per line, whose fate is
92
+ // the old `accepted` flag; reading it as an end keeps an existing file useful, and a phaseless
93
+ // line is never reported as an orphan end.
94
+ if (record.phase === 'end' && id !== undefined && !paired) {
95
+ orphans.push(`command ${id} (${String(record.kind ?? 'unknown')}): end without begin`);
96
+ }
97
+ const outcome = typeof record.outcome === 'string' ? record.outcome : record.accepted === true ? 'ok' : 'rejected';
98
+ count(outcomes, outcome);
99
+ count(kinds, String(record.kind ?? 'unknown'));
100
+ break;
101
+ }
102
+ case 'mutation':
103
+ count(lanes, String(record.lane ?? 'unknown'));
104
+ count(sessions, String(record.session ?? 'unknown'));
105
+ break;
106
+ case 'foreground': {
107
+ if (record.phase === 'begin') {
108
+ operations++;
109
+ if (id !== undefined)
110
+ openOperations.set(id, { label: String(record.label ?? record.kind ?? ''), startedAt: time === undefined ? 0 : Date.parse(time) });
111
+ break;
112
+ }
113
+ if (id === undefined)
114
+ break;
115
+ const started = openOperations.get(id);
116
+ openOperations.delete(id);
117
+ if (started === undefined)
118
+ orphans.push(`foreground ${id}: end without begin`);
119
+ if (record.cancelled === true)
120
+ cancelled++;
121
+ if (started !== undefined && time !== undefined) {
122
+ const ms = Date.parse(time) - started.startedAt;
123
+ if (Number.isFinite(ms) && (longestMs === undefined || ms > longestMs)) {
124
+ longestMs = ms;
125
+ longest = started.label;
126
+ }
127
+ }
128
+ break;
129
+ }
130
+ case 'loop': {
131
+ // Events that describe a submission rather than a run (`command`, `form`, `rejected`) are not
132
+ // a run and must not invent one. A trace written before `runId` existed has one key for the
133
+ // run events it did record; they still pair, they just cannot be told apart per run.
134
+ const runId = typeof record.runId === 'string' ? record.runId : undefined;
135
+ const describesRun = runId !== undefined || record.phase === 'begin' || record.phase === 'sent' || record.phase === 'end';
136
+ if (!describesRun)
137
+ break;
138
+ const key = runId ?? '';
139
+ const run = runs.get(key) ?? { kind: 'unknown', sent: 0 };
140
+ if (typeof record.kind === 'string')
141
+ run.kind = record.kind;
142
+ if (record.phase === 'begin')
143
+ openLoops.set(key, run.kind);
144
+ if (record.phase === 'sent')
145
+ run.sent++;
146
+ if (record.phase === 'end') {
147
+ if (!openLoops.delete(key))
148
+ orphans.push(`loop ${label(key)} (${run.kind}): end without begin`);
149
+ run.result = String(record.result ?? 'unknown');
150
+ if (record.reason !== undefined)
151
+ run.reason = String(record.reason);
152
+ }
153
+ runs.set(key, run);
154
+ break;
155
+ }
156
+ case 'verify': {
157
+ if (record.phase === 'begin') {
158
+ verifierBegins++;
159
+ break;
160
+ }
161
+ if (record.phase === 'verified') {
162
+ verified++;
163
+ break;
164
+ }
165
+ if (record.phase === 'cancelled' || record.phase === 'abandoned') {
166
+ verifierCancelled++;
167
+ break;
168
+ }
169
+ if (record.phase === 'unavailable') {
170
+ unavailable++;
171
+ const match = /stderrClass (\w+)/.exec(String(record.reason ?? ''));
172
+ if (match?.[1] !== undefined)
173
+ count(byClass, match[1]);
174
+ }
175
+ break;
176
+ }
177
+ default: break;
178
+ }
179
+ }
180
+ for (const [id, kind] of openCommands)
181
+ anomalies.push(`command ${id} (${kind}): begin without end`);
182
+ for (const id of openOperations.keys())
183
+ anomalies.push(`foreground ${id}: begin without end`);
184
+ for (const [runId, kind] of openLoops)
185
+ anomalies.push(`loop ${label(runId)} (${kind}): begin without end`);
186
+ // An end whose begin is missing means the window dropped it or the writer is broken; either way the
187
+ // reader has to see it, because the pairing is what makes the rest of the file trustworthy.
188
+ anomalies.push(...orphans);
189
+ return {
190
+ path, events, skipped,
191
+ ...(from === undefined ? {} : { from }),
192
+ ...(to === undefined ? {} : { to }),
193
+ commands: { total: commandEnds, outcomes, kinds },
194
+ mutations: { total: Object.values(lanes).reduce((sum, value) => sum + value, 0), lanes, sessions },
195
+ operations: { total: operations, cancelled, ...(longestMs === undefined ? {} : { longestMs, longest }), open: openOperations.size },
196
+ loops: [...runs.entries()].map(([runId, run]) => ({ runId, ...run })),
197
+ verifiers: { begin: verifierBegins, verified, unavailable, cancelled: verifierCancelled, byClass },
198
+ anomalies,
199
+ };
200
+ }
201
+ /** Render one tally as `key value · key value`, widest count first. */
202
+ function tally(text) {
203
+ return Object.entries(text).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
204
+ .map(([key, value]) => `${key} ${value}`).join(' · ');
205
+ }
206
+ /** Render a summary as the lines `dsht trace` prints.
207
+ * @param summary - Result of `summarizeTrace`.
208
+ * @returns Plain lines, one fact per line, without a trailing newline.
209
+ */
210
+ export function formatTraceSummary(summary) {
211
+ const lines = [`trace · ${summary.path}`, `events ${summary.events}${summary.skipped === 0 ? '' : ` (+${summary.skipped} unreadable)`}`
212
+ + `${summary.from === undefined ? '' : ` · ${summary.from} → ${summary.to}`}`];
213
+ if (summary.commands.total > 0) {
214
+ lines.push(`commands ${summary.commands.total}: ${tally(summary.commands.outcomes)}`);
215
+ lines.push(` kinds: ${tally(summary.commands.kinds)}`);
216
+ }
217
+ if (summary.mutations.total > 0) {
218
+ lines.push(`session writes ${summary.mutations.total}: ${tally(summary.mutations.lanes)}`);
219
+ lines.push(` by session: ${tally(summary.mutations.sessions)}`);
220
+ }
221
+ const { longestMs, longest } = summary.operations;
222
+ if (summary.operations.total > 0) {
223
+ const slowest = longestMs === undefined ? '' : ` · longest ${(longestMs / 1000).toFixed(1)}s (${longest})`;
224
+ lines.push(`foreground ${summary.operations.total}: cancelled ${summary.operations.cancelled}${slowest}`);
225
+ }
226
+ for (const run of summary.loops) {
227
+ const end = run.result === undefined ? 'unfinished' : `${run.result}${run.reason === undefined ? '' : ` (${run.reason})`}`;
228
+ lines.push(`loop ${label(run.runId)} · ${run.kind} · sent ${run.sent} → ${end}`);
229
+ }
230
+ const { begin, verified, unavailable, cancelled: verifierCancelled, byClass } = summary.verifiers;
231
+ if (begin > 0 || verified + unavailable + verifierCancelled > 0) {
232
+ const classes = Object.keys(byClass).length === 0 ? '' : ` (${tally(byClass)})`;
233
+ const concluded = verified + unavailable + verifierCancelled;
234
+ lines.push(`verifiers: begin ${begin} · verified ${verified} · unavailable ${unavailable}${classes}`
235
+ + ` · cancelled ${verifierCancelled}`
236
+ + (begin > concluded ? ` · ${begin - concluded} without a conclusion` : ''));
237
+ }
238
+ for (const anomaly of summary.anomalies)
239
+ lines.push(`anomaly · ${anomaly}`);
240
+ return lines;
241
+ }
@@ -0,0 +1,60 @@
1
+ import { runProcess } from '../shell/index.ts';
2
+ import type { VerifierOutcome, VerifierPort, VerifierRequest } from '../controller/verifier.ts';
3
+ /** What a child's error output says about why it failed, without carrying the output itself.
4
+ *
5
+ * A reason travels into the progress line, the verdict summary and the trace, and that log may be
6
+ * pasted into a report; the child's own words can name paths, projects or credentials. The class is
7
+ * the actionable half: `auth` and `host` are worth retrying differently, `config` never is.
8
+ */
9
+ export type VerifierStderrClass = 'auth' | 'host' | 'config' | 'unknown';
10
+ /** Classify a child's captured error output.
11
+ * @param text - Everything the child wrote to stderr, bounded by the caller.
12
+ * @returns The most actionable class the output matches.
13
+ */
14
+ export declare function classifyVerifierOutput(text: string): VerifierStderrClass;
15
+ /** Name the fault the child reported about itself, when this client wrote that line.
16
+ * @param text - Everything the child wrote to stderr.
17
+ * @returns The fault as one clause, or undefined when nothing recognized is there.
18
+ */
19
+ export declare function childFault(text: string): string | undefined;
20
+ /** Everything a forked verification needs from the client that owns it. */
21
+ export interface ProcessVerifierOptions {
22
+ /** Program and arguments that start this client again: exec path, exec arguments, entry script. */
23
+ command: readonly string[];
24
+ /** Host URL as the operator gave it, so the child authenticates the same way. */
25
+ url: string;
26
+ /** Cookie directory the parent used, when it was explicit. */
27
+ authDir?: string;
28
+ /** Working directory for the child. */
29
+ cwd: string;
30
+ /** Environment for the child; it inherits the parent's, including DSH_TOKEN. */
31
+ env: NodeJS.ProcessEnv;
32
+ /** How long one verification may run. */
33
+ timeoutMs: number;
34
+ /** Creates the verifier's own session and names it; the controller owns the connection. */
35
+ createSession(title: string): Promise<string | undefined>;
36
+ /** Stops that session's turn on the host; without it a killed child leaves the agent running. */
37
+ cancelSession?(sessionId: string): Promise<void>;
38
+ /** Receives the child's output lines, so the parent can log why a verdict is missing. */
39
+ onLine(line: string, stream: 'stdout' | 'stderr'): void;
40
+ /** Quote the child's own (sanitized) words in a reason; off by default because that text leaves. */
41
+ verbose?: boolean;
42
+ /** Process runner, injectable so the logic can be tested without spawning a client. */
43
+ run?: typeof runProcess;
44
+ }
45
+ /** Run one verifier as a child client and read the file it leaves behind.
46
+ *
47
+ * The child is the same program as the parent but a different session, so it starts from an empty
48
+ * context: the prompt, the standard and the artifact on disk are all it knows.
49
+ */
50
+ export declare class ProcessVerifier implements VerifierPort {
51
+ private readonly options;
52
+ /** This client forks itself, so the harness that judges is this one. */
53
+ readonly name = "dsht";
54
+ constructor(options: ProcessVerifierOptions);
55
+ /** @param request - Round identity, prompt and verdict path.
56
+ * @param signal - Cancels the child; the run also stops at its own deadline.
57
+ * @returns The verdict read back from the file, or a note explaining its absence.
58
+ */
59
+ verify(request: VerifierRequest, signal: AbortSignal): Promise<VerifierOutcome>;
60
+ }
@@ -0,0 +1,242 @@
1
+ /** Verification by a child `dsht` process: its own session, its own context, a file back.
2
+ *
3
+ * The reviewed session cannot judge itself fairly, and an in-host subagent still shares the process
4
+ * that produced the work. This implementation forks the client itself, points the child at a fresh
5
+ * named session, and reads the verdict file that session writes — so the verdict arrives through a
6
+ * file written on disk rather than through the reviewer's own reply.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { dirname, join } from 'node:path';
10
+ import { runProcess } from "../shell/index.js";
11
+ import { ensureDirectory, readText, removeFile } from "../storage/index.js";
12
+ import { parseVerdict } from "../controller/loop-contract.js";
13
+ import { sanitizeTraceText } from "../text.js";
14
+ import { parseNeedsHumanLine } from "../controller/verifier.js";
15
+ /** How long a cancellation request may wait for the host's answer before local cleanup proceeds. */
16
+ const CANCEL_CONFIRM_MS = 1_000;
17
+ /** How much of the child's error output is kept to classify it; the text itself is never reported. */
18
+ const VERIFIER_STDERR_LIMIT = 4_096;
19
+ /** Classify a child's captured error output.
20
+ * @param text - Everything the child wrote to stderr, bounded by the caller.
21
+ * @returns The most actionable class the output matches.
22
+ */
23
+ export function classifyVerifierOutput(text) {
24
+ if (/unauthor|forbidden|401|403|credential|log ?in|token/i.test(text))
25
+ return 'auth';
26
+ if (/econnrefused|econnreset|enotfound|fetch failed|timed? ?out|socket|50[234]|unreachable/i.test(text))
27
+ return 'host';
28
+ if (/config|yaml|invalid|missing|enoent|no such file|usage|unknown option/i.test(text))
29
+ return 'config';
30
+ return 'unknown';
31
+ }
32
+ /** The faults this client's own child reports on stderr, named so a missing verdict explains itself.
33
+ *
34
+ * These sentences are written by `runStartup` in this same program, so naming them leaks nothing and
35
+ * needs no model text: the alternative is the keyword class below, which reads our own diagnosis as
36
+ * `unknown`. Anything else the child writes still goes through that class.
37
+ */
38
+ const CHILD_FAULTS = [
39
+ { pattern: /no parsable verdict in the reply/i, note: 'no JSON verdict in the reply' },
40
+ { pattern: /no reply was committed after the prompt/i, note: 'no reply committed after the turn' },
41
+ ];
42
+ /** Name the fault the child reported about itself, when this client wrote that line.
43
+ * @param text - Everything the child wrote to stderr.
44
+ * @returns The fault as one clause, or undefined when nothing recognized is there.
45
+ */
46
+ export function childFault(text) {
47
+ return CHILD_FAULTS.find(fault => fault.pattern.test(text))?.note;
48
+ }
49
+ /** Content fingerprint of a workspace file, or undefined when it cannot be read from here.
50
+ *
51
+ * A remote host's workspace is not on this filesystem, so an unreadable artifact means the check
52
+ * cannot be made — which is reported as a boundary rather than treated as tampering.
53
+ * @param path - Absolute path of the file.
54
+ * @returns SHA-256 of its text, or undefined when it is not readable.
55
+ */
56
+ async function fingerprint(path) {
57
+ const text = await readText(path);
58
+ return text === undefined ? undefined : createHash('sha256').update(text).digest('hex');
59
+ }
60
+ /** Run one verifier as a child client and read the file it leaves behind.
61
+ *
62
+ * The child is the same program as the parent but a different session, so it starts from an empty
63
+ * context: the prompt, the standard and the artifact on disk are all it knows.
64
+ */
65
+ export class ProcessVerifier {
66
+ options;
67
+ /** This client forks itself, so the harness that judges is this one. */
68
+ name = 'dsht';
69
+ constructor(options) {
70
+ this.options = options;
71
+ }
72
+ /** @param request - Round identity, prompt and verdict path.
73
+ * @param signal - Cancels the child; the run also stops at its own deadline.
74
+ * @returns The verdict read back from the file, or a note explaining its absence.
75
+ */
76
+ async verify(request, signal) {
77
+ const run = this.options.run ?? runProcess;
78
+ const file = request.file;
79
+ // Cancelled before anything happened: no session, no child, no verdict file.
80
+ if (signal.aborted)
81
+ return { type: 'cancelled' };
82
+ await ensureDirectory(dirname(file));
83
+ // A verdict left by an earlier run must never be read as this one's.
84
+ await removeFile(file);
85
+ // "Do not modify the artifact" is an instruction until it can be checked: fingerprint it when
86
+ // the workspace this request declares is readable from here, and let a changed artifact void the
87
+ // verdict. Which file that is comes from the request, never from this process's own directory.
88
+ const artifactPath = request.artifact === undefined ? undefined : join(request.workspace, request.artifact);
89
+ const before = artifactPath === undefined ? undefined : await fingerprint(artifactPath);
90
+ const controller = new AbortController();
91
+ let sessionId;
92
+ let cancelled = false;
93
+ let timedOut = false;
94
+ /** Settles when the host answered the cancel request, so the outcome can say if it was confirmed. */
95
+ let confirmation;
96
+ let confirmed;
97
+ // The host owns the turn, so a killed child proves nothing about it. Cancellation is therefore a
98
+ // request with a short, bounded wait: local cleanup never blocks on a host that never answers.
99
+ const requestCancel = (id) => {
100
+ const cancel = this.options.cancelSession;
101
+ if (cancel === undefined || confirmation !== undefined)
102
+ return;
103
+ confirmation = Promise.resolve(cancel(id)).then(() => { confirmed = true; }, () => { confirmed = false; });
104
+ };
105
+ const stop = () => {
106
+ if (sessionId !== undefined)
107
+ requestCancel(sessionId);
108
+ controller.abort();
109
+ };
110
+ const onAbort = () => { cancelled = true; stop(); };
111
+ const onTimeout = () => { timedOut = true; stop(); };
112
+ const timer = setTimeout(onTimeout, this.options.timeoutMs);
113
+ // Registered before the session exists, so an abort during creation cannot slip past the listener.
114
+ signal.addEventListener('abort', onAbort, { once: true });
115
+ if (signal.aborted)
116
+ onAbort();
117
+ /** How the remote cancellation ended, and whether the remote turn is known to be stopped.
118
+ *
119
+ * `stopped: false` is what forbids a retry: starting a second remote task while the first may
120
+ * still be running doubles the cost and the side effects.
121
+ */
122
+ const cancelNote = async () => {
123
+ if (confirmation === undefined) {
124
+ return this.options.cancelSession === undefined
125
+ ? { note: 'host cannot cancel', stopped: false }
126
+ : { note: 'no verifier session to cancel', stopped: true };
127
+ }
128
+ const settled = await Promise.race([
129
+ confirmation.then(() => true),
130
+ new Promise(resolve => { const wait = setTimeout(() => resolve(false), CANCEL_CONFIRM_MS); wait.unref(); }),
131
+ ]);
132
+ if (!settled)
133
+ return { note: `remote cancel unconfirmed after ${CANCEL_CONFIRM_MS} ms`, stopped: false };
134
+ return confirmed === true
135
+ ? { note: 'remote cancel confirmed', stopped: true }
136
+ : { note: 'remote cancel rejected', stopped: false };
137
+ };
138
+ try {
139
+ const created = await this.options.createSession(request.title);
140
+ if (created === undefined)
141
+ return { type: 'unavailable', reason: 'could not create a verifier session' };
142
+ sessionId = created;
143
+ if (cancelled || timedOut) {
144
+ // The abort or the deadline landed while the session was being created: cancel what was just
145
+ // made and never spawn a child that would send a prompt nobody is waiting for.
146
+ requestCancel(created);
147
+ const { note, stopped } = await cancelNote();
148
+ return cancelled
149
+ ? { type: 'cancelled' }
150
+ : { type: 'unavailable', reason: `verifier timed out after ${this.options.timeoutMs} ms · ${note}`, sessionId,
151
+ ...(stopped ? {} : { retryable: false }) };
152
+ }
153
+ const [program, ...leading] = this.options.command;
154
+ // A missing entry is a configuration error, not an outage: retrying cannot fix it.
155
+ if (program === undefined)
156
+ return { type: 'unavailable', reason: 'no client entry to fork', retryable: false };
157
+ const args = [...leading,
158
+ '--url', this.options.url,
159
+ ...(this.options.authDir === undefined ? [] : ['--auth-dir', this.options.authDir]),
160
+ '--session', created,
161
+ '--prompt', request.prompt,
162
+ // The child judges and this client owns the protocol file, so the verdict is written by the
163
+ // child from its own reply rather than by the model choosing a path and opening a file.
164
+ '--verdict', file, '--verdict-identity', request.verificationId,
165
+ '--wait', '--headless', '--no-memory-log'];
166
+ // The child reports an interaction it cannot answer on the line below; the same parser keeps
167
+ // both sides of the contract together, and every other line is passed through untouched.
168
+ let human;
169
+ // The child's output is kept only to classify why it failed: `stderrText` for the class, and the
170
+ // last line for the operator who explicitly asked for text (`--trace-verbose`). Neither is
171
+ // reported by default, because a reason reaches the progress line and the trace, and that log
172
+ // may be pasted into a report.
173
+ let stderrText = '';
174
+ let lastLine = '';
175
+ const onLine = (line, stream) => {
176
+ human ??= parseNeedsHumanLine(line);
177
+ const trimmed = line.trim();
178
+ if (trimmed !== '') {
179
+ lastLine = trimmed;
180
+ if (stream === 'stderr')
181
+ stderrText = `${stderrText}${trimmed}\n`.slice(-VERIFIER_STDERR_LIMIT);
182
+ }
183
+ this.options.onLine(line, stream);
184
+ };
185
+ /** A missing or unusable verdict stated as facts, with the child's words only on request. */
186
+ const reasonFor = (lead) => {
187
+ // What this client's own child said about the failure beats the keyword class: "no JSON in the
188
+ // reply" and "no reply committed" are different faults with different next steps, and both used
189
+ // to be reported as `stderrClass unknown`. A child that said nothing gets no class at all,
190
+ // because "unknown" would be noise on the ordinary "exited without writing a file" case.
191
+ const fault = childFault(stderrText);
192
+ const base = fault !== undefined ? `${lead} · ${fault}`
193
+ : stderrText === '' ? lead : `${lead} · stderrClass ${classifyVerifierOutput(stderrText)}`;
194
+ return this.options.verbose === true && lastLine !== '' ? `${base} · ${sanitizeTraceText(lastLine)}` : base;
195
+ };
196
+ const exit = await run(program, args, {
197
+ cwd: this.options.cwd, env: this.options.env, signal: controller.signal, onLine,
198
+ });
199
+ // A cancelled review decides nothing, and is not a verifier outage either.
200
+ if (cancelled)
201
+ return { type: 'cancelled' };
202
+ // The child stopped because the host wants a human: cancel the turn it left waiting (bounded,
203
+ // reported), and hand the request to the caller instead of retrying into the same block.
204
+ if (human !== undefined) {
205
+ requestCancel(created);
206
+ await cancelNote();
207
+ return { type: 'needs-human', request: human, sessionId };
208
+ }
209
+ // A task that ran out of time is abandoned: a file that appeared as it died must not decide
210
+ // the attempt, or a killed verifier could still pass the round. When the host never confirmed
211
+ // the cancel, a retry could overlap a turn that is still running, so it is not offered one.
212
+ if (timedOut) {
213
+ const { note, stopped } = await cancelNote();
214
+ return { type: 'unavailable', reason: `verifier timed out after ${this.options.timeoutMs} ms · ${note}`, sessionId,
215
+ ...(stopped ? {} : { retryable: false }) };
216
+ }
217
+ const text = await readText(file);
218
+ if (text === undefined) {
219
+ const why = exit.code === null ? `signal ${exit.signal ?? 'unknown'}` : `exit ${exit.code}`;
220
+ return { type: 'unavailable', reason: reasonFor(`verifier wrote no verdict (${why})`), sessionId };
221
+ }
222
+ if (before !== undefined) {
223
+ const after = artifactPath === undefined ? undefined : await fingerprint(artifactPath);
224
+ // Which process wrote it is not knowable here, so the reason states the fact rather than an
225
+ // attribution: the file this verdict is about is not the file that was judged.
226
+ if (after !== before) {
227
+ return { type: 'unavailable', sessionId,
228
+ reason: `reviewed artifact changed during verification (${request.artifact} · ${before.slice(0, 8)} → ${after?.slice(0, 8) ?? 'unreadable'})` };
229
+ }
230
+ }
231
+ const result = parseVerdict(text, { verificationId: request.verificationId,
232
+ kind: request.kind, step: request.step, attempt: request.attempt });
233
+ if (result === undefined)
234
+ return { type: 'unavailable', reason: reasonFor('verifier verdict was unusable'), sessionId };
235
+ return { type: 'verified', result, sessionId };
236
+ }
237
+ finally {
238
+ clearTimeout(timer);
239
+ signal.removeEventListener('abort', onAbort);
240
+ }
241
+ }
242
+ }