@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
package/dist/cli/dsht.js CHANGED
@@ -6,62 +6,123 @@ import { join } from 'node:path';
6
6
  import { CostLedger, loadPrices } from "../cost/index.js";
7
7
  import { parseArgs } from 'node:util';
8
8
  import { mount } from "../ui/mount.js";
9
- import { ensureDirectory } from "../storage/index.js";
10
- import { sessionLabel } from "../session/navigation.js";
9
+ import { ensureDirectory, readText } from "../storage/index.js";
10
+ import { runStartup } from "./startup.js";
11
+ import { sessionLabel } from "../session-title.js";
11
12
  import { CookieStore, login } from "../transport/auth.js";
12
13
  import { Client } from "../transport/client.js";
14
+ import { fileURLToPath } from 'node:url';
13
15
  import { historyLimits } from "../session/memory.js";
16
+ import { ProcessVerifier } from "./verifier.js";
14
17
  import { Controller } from "../controller/controller.js";
15
18
  import { endpoint } from "../transport/endpoint.js";
16
- import { errorText, safeText, string } from "../transport/wire.js";
17
- const HELP = `Usage: dsht [options] [list workspaces|list sessions]
19
+ import { errorText, object, string } from "../transport/wire.js";
20
+ import { formatTraceSummary, summarizeTrace } from "./trace-summary.js";
21
+ import { safeText } from "../text.js";
22
+ const HELP = `Usage: dsht [options] [list workspaces|list sessions|trace]
18
23
 
19
24
  With no command, choose a workspace and session interactively.
20
25
 
21
26
  --url <url> Host URL, or the dsh web URL with ?token= (DSH_URL)
22
27
  --workspace <id> Filter list sessions by workspace
23
- --session <id> Open a session directly
28
+ --session <id|new> Open a session directly, or create one
29
+ --ws <id|name|path> Select this workspace at startup (default: this directory)
30
+ --command <line> Run this slash command once the session is ready (repeatable)
31
+ --prompt <text> Send this plain prompt once the session is ready
32
+ --wait With --headless, exit when the sent prompt's turn has finished
33
+ --verdict <path> With --prompt/--wait, write the reply's verdict to this file
34
+ --verdict-identity <id> <runId>/<kind>/<step>/<attempt>/<seq> the verdict must declare
35
+ --headless Run --command without the terminal interface, then exit
36
+ --deadline <minutes> Stop the whole loop after this many minutes (DSHT_LOOP_DEADLINE)
24
37
  --auth-dir <path> Private cookie directory (or DSHT_AUTH_DIR)
25
38
  --history-records <n> Soft history record limit (default 2000)
26
39
  --history-mb <n> Soft history payload budget in MiB (default 16)
27
40
  --memory-log <path> Append runtime memory samples; a failing log stops itself
28
41
  --no-memory-log Disable the runtime memory log (default: enabled)
42
+ --trace <path> Append connection/screen/selection events (default: <state>/trace.log)
43
+ With the trace command, read that file instead of appending to it
44
+ --no-trace Disable the transition trace
45
+ --trace-verbose Quote sanitized child output in verifier failure reasons
29
46
  --no-shell Disable ! local commands (DSHT_NO_SHELL=1)
30
- --json Print machine-readable list output
47
+ --json Print machine-readable list or trace output
48
+ --version Print the package version and exit
31
49
  --help Show this help
32
50
 
33
51
  The default host is http://127.0.0.1:3080.
34
52
  First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
35
53
  Cookies are saved per server origin and reused on later starts. Tokens are never saved.
36
54
  /cost shows the session and today CNY estimates.
55
+ /prompt lists saved shortcut prompts; /prompt TEXT saves one in <state>/prompts.json.
37
56
  !command runs on this machine, not on the host, and prints its output in the transcript.
38
57
  DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
39
58
  The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
59
+ The transition trace defaults to <state>/trace.log; DSHT_TRACE sets another path or 'off'.
40
60
  prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
41
61
  history with the table loaded then, so an edited table reaches past requests on the next scan.
42
62
  Examples:
43
63
  npx @itookit/dsht
44
64
  dsht list workspaces --json
45
65
  dsht list sessions --workspace <id> --json
66
+ dsht trace --json
46
67
  `;
68
+ /** State root this client reads and writes logs under, honouring the same overrides as the client. */
69
+ function stateRoot() {
70
+ return process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht');
71
+ }
72
+ /** Read one trace file back as a few lines of facts.
73
+ *
74
+ * Needs no host and no credentials: the trace is the client's own record of what it did, and reading
75
+ * it is the whole point of having written it.
76
+ * @param requested - `--trace` value, when given.
77
+ * @param json - Print the summary as JSON instead of lines.
78
+ */
79
+ async function printTrace(requested, json) {
80
+ const path = requested ?? process.env.DSHT_TRACE ?? join(stateRoot(), 'trace.log');
81
+ const text = await readText(path);
82
+ if (text === undefined) {
83
+ process.stdout.write(`No trace at ${path}\n`);
84
+ return;
85
+ }
86
+ const summary = summarizeTrace(text.split('\n').filter(line => line !== ''), path);
87
+ process.stdout.write(json ? `${JSON.stringify(summary, null, 2)}\n` : `${formatTraceSummary(summary).join('\n')}\n`);
88
+ }
47
89
  async function main() {
48
90
  const { values, positionals } = parseArgs({ allowPositionals: true, options: {
49
91
  url: { type: 'string', default: process.env.DSH_URL ?? 'http://127.0.0.1:3080' },
50
92
  'history-records': { type: 'string' }, 'history-mb': { type: 'string' },
51
- workspace: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' },
93
+ workspace: { type: 'string' }, ws: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' }, version: { type: 'boolean' },
94
+ command: { type: 'string', multiple: true }, prompt: { type: 'string' }, wait: { type: 'boolean' },
95
+ verdict: { type: 'string' }, 'verdict-identity': { type: 'string' }, headless: { type: 'boolean' },
96
+ deadline: { type: 'string' },
52
97
  'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' }, 'no-shell': { type: 'boolean' },
98
+ trace: { type: 'string' }, 'no-trace': { type: 'boolean' }, 'trace-verbose': { type: 'boolean' },
53
99
  } });
54
100
  if (values.help) {
55
101
  process.stdout.write(HELP);
56
102
  return;
57
103
  }
104
+ // The version is read from the manifest rather than repeated here, so a release never has to edit
105
+ // a string in this file; like `--help` it needs no host, no credentials and no terminal.
106
+ if (values.version) {
107
+ process.stdout.write(`${await packageVersion()}\n`);
108
+ return;
109
+ }
58
110
  const list = positionals[0] === 'list' && ['workspaces', 'sessions'].includes(positionals[1] ?? '') && positionals.length === 2;
59
- if (positionals.length && !list)
111
+ const trace = positionals[0] === 'trace' && positionals.length === 1;
112
+ if (positionals.length && !list && !trace)
60
113
  throw new Error('Unknown command. Use --help.');
61
- if (!list && (values.json || values.workspace))
62
- throw new Error('--json and --workspace apply to list commands');
63
- if (list && values.session)
114
+ if (!list && !trace && (values.json || values.workspace))
115
+ throw new Error('--json and --workspace apply to list or trace commands');
116
+ if ((list || trace) && values.session)
64
117
  throw new Error('--session applies to interactive mode');
118
+ if ((list || trace) && (values.ws || values.command?.length || values.prompt !== undefined || values.wait || values.headless || values.deadline)) {
119
+ throw new Error('--ws, --command, --prompt, --wait, --deadline and --headless apply to interactive mode');
120
+ }
121
+ // Reading a trace needs no host, no credentials and no terminal, so it runs before any of them.
122
+ if (trace) {
123
+ await printTrace(values.trace, values.json === true);
124
+ return;
125
+ }
65
126
  const limits = historyLimits(values['history-records'], values['history-mb']);
66
127
  const { url, token } = endpoint(values.url, process.env.DSH_TOKEN);
67
128
  const store = new CookieStore(values['auth-dir']);
@@ -93,14 +154,68 @@ async function main() {
93
154
  const costDirectory = join(stateRoot, 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
94
155
  const costs = new CostLedger(prices, costDirectory, custom);
95
156
  await costs.load();
96
- if (!process.stdin.isTTY || !process.stdout.isTTY)
97
- throw new Error('Interactive mode requires a terminal. Use list workspaces or list sessions for scripts.');
157
+ if (!values.headless && (!process.stdin.isTTY || !process.stdout.isTTY)) {
158
+ throw new Error('Interactive mode requires a terminal. Use --headless or list workspaces/list sessions for scripts.');
159
+ }
98
160
  const shellEnabled = !values['no-shell'] && process.env.DSHT_NO_SHELL !== '1';
99
- const controller = new Controller(url, token, values.session, undefined, client => login(client, token, store), costs, limits, memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']), undefined, shellEnabled);
161
+ const localDirectory = process.cwd();
162
+ // A scored review can delegate each round's verdict to a child client, which needs no shared state
163
+ // with this one: it is handed a session and a prompt, and answers through a file.
164
+ const verifier = process.env.DSHT_NO_VERIFY === '1' ? undefined : new ProcessVerifier({
165
+ command: [process.execPath, ...process.execArgv, process.argv[1] ?? fileURLToPath(import.meta.url)],
166
+ url: values.url,
167
+ ...(values['auth-dir'] === undefined ? {} : { authDir: values['auth-dir'] }),
168
+ cwd: localDirectory, env: process.env,
169
+ timeoutMs: verifyTimeoutMs(process.env.DSHT_VERIFY_TIMEOUT_MS),
170
+ createSession: (title) => controller.actions.createVerifierSession(title),
171
+ cancelSession: async (sessionId) => { await controller.actions.cancelVerifierSession(sessionId); },
172
+ onLine: line => { if (values.headless)
173
+ log(line); },
174
+ // Off by default: a verifier reason reaches the progress line and the trace, and that log may be
175
+ // pasted into a report, so the child's own words are quoted only when the operator asks.
176
+ verbose: values['trace-verbose'] === true || process.env.DSHT_TRACE_VERBOSE === '1',
177
+ });
178
+ const controller = new Controller({
179
+ base: url, token, initialSession: values.session === 'new' ? undefined : values.session,
180
+ authenticate: client => login(client, token, store),
181
+ localDirectory, verifier,
182
+ // Verdicts belong to this client rather than to the reviewed tree, and the client's own
183
+ // directory is the one place it is always allowed to write; DSHT_VERDICT_ROOT points them
184
+ // elsewhere when the review targets a workspace this machine cannot write.
185
+ verdictRoot: process.env.DSHT_VERDICT_ROOT ?? localDirectory,
186
+ costs, historyLimits: limits, shellEnabled,
187
+ deadlineMs: loopDeadlineMs(values.deadline ?? process.env.DSHT_LOOP_DEADLINE),
188
+ memoryLogPath: memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']),
189
+ tracePath: tracePath(stateRoot, values.trace, values['no-trace']),
190
+ promptsPath: join(stateRoot, 'prompts.json'),
191
+ });
192
+ const plan = {
193
+ ...(values.ws === undefined ? {} : { workspace: values.ws }),
194
+ ...(values.session === undefined ? {} : { session: values.session }),
195
+ commands: values.command ?? [],
196
+ ...(values.prompt === undefined ? {} : { prompt: values.prompt }),
197
+ ...(values.wait === undefined ? {} : { wait: values.wait }),
198
+ ...(values.verdict === undefined ? {} : { verdict: { file: values.verdict, identity: requireIdentity(values['verdict-identity']) } }),
199
+ timeoutSeconds: 3600,
200
+ };
201
+ const log = (line) => process.stderr.write(`${line}\n`);
202
+ controller.start();
203
+ if (values.headless) {
204
+ // No renderer: run the plan, follow a started loop to its verdict, and report it as the exit code.
205
+ try {
206
+ const outcome = await runStartup(controller, plan, log);
207
+ // 0 passed, 1 failed, 3 waiting for a person: a script can tell the three apart.
208
+ process.exitCode = outcome === 'failed' ? 1 : outcome === 'needs-human' ? 3 : 0;
209
+ }
210
+ finally {
211
+ await controller.shutdown();
212
+ }
213
+ return;
214
+ }
100
215
  const app = mount(controller);
101
216
  const terminate = () => app.unmount();
102
217
  process.once('SIGTERM', terminate);
103
- controller.start();
218
+ void runStartup(controller, plan, log).catch(error => process.stderr.write(`${errorText(error)}\n`));
104
219
  try {
105
220
  await app.waitUntilExit();
106
221
  }
@@ -109,6 +224,55 @@ async function main() {
109
224
  await controller.shutdown();
110
225
  }
111
226
  }
227
+ /** Read the published version from the manifest beside this entry point.
228
+ *
229
+ * The path is relative to this module, so it resolves both in the source tree (`src/cli/`) and in
230
+ * the published build (`dist/cli/`). The version is never repeated as a literal, which is what lets
231
+ * a release touch only `package.json` and the lockfile.
232
+ * @returns The `version` field of `package.json`.
233
+ */
234
+ async function packageVersion() {
235
+ const manifest = await readText(fileURLToPath(new URL('../../package.json', import.meta.url)));
236
+ if (manifest === undefined)
237
+ throw new Error('package.json is missing beside the client entry point');
238
+ const version = string(object(JSON.parse(manifest)).version);
239
+ if (version === '')
240
+ throw new Error('package.json has no version');
241
+ return version;
242
+ }
243
+ /** The identity a written verdict must declare, refused when the flag that carries it is missing.
244
+ * @param value - `--verdict-identity` value.
245
+ * @returns The identity.
246
+ */
247
+ function requireIdentity(value) {
248
+ if (value === undefined || value.trim() === '')
249
+ throw new Error('--verdict requires --verdict-identity');
250
+ return value;
251
+ }
252
+ /** Whole-run budget from `--deadline`/`DSHT_LOOP_DEADLINE`, in minutes.
253
+ *
254
+ * Absent means no budget, which keeps the old behaviour for callers that never set one. A value that
255
+ * is not a positive number is refused rather than silently ignored, because a run that was supposed
256
+ * to be bounded and is not is worse than a startup error.
257
+ * @param value - Flag or environment value.
258
+ * @returns The budget in milliseconds, or undefined when unbounded.
259
+ */
260
+ function loopDeadlineMs(value) {
261
+ if (value === undefined || value.trim() === '')
262
+ return undefined;
263
+ const minutes = Number(value);
264
+ if (!Number.isFinite(minutes) || minutes <= 0)
265
+ throw new Error(`--deadline must be a positive number of minutes: ${value}`);
266
+ return Math.round(minutes * 60_000);
267
+ }
268
+ /** How long one forked verification may run, from a minute count in the environment.
269
+ * @param value - `DSHT_VERIFY_TIMEOUT_MS` when set.
270
+ * @returns The timeout in milliseconds, defaulting to twenty minutes.
271
+ */
272
+ function verifyTimeoutMs(value) {
273
+ const parsed = Number(value);
274
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 20 * 60_000;
275
+ }
112
276
  /** Resolve the runtime memory log path: an explicit flag wins, then the environment, then the default.
113
277
  * @param stateRoot - Application state root used for the default path.
114
278
  * @param requested - `--memory-log` value, when given.
@@ -116,13 +280,34 @@ async function main() {
116
280
  * @returns Absolute log path, or undefined when the log is disabled.
117
281
  */
118
282
  function memoryLogPath(stateRoot, requested, disabled) {
283
+ return diagnosticLogPath('memory-log', 'memory.log', stateRoot, requested, disabled, process.env.DSHT_MEMORY_LOG);
284
+ }
285
+ /** Resolve the transition trace path: an explicit flag wins, then the environment, then the default.
286
+ * @param stateRoot - Application state root used for the default path.
287
+ * @param requested - `--trace` value, when given.
288
+ * @param disabled - `--no-trace` flag.
289
+ * @returns Absolute trace path, or undefined when the trace is disabled.
290
+ */
291
+ function tracePath(stateRoot, requested, disabled) {
292
+ return diagnosticLogPath('trace', 'trace.log', stateRoot, requested, disabled, process.env.DSHT_TRACE);
293
+ }
294
+ /** Resolve one diagnostic log path shared by the memory log and the transition trace.
295
+ * @param flag - Long option name, used in the error for an empty value.
296
+ * @param file - Default filename under the state root.
297
+ * @param stateRoot - Application state root.
298
+ * @param requested - Flag value, when given; an empty string is a mistyped flag, not a default.
299
+ * @param disabled - `--no-<flag>` flag.
300
+ * @param environment - Environment override, where `off` disables the log.
301
+ * @returns Absolute log path, or undefined when the log is disabled.
302
+ */
303
+ function diagnosticLogPath(flag, file, stateRoot, requested, disabled, environment) {
119
304
  if (requested !== undefined && requested.trim() === '')
120
- throw new Error('--memory-log requires a path');
305
+ throw new Error(`--${flag} requires a path`);
121
306
  if (disabled)
122
307
  return undefined;
123
- const chosen = (requested ?? process.env.DSHT_MEMORY_LOG)?.trim();
308
+ const chosen = (requested ?? environment)?.trim();
124
309
  if (chosen === undefined || chosen === '')
125
- return join(stateRoot, 'memory.log');
310
+ return join(stateRoot, file);
126
311
  return chosen === 'off' ? undefined : chosen;
127
312
  }
128
313
  main().catch(error => { process.stderr.write(`${errorText(error)}\n`); process.exitCode = 1; });
@@ -0,0 +1,40 @@
1
+ /** Startup automation: pick a workspace and a session, then run the requested slash lines.
2
+ *
3
+ * This is the scriptable half of the client — `dsht --ws X --session new --command "…"` — so a
4
+ * review can be launched without typing. It drives the same controller the UI drives, and reads only
5
+ * the outcome of each line: the presentational effects of a command belong to the UI.
6
+ */
7
+ import { type Controller } from '../controller/index.ts';
8
+ /** What the operator asked the client to do before/while taking over. */
9
+ export interface StartupPlan {
10
+ /** Workspace ID, name or path; absent adopts the directory this client runs in. */
11
+ workspace?: string;
12
+ /** Session to open, or `new`; absent creates one when commands were given. */
13
+ session?: string;
14
+ /** Slash lines to run once the session is ready, in order. */
15
+ commands: readonly string[];
16
+ /** Plain prompt to send after the commands; a forked verifier drives a session this way. */
17
+ prompt?: string;
18
+ /** Wait for that prompt's turn to finish before returning, so the child exits on its own. */
19
+ wait?: boolean;
20
+ /** Where to persist the verdict this session produced, and the identity it must declare. */
21
+ verdict?: VerdictTarget;
22
+ /** Seconds a started loop may run before it is stopped. */
23
+ timeoutSeconds: number;
24
+ }
25
+ /** The verdict file a forked verifier session must leave behind. */
26
+ export interface VerdictTarget {
27
+ /** Absolute path to write. */
28
+ file: string;
29
+ /** `<runId>/<kind>/<step>/<attempt>`, checked against the reply before anything is written. */
30
+ identity: string;
31
+ }
32
+ /** How the startup run ended. */
33
+ export type StartupOutcome = 'passed' | 'idle' | 'failed' | 'needs-human';
34
+ /** Run the plan against a started controller.
35
+ * @param controller - Connected application facade.
36
+ * @param plan - Workspace, session and lines to run.
37
+ * @param log - Progress sink, usually stderr in headless mode.
38
+ * @returns Whether a started loop passed, nothing ran, or the run failed.
39
+ */
40
+ export declare function runStartup(controller: Controller, plan: StartupPlan, log: (line: string) => void): Promise<StartupOutcome>;
@@ -0,0 +1,295 @@
1
+ /** Startup automation: pick a workspace and a session, then run the requested slash lines.
2
+ *
3
+ * This is the scriptable half of the client — `dsht --ws X --session new --command "…"` — so a
4
+ * review can be launched without typing. It drives the same controller the UI drives, and reads only
5
+ * the outcome of each line: the presentational effects of a command belong to the UI.
6
+ */
7
+ import { runCommand } from "../controller/index.js";
8
+ import { errorText } from "../transport/wire.js";
9
+ import { dirname } from 'node:path';
10
+ import { ensureDirectory, renameFile, writePrivateFile } from "../storage/index.js";
11
+ import { latestAssistantText } from "../controller/loop.js";
12
+ import { parseVerdict } from "../controller/loop-contract.js";
13
+ import { needsHumanLine } from "../controller/verifier.js";
14
+ import { authorize, normalize } from "../slash/index.js";
15
+ const POLL_MS = 250;
16
+ /** How long a connection, session or workspace operation may take. */
17
+ const STEP_TIMEOUT_MS = 30_000;
18
+ /** How long a forked child may wait for the session it was pointed at to stream its snapshot. */
19
+ const PROMPT_SNAPSHOT_TIMEOUT_MS = 180_000;
20
+ /** How long the verdict may take to appear after the turn was reported idle. */
21
+ const VERDICT_GRACE_MS = 5_000;
22
+ /** How often the reply is re-read while that grace lasts. */
23
+ const VERDICT_POLL_MS = 200;
24
+ /** Wait until one condition holds.
25
+ * @param condition - Predicate polled until true.
26
+ * @param what - Human-readable subject for the timeout message.
27
+ * @param timeoutMs - Longest wait.
28
+ */
29
+ async function until(condition, what, timeoutMs = STEP_TIMEOUT_MS) {
30
+ const deadline = Date.now() + timeoutMs;
31
+ while (!condition()) {
32
+ if (Date.now() >= deadline)
33
+ throw new Error(`Timed out waiting for ${what}`);
34
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
35
+ }
36
+ }
37
+ /** Run the plan against a started controller.
38
+ * @param controller - Connected application facade.
39
+ * @param plan - Workspace, session and lines to run.
40
+ * @param log - Progress sink, usually stderr in headless mode.
41
+ * @returns Whether a started loop passed, nothing ran, or the run failed.
42
+ */
43
+ export async function runStartup(controller, plan, log) {
44
+ // `online` alone is too early: the startup picker runs after it and would overwrite a selection
45
+ // made here, which is exactly how a forked verifier used to lose its session.
46
+ await until(() => controller.state.online && controller.queries.connectionSettled, 'the host connection');
47
+ if (plan.workspace !== undefined && !await controller.actions.switchWorkspace(plan.workspace)) {
48
+ throw new Error(`Workspace not found: ${plan.workspace}`);
49
+ }
50
+ // An explicit `new`, or automation with no session named, starts a fresh conversation; the
51
+ // workspace defaults to the directory this client runs in, registered on demand.
52
+ if (plan.session === 'new' || plan.session === undefined && plan.commands.length > 0) {
53
+ await ensureWorkspace(controller, log);
54
+ if (!await controller.actions.createSession())
55
+ throw new Error('Could not create a session');
56
+ }
57
+ else if (plan.session !== undefined) {
58
+ if (!await controller.actions.selectSession(plan.session))
59
+ throw new Error(`Session not found: ${plan.session}`);
60
+ }
61
+ // A selected session is not sendable until its follow snapshot lands.
62
+ if (plan.commands.length) {
63
+ await until(() => controller.state.screen === 'chat' && controller.state.sessionId !== undefined && controller.queries.record.ready, 'the session snapshot');
64
+ }
65
+ // Start-up lines are one-shot action commands; the cancellable port is only used by searches
66
+ // and exports, which have no meaning before the operator is present.
67
+ const port = { run: (_label, operation) => operation(controller.connection.signal()) };
68
+ for (const line of plan.commands) {
69
+ // The scripted half runs the same two stages the composer does after `interpret`: a headless
70
+ // caller has no menus or screens, but it has every application fact `normalize`/`authorize` read.
71
+ const command = normalize({ kind: 'line', line }, {
72
+ sessionSelected: controller.state.sessionId !== undefined,
73
+ question: controller.state.pending[0]?.kind === 'question',
74
+ pending: controller.state.pending.length > 0,
75
+ });
76
+ if (command.kind === 'error')
77
+ throw new Error(command.message);
78
+ const verdict = await authorizeWhenReady(controller, command);
79
+ if (!verdict.allow)
80
+ throw new Error(verdict.error.message);
81
+ const result = await runCommand(controller, verdict.command, port);
82
+ if (result === undefined) {
83
+ const reason = controller.state.lastFailure;
84
+ throw new Error(`Command was not accepted: ${line}${reason ? ` (${reason})` : ''}`);
85
+ }
86
+ if (result.outcome !== 'ok') {
87
+ const failure = result.effects.find(effect => effect.kind === 'error');
88
+ throw new Error(failure?.kind === 'error' ? failure.text : `Command failed (${result.outcome}): ${line}`);
89
+ }
90
+ if (result.disposition === 'retain')
91
+ throw new Error(`Command did not settle: ${line}`);
92
+ for (const effect of result.effects)
93
+ if (effect.kind === 'notice')
94
+ log(effect.text);
95
+ }
96
+ // Both counts are taken before the prompt, because a turn can start and finish between two polls.
97
+ const finishedBefore = controller.queries.turnsCompleted;
98
+ const repliesBefore = controller.queries.record.messages.length;
99
+ if (plan.prompt !== undefined) {
100
+ // Sending a prompt needs the follow snapshot, and a verifier starts the instant a busy turn ends,
101
+ // so this wait is longer than a normal startup step: the host can be slow to open the new stream.
102
+ try {
103
+ await until(() => controller.state.sessionId !== undefined && controller.queries.record.ready, 'the verifier session snapshot', PROMPT_SNAPSHOT_TIMEOUT_MS);
104
+ }
105
+ catch (error) {
106
+ // Which of the three conditions failed is the whole diagnosis, so it travels with the error.
107
+ throw new Error(`${errorText(error)} (screen=${controller.state.screen}, session=${controller.state.sessionId ?? 'none'},`
108
+ + ` snapshot=${String(controller.queries.record.ready)}, online=${String(controller.state.online)})`);
109
+ }
110
+ await controller.actions.prompt(plan.prompt);
111
+ }
112
+ if (controller.queries.loop !== undefined)
113
+ return await waitForLoop(controller, plan.timeoutSeconds * 1000, log);
114
+ if (plan.wait) {
115
+ const outcome = await waitForTurn(controller, finishedBefore, plan.timeoutSeconds * 1000, log);
116
+ if (outcome === 'idle' && plan.verdict !== undefined)
117
+ await writeVerdict(controller, plan.verdict, repliesBefore, log);
118
+ return outcome;
119
+ }
120
+ return 'idle';
121
+ }
122
+ /** Authorize one startup line, waiting out a fact the policy queues behind.
123
+ *
124
+ * There is no composer here to hold a line, and failing a scripted run because the client happened to
125
+ * be mid-turn would make `--command` unusable in exactly the automation it exists for; so the scripted
126
+ * caller waits for the same fact the UI queue waits for, bounded by the step timeout.
127
+ * @param controller - Connected facade whose facts decide.
128
+ * @param command - Normalized line to run.
129
+ * @returns The verdict once the line may run, or the refusal it will never outlive.
130
+ */
131
+ async function authorizeWhenReady(controller, command) {
132
+ const deadline = Date.now() + STEP_TIMEOUT_MS;
133
+ for (;;) {
134
+ const verdict = authorize(command, {
135
+ sessionSelected: controller.state.sessionId !== undefined,
136
+ pending: controller.state.pending.length > 0,
137
+ during: controller.queries.loop?.active === true ? 'loop' : controller.queries.running ? 'turn' : 'idle',
138
+ foreground: controller.queries.foreground !== undefined,
139
+ });
140
+ if (!verdict.allow || verdict.defer === undefined)
141
+ return verdict;
142
+ if (Date.now() >= deadline)
143
+ throw new Error(`Timed out waiting for the client to be free: ${command.kind}`);
144
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
145
+ }
146
+ }
147
+ /** Persist the verdict this session's reply just produced.
148
+ *
149
+ * The agent judges; this client owns the protocol file: the reply is parsed here and written through
150
+ * a temporary file that is renamed into place, so a reader never sees a half-written verdict.
151
+ * A reply with no usable verdict changes nothing — the parent validates whatever is there.
152
+ * @param controller - Connected facade whose transcript holds the reply.
153
+ * @param target - File to write and the identity the verdict must declare.
154
+ * @param repliesBefore - Messages on the transcript before the prompt was sent: only a reply that
155
+ * arrived after it can be this turn's, so an idle from an earlier or replayed turn cannot be read
156
+ * as the verdict.
157
+ * @param log - Progress sink.
158
+ */
159
+ async function writeVerdict(controller, target, repliesBefore, log) {
160
+ const [, kind = '', step = '', attempt = ''] = target.identity.split('/');
161
+ const expect = { verificationId: target.identity, kind, step: Number(step), attempt: Number(attempt) };
162
+ // The host reports the turn idle just before that reply is committed, so an immediate parse can
163
+ // find nothing; the verdict is only missing once it has had time to arrive. Waiting on an
164
+ // assistant reply that did not exist when the prompt was sent is what ties the verdict to this
165
+ // prompt: the prompt itself only adds a user message, and an earlier turn's reply is behind the
166
+ // baseline.
167
+ const replyArrived = () => controller.queries.record.messages
168
+ .slice(repliesBefore).some(message => message.role === 'Assistant');
169
+ let parsed = replyArrived() ? parseVerdict(latestAssistantText(controller.queries.record.messages), expect) : undefined;
170
+ const deadline = Date.now() + VERDICT_GRACE_MS;
171
+ while (parsed === undefined && Date.now() < deadline) {
172
+ await new Promise(resolve => setTimeout(resolve, VERDICT_POLL_MS));
173
+ if (replyArrived())
174
+ parsed = parseVerdict(latestAssistantText(controller.queries.record.messages), expect);
175
+ }
176
+ if (parsed === undefined) {
177
+ // Say what actually happened: "no reply yet", "no JSON at all" and "JSON that is not this round"
178
+ // are different faults, and one line here is what tells them apart without another live run.
179
+ if (!replyArrived()) {
180
+ log('Turn ended but no reply was committed after the prompt; leaving the verdict file untouched');
181
+ return;
182
+ }
183
+ const tail = latestAssistantText(controller.queries.record.messages).slice(-240).replace(/\s+/g, ' ');
184
+ log(`No parsable verdict in the reply; leaving the verdict file untouched (reply tail: ${tail})`);
185
+ return;
186
+ }
187
+ const body = JSON.stringify({
188
+ verificationId: target.identity, kind, step: Number(step), attempt: Number(attempt),
189
+ ...(parsed.score === undefined ? {} : { score: parsed.score }),
190
+ ...(parsed.status === undefined ? {} : { status: parsed.status }),
191
+ ...(parsed.blocked === true ? { blocked: true } : {}),
192
+ ...(parsed.evidence === undefined ? {} : { evidence: parsed.evidence }),
193
+ ...(parsed.findings === undefined ? {} : { top_findings: parsed.findings }),
194
+ });
195
+ await ensureDirectory(dirname(target.file));
196
+ const temporary = `${target.file}.part`;
197
+ await writePrivateFile(temporary, body);
198
+ await renameFile(temporary, target.file);
199
+ log(`Verdict written to ${target.file}`);
200
+ }
201
+ /** Follow a sent prompt's turn to its end, which is what lets a forked child exit by itself.
202
+ *
203
+ * The host's idle event is the same signal the scored loop trusts: it cannot be missed by polling,
204
+ * and a turn that starts and ends between two polls still counts.
205
+ * @param controller - Connected application facade.
206
+ * @param finishedBefore - Turns completed before the prompt was sent.
207
+ * @param timeoutMs - Longest wait.
208
+ * @param log - Progress sink.
209
+ * @returns `idle` when the turn ended, `failed` on timeout.
210
+ */
211
+ async function waitForTurn(controller, finishedBefore, timeoutMs, log) {
212
+ const deadline = Date.now() + timeoutMs;
213
+ for (;;) {
214
+ if (controller.queries.turnsCompleted > finishedBefore) {
215
+ log('Turn finished');
216
+ return 'idle';
217
+ }
218
+ // A headless verifier cannot answer an approval or a question: stop and say what is needed,
219
+ // instead of waiting for the deadline and being retried as an infrastructure failure.
220
+ const request = humanRequest(controller);
221
+ if (request !== undefined) {
222
+ log(needsHumanLine(request));
223
+ return 'needs-human';
224
+ }
225
+ if (Date.now() >= deadline) {
226
+ log('Turn timed out');
227
+ return 'failed';
228
+ }
229
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
230
+ }
231
+ }
232
+ /** The host interaction this client cannot answer itself, when one is pending.
233
+ * @param controller - Connected facade whose selected session is the verifier's session.
234
+ * @returns The request to report, or undefined when nothing is pending.
235
+ */
236
+ function humanRequest(controller) {
237
+ const [pending] = controller.state.pending;
238
+ if (pending === undefined)
239
+ return undefined;
240
+ if (pending.kind === 'approval') {
241
+ return { kind: 'approval', text: pending.description === '' ? 'a tool approval is required' : pending.description };
242
+ }
243
+ return { kind: 'question', text: pending.questions[0]?.question ?? 'a question was asked' };
244
+ }
245
+ /** Select the directory this client runs in, registering it when the host does not know it yet. */
246
+ async function ensureWorkspace(controller, log) {
247
+ if (controller.state.workspaceId !== undefined)
248
+ return;
249
+ if (await controller.actions.switchWorkspace(controller.localDirectory))
250
+ return;
251
+ log(`Registering ${controller.localDirectory} as a workspace`);
252
+ if (!await controller.actions.createWorkspace(controller.localDirectory)) {
253
+ throw new Error(`Could not use ${controller.localDirectory} as a workspace`);
254
+ }
255
+ }
256
+ /** Follow a running loop to its terminal phase, reporting progress changes.
257
+ * @returns `passed` when the loop met its threshold, `failed` otherwise.
258
+ */
259
+ async function waitForLoop(controller, timeoutMs, log) {
260
+ const deadline = Date.now() + timeoutMs;
261
+ let previous = '';
262
+ for (;;) {
263
+ const progress = controller.queries.loop;
264
+ if (progress === undefined || progress.phase !== 'running') {
265
+ // A cancelled loop usually means the connection ended; say so, or the exit code is a mystery.
266
+ const why = controller.state.lastFailure || controller.state.status;
267
+ const interaction = progress?.interaction;
268
+ // `passed` only claims the rounds that ran, so a headless reader is told which ones.
269
+ const scope = progress?.phase === 'passed' ? ` · ${progress.scope}` : '';
270
+ log(`Loop ${progress?.phase ?? 'gone'}${scope}${why ? ` · ${why}` : ''}`
271
+ + `${interaction === undefined ? '' : ` · ${interaction.kind}: ${interaction.text}`}`
272
+ + `${progress?.note === undefined ? '' : ` · ${progress.note}`}`);
273
+ if (progress?.phase === 'passed')
274
+ return 'passed';
275
+ // A run stopped on a host request is not a failure: it is waiting for a person, and phase 1
276
+ // deliberately has no in-process answer path.
277
+ return progress?.phase === 'needs-human' ? 'needs-human' : 'failed';
278
+ }
279
+ const label = progress.stepLabel === undefined ? '' : ` · ${progress.stepLabel}`;
280
+ // The note carries why an attempt was decided the way it was — including why verification was
281
+ // unavailable — so a headless run is diagnosable without a renderer.
282
+ const note = progress.note === undefined ? '' : ` · ${progress.note}`;
283
+ const line = `${progress.title}${label} · step ${progress.step}/${progress.to} · attempt ${progress.attempt}/${progress.tries} · best ${progress.best}/${progress.score}${note}`;
284
+ if (line !== previous) {
285
+ previous = line;
286
+ log(line);
287
+ }
288
+ if (Date.now() >= deadline) {
289
+ controller.actions.stopLoop();
290
+ log('Loop timed out and was stopped');
291
+ return 'failed';
292
+ }
293
+ await new Promise(resolve => setTimeout(resolve, POLL_MS));
294
+ }
295
+ }