@itookit/dsht 0.3.8 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +33 -12
  3. package/README.zh.md +35 -14
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +206 -18
  8. package/dist/cli/startup.d.ts +40 -0
  9. package/dist/cli/startup.js +314 -0
  10. package/dist/cli/trace-summary.d.ts +78 -0
  11. package/dist/cli/trace-summary.js +241 -0
  12. package/dist/cli/verifier.d.ts +64 -0
  13. package/dist/cli/verifier.js +265 -0
  14. package/dist/contracts.d.ts +359 -0
  15. package/dist/contracts.js +1 -0
  16. package/dist/controller/commands.d.ts +47 -0
  17. package/dist/controller/commands.js +322 -0
  18. package/dist/controller/connection-streams.d.ts +22 -0
  19. package/dist/controller/connection-streams.js +105 -0
  20. package/dist/controller/connection.d.ts +24 -31
  21. package/dist/controller/connection.js +48 -111
  22. package/dist/controller/controller.d.ts +412 -178
  23. package/dist/controller/controller.js +713 -167
  24. package/dist/controller/foreground.d.ts +44 -0
  25. package/dist/controller/foreground.js +79 -0
  26. package/dist/controller/index.d.ts +8 -1
  27. package/dist/controller/index.js +5 -0
  28. package/dist/controller/loop-contract.d.ts +136 -0
  29. package/dist/controller/loop-contract.js +308 -0
  30. package/dist/controller/loop-coordinator.d.ts +48 -0
  31. package/dist/controller/loop-coordinator.js +647 -0
  32. package/dist/controller/loop-prompts-schema.d.ts +56 -0
  33. package/dist/controller/loop-prompts-schema.js +144 -0
  34. package/dist/controller/loop-prompts.d.ts +55 -0
  35. package/dist/controller/loop-prompts.generated.d.ts +104 -0
  36. package/dist/controller/loop-prompts.generated.js +185 -0
  37. package/dist/controller/loop-prompts.js +104 -0
  38. package/dist/controller/loop-protocols.d.ts +39 -0
  39. package/dist/controller/loop-protocols.js +115 -0
  40. package/dist/controller/loop.d.ts +275 -0
  41. package/dist/controller/loop.js +378 -0
  42. package/dist/controller/prompts.d.ts +54 -0
  43. package/dist/controller/prompts.js +162 -0
  44. package/dist/controller/trace-log.d.ts +45 -0
  45. package/dist/controller/trace-log.js +144 -0
  46. package/dist/controller/verifier.d.ts +130 -0
  47. package/dist/controller/verifier.js +75 -0
  48. package/dist/cost/controller.d.ts +1 -1
  49. package/dist/cost/controller.js +12 -5
  50. package/dist/cost/index.d.ts +1 -1
  51. package/dist/cost/index.js +1 -1
  52. package/dist/cost/ledger.d.ts +0 -1
  53. package/dist/cost/ledger.js +0 -1
  54. package/dist/cost/scanner.js +1 -0
  55. package/dist/json.d.ts +18 -0
  56. package/dist/json.js +19 -0
  57. package/dist/references.d.ts +25 -0
  58. package/dist/references.js +26 -0
  59. package/dist/session/connection-view.d.ts +2 -11
  60. package/dist/session/controller.d.ts +94 -105
  61. package/dist/session/controller.js +262 -536
  62. package/dist/session/history-reader.d.ts +32 -0
  63. package/dist/session/history-reader.js +170 -0
  64. package/dist/session/history.d.ts +6 -18
  65. package/dist/session/history.js +1 -24
  66. package/dist/session/index.d.ts +9 -4
  67. package/dist/session/index.js +7 -3
  68. package/dist/session/info.d.ts +20 -82
  69. package/dist/session/info.js +52 -25
  70. package/dist/session/interactions.d.ts +26 -0
  71. package/dist/session/interactions.js +75 -0
  72. package/dist/session/markdown.js +1 -1
  73. package/dist/session/math.js +1 -1
  74. package/dist/session/mutation-gate.d.ts +51 -0
  75. package/dist/session/mutation-gate.js +73 -0
  76. package/dist/session/navigation.d.ts +2 -89
  77. package/dist/session/navigation.js +2 -129
  78. package/dist/session/navigator.d.ts +47 -0
  79. package/dist/session/navigator.js +158 -0
  80. package/dist/session/peek.d.ts +38 -0
  81. package/dist/session/peek.js +103 -0
  82. package/dist/session/prompt-backfill.d.ts +23 -0
  83. package/dist/session/prompt-backfill.js +88 -0
  84. package/dist/session/references.d.ts +2 -20
  85. package/dist/session/references.js +1 -26
  86. package/dist/session/runtime.d.ts +26 -0
  87. package/dist/session/runtime.js +28 -0
  88. package/dist/session/state.d.ts +20 -0
  89. package/dist/session/state.js +1 -0
  90. package/dist/session/telemetry.d.ts +25 -17
  91. package/dist/session/telemetry.js +66 -60
  92. package/dist/session/transcript.d.ts +5 -7
  93. package/dist/session/transcript.js +2 -15
  94. package/dist/session/types.d.ts +25 -0
  95. package/dist/session/types.js +0 -1
  96. package/dist/session-title.d.ts +9 -0
  97. package/dist/session-title.js +21 -0
  98. package/dist/shell/controller.d.ts +31 -1
  99. package/dist/shell/controller.js +34 -2
  100. package/dist/shell/index.d.ts +3 -3
  101. package/dist/shell/index.js +2 -2
  102. package/dist/shell/runner.d.ts +10 -0
  103. package/dist/shell/runner.js +48 -9
  104. package/dist/slash/index.d.ts +10 -0
  105. package/dist/slash/index.js +7 -0
  106. package/dist/slash/parse.d.ts +42 -0
  107. package/dist/slash/parse.js +259 -0
  108. package/dist/slash/pipeline.d.ts +140 -0
  109. package/dist/slash/pipeline.js +115 -0
  110. package/dist/slash/registry.d.ts +88 -0
  111. package/dist/slash/registry.js +177 -0
  112. package/dist/slash/types.d.ts +126 -0
  113. package/dist/slash/types.js +1 -0
  114. package/dist/state.d.ts +16 -18
  115. package/dist/state.js +4 -3
  116. package/dist/text.d.ts +28 -0
  117. package/dist/text.js +55 -0
  118. package/dist/transport/client.d.ts +4 -3
  119. package/dist/transport/client.js +71 -25
  120. package/dist/transport/events.d.ts +104 -0
  121. package/dist/transport/events.js +149 -0
  122. package/dist/transport/wire.d.ts +9 -17
  123. package/dist/transport/wire.js +2 -27
  124. package/dist/ui/app.js +750 -550
  125. package/dist/ui/chat/header.js +1 -1
  126. package/dist/ui/chat/history-view.d.ts +1 -1
  127. package/dist/ui/chat/loop-status.d.ts +11 -0
  128. package/dist/ui/chat/loop-status.js +28 -0
  129. package/dist/ui/chat/navigation-model.d.ts +86 -0
  130. package/dist/ui/chat/navigation-model.js +107 -0
  131. package/dist/ui/chat/shell-view.d.ts +17 -2
  132. package/dist/ui/chat/shell-view.js +45 -3
  133. package/dist/ui/chat/status.d.ts +47 -3
  134. package/dist/ui/chat/status.js +65 -50
  135. package/dist/ui/chat/use-history-view.d.ts +69 -0
  136. package/dist/ui/chat/use-history-view.js +123 -0
  137. package/dist/ui/chat/viewport.d.ts +1 -1
  138. package/dist/ui/dialogs/cost.d.ts +21 -4
  139. package/dist/ui/dialogs/cost.js +7 -12
  140. package/dist/ui/dialogs/index.d.ts +22 -5
  141. package/dist/ui/dialogs/index.js +19 -3
  142. package/dist/ui/dialogs/loop.d.ts +43 -0
  143. package/dist/ui/dialogs/loop.js +224 -0
  144. package/dist/ui/dialogs/peek.d.ts +25 -0
  145. package/dist/ui/dialogs/peek.js +35 -0
  146. package/dist/ui/dialogs/picker.d.ts +2 -0
  147. package/dist/ui/dialogs/picker.js +4 -2
  148. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  149. package/dist/ui/dialogs/use-panels.js +51 -0
  150. package/dist/ui/input/mouse.d.ts +12 -2
  151. package/dist/ui/input/mouse.js +20 -7
  152. package/dist/ui/input/references.d.ts +1 -1
  153. package/dist/ui/input/use-composer.d.ts +35 -0
  154. package/dist/ui/input/use-composer.js +109 -0
  155. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  156. package/dist/ui/input/use-deferred-lines.js +54 -0
  157. package/dist/ui/input/use-history-recall.d.ts +20 -0
  158. package/dist/ui/input/use-history-recall.js +47 -0
  159. package/dist/ui/status/model.d.ts +7 -0
  160. package/dist/ui/status/model.js +5 -0
  161. package/dist/ui/theme/index.d.ts +1 -1
  162. package/package.json +6 -4
  163. package/dist/ui/commands/parse.d.ts +0 -104
  164. package/dist/ui/commands/parse.js +0 -135
  165. package/dist/ui/commands/registry.d.ts +0 -33
  166. package/dist/ui/commands/registry.js +0 -73
@@ -1,88 +1,116 @@
1
1
  import { array, errorText, object, string } from "../transport/wire.js";
2
2
  /** Owns model-catalog and preset loads for the selected session. */
3
3
  export class CatalogController {
4
- store;
5
4
  host;
6
5
  presetClient;
7
6
  revision = 0;
7
+ generation = new AbortController();
8
+ refreshAbort;
8
9
  tasks = new Set();
9
- constructor(store, host) {
10
- this.store = store;
10
+ constructor(host) {
11
11
  this.host = host;
12
12
  }
13
13
  /** Drop generation-scoped catalog state at the start of a connection generation. */
14
14
  reset() {
15
- this.store.update({ modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
15
+ this.close();
16
+ this.generation = new AbortController();
17
+ this.presetClient = undefined;
18
+ this.host.publish({ modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
16
19
  }
20
+ /** Stop catalog requests before waiting for transport shutdown. Reset opens the next generation. */
21
+ close() { this.revision++; this.generation.abort(); }
17
22
  /** Wait for every in-flight catalog task, so shutdown leaves no pending request. */
18
- async settle() { await Promise.all(this.tasks); }
23
+ async settle() { await Promise.allSettled(this.tasks); }
24
+ track(task) {
25
+ this.tasks.add(task);
26
+ void task.then(() => this.tasks.delete(task), () => this.tasks.delete(task));
27
+ return task;
28
+ }
29
+ signal(caller = this.host.signal()) {
30
+ return AbortSignal.any([caller, this.host.signal(), this.generation.signal]);
31
+ }
19
32
  /** Load the optional preset roster once per connection, only when a session names a preset. */
20
33
  loadPresetNames() {
21
34
  const client = this.host.client();
22
- if (!client || !this.store.state.online || this.presetClient === client)
35
+ const signal = this.signal();
36
+ if (!client || !this.host.online() || this.presetClient === client || signal.aborted)
23
37
  return;
24
38
  this.presetClient = client;
25
- const task = client.call('agentPresets/list', {}).then(value => {
26
- if (client === this.host.client())
27
- this.store.update({ presets: array(object(value).presets).map(object), presetError: undefined });
39
+ const current = () => !signal.aborted && client === this.host.client();
40
+ const task = client.call('agentPresets/list', {}, signal).then(value => {
41
+ if (current())
42
+ this.host.publish({ presets: array(object(value).presets).map(object), presetError: undefined });
28
43
  }).catch(error => {
29
- if (client === this.host.client())
30
- this.store.update({ presets: [], presetError: errorText(error) });
44
+ if (current())
45
+ this.host.publish({ presets: [], presetError: errorText(error) });
31
46
  });
32
- this.tasks.add(task);
33
- void task.finally(() => this.tasks.delete(task));
47
+ this.track(task);
34
48
  }
35
49
  /** Fetch current model routes and adapter-owned reasoning choices for the selected session.
36
50
  * @returns Host catalog; provider failures remain available to the selector.
37
51
  */
38
- async modelCatalog() {
39
- const sessionId = this.sessionId;
40
- const selection = this.store.selection();
41
- const value = object(await this.host.require().call('session/modelCatalog', {}));
42
- if (selection !== this.store.selection() || sessionId !== this.store.state.sessionId)
43
- throw new Error('Session changed while loading models');
44
- return value;
52
+ async modelCatalog(caller) {
53
+ const selection = this.selected();
54
+ const signal = this.signal(caller);
55
+ signal.throwIfAborted();
56
+ return this.track((async () => {
57
+ const value = object(await this.host.require().call('session/modelCatalog', {}, signal));
58
+ signal.throwIfAborted();
59
+ if (!this.isSelected(selection))
60
+ throw new Error('Session changed while loading models');
61
+ return value;
62
+ })());
45
63
  }
46
64
  /** Select the next request's model; the host also attempts to save its deployment default.
47
65
  * @param provider - Host provider route ID.
48
66
  * @param model - Exact model ID.
49
67
  * @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
50
68
  */
51
- async selectModel(provider, model, reasoningEffort) {
52
- const sessionId = this.sessionId;
53
- const selection = this.store.selection();
54
- const selected = object(object(await this.host.require().call('session/selectModel', { request: {
55
- sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
56
- } })).selected);
57
- if (selection !== this.store.selection() || sessionId !== this.store.state.sessionId)
58
- return;
59
- this.store.update({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
60
- this.refresh();
69
+ async selectModel(provider, model, reasoningEffort, caller) {
70
+ const selection = this.selected();
71
+ const signal = this.signal(caller);
72
+ signal.throwIfAborted();
73
+ await this.track((async () => {
74
+ const selected = object(object(await this.host.require().call('session/selectModel', { request: {
75
+ sessionId: selection.sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
76
+ } }, signal)).selected);
77
+ signal.throwIfAborted();
78
+ if (!this.isSelected(selection))
79
+ return;
80
+ this.host.publish({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
81
+ this.refresh();
82
+ })());
61
83
  }
62
84
  /** Reload the default route and provider failures without touching session state. */
63
85
  refresh() {
64
86
  const client = this.host.client();
65
- if (!client)
87
+ if (!client || !this.host.online())
88
+ return;
89
+ this.refreshAbort?.abort();
90
+ this.refreshAbort = new AbortController();
91
+ const signal = this.signal(this.refreshAbort.signal);
92
+ if (signal.aborted)
66
93
  return;
67
94
  const revision = ++this.revision;
68
- const task = client.call('session/modelCatalog', {}).then(value => {
69
- if (client === this.host.client() && revision === this.revision)
70
- this.store.update({ defaultModel: object(object(value).default), modelError: undefined });
71
- }, error => {
72
- if (client === this.host.client() && revision === this.revision)
73
- this.store.update({ defaultModel: undefined, modelError: errorText(error) });
95
+ const current = () => !signal.aborted && client === this.host.client() && revision === this.revision;
96
+ const task = client.call('session/modelCatalog', {}, signal).then(value => {
97
+ if (current())
98
+ this.host.publish({ defaultModel: object(object(value).default), modelError: undefined });
74
99
  }).catch(error => {
75
- if (client === this.host.client() && revision === this.revision)
76
- this.store.update({ defaultModel: undefined, modelError: errorText(error) });
100
+ if (current())
101
+ this.host.publish({ defaultModel: undefined, modelError: errorText(error) });
77
102
  });
78
- this.tasks.add(task);
79
- void task.finally(() => this.tasks.delete(task));
103
+ this.track(task);
80
104
  }
81
105
  /** @returns The selected session identity, or a `Select a session first` failure. */
82
- get sessionId() {
83
- const id = this.store.state.sessionId;
84
- if (!id)
106
+ selected() {
107
+ const selected = this.host.selection();
108
+ if (!selected.sessionId)
85
109
  throw new Error('Select a session first');
86
- return id;
110
+ return { ...selected, sessionId: selected.sessionId };
111
+ }
112
+ isSelected(selection) {
113
+ const current = this.host.selection();
114
+ return current.revision === selection.revision && current.sessionId === selection.sessionId;
87
115
  }
88
116
  }
@@ -1,2 +1,3 @@
1
1
  /** Catalog domain: model routes, reasoning efforts and agent-preset metadata. */
2
2
  export { CatalogController } from './controller.ts';
3
+ export type { CatalogHost, CatalogUpdate } from './controller.ts';
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,71 @@ 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) => {
172
+ if (!await controller.actions.cancelVerifierSession(sessionId))
173
+ throw new Error('Verifier cancellation was not accepted');
174
+ },
175
+ onLine: line => { if (values.headless)
176
+ log(line); },
177
+ // Off by default: a verifier reason reaches the progress line and the trace, and that log may be
178
+ // pasted into a report, so the child's own words are quoted only when the operator asks.
179
+ verbose: values['trace-verbose'] === true || process.env.DSHT_TRACE_VERBOSE === '1',
180
+ });
181
+ const controller = new Controller({
182
+ base: url, token, initialSession: values.session === 'new' ? undefined : values.session,
183
+ authenticate: client => login(client, token, store),
184
+ localDirectory, verifier,
185
+ // Verdicts belong to this client rather than to the reviewed tree, and the client's own
186
+ // directory is the one place it is always allowed to write; DSHT_VERDICT_ROOT points them
187
+ // elsewhere when the review targets a workspace this machine cannot write.
188
+ verdictRoot: process.env.DSHT_VERDICT_ROOT ?? localDirectory,
189
+ costs, historyLimits: limits, shellEnabled,
190
+ deadlineMs: loopDeadlineMs(values.deadline ?? process.env.DSHT_LOOP_DEADLINE),
191
+ memoryLogPath: memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']),
192
+ tracePath: tracePath(stateRoot, values.trace, values['no-trace']),
193
+ promptsPath: join(stateRoot, 'prompts.json'),
194
+ });
195
+ const plan = {
196
+ ...(values.ws === undefined ? {} : { workspace: values.ws }),
197
+ ...(values.session === undefined ? {} : { session: values.session }),
198
+ commands: values.command ?? [],
199
+ ...(values.prompt === undefined ? {} : { prompt: values.prompt }),
200
+ ...(values.wait === undefined ? {} : { wait: values.wait }),
201
+ ...(values.verdict === undefined ? {} : { verdict: { file: values.verdict, identity: requireIdentity(values['verdict-identity']) } }),
202
+ timeoutSeconds: 3600,
203
+ };
204
+ const log = (line) => process.stderr.write(`${line}\n`);
205
+ controller.start();
206
+ if (values.headless) {
207
+ // No renderer: run the plan, follow a started loop to its verdict, and report it as the exit code.
208
+ try {
209
+ const outcome = await runStartup(controller, plan, log);
210
+ // 0 passed, 1 failed, 3 waiting for a person: a script can tell the three apart.
211
+ process.exitCode = outcome === 'failed' ? 1 : outcome === 'needs-human' ? 3 : 0;
212
+ }
213
+ finally {
214
+ await controller.shutdown();
215
+ }
216
+ return;
217
+ }
100
218
  const app = mount(controller);
101
219
  const terminate = () => app.unmount();
102
220
  process.once('SIGTERM', terminate);
103
- controller.start();
221
+ void runStartup(controller, plan, log).catch(error => process.stderr.write(`${errorText(error)}\n`));
104
222
  try {
105
223
  await app.waitUntilExit();
106
224
  }
@@ -109,6 +227,55 @@ async function main() {
109
227
  await controller.shutdown();
110
228
  }
111
229
  }
230
+ /** Read the published version from the manifest beside this entry point.
231
+ *
232
+ * The path is relative to this module, so it resolves both in the source tree (`src/cli/`) and in
233
+ * the published build (`dist/cli/`). The version is never repeated as a literal, which is what lets
234
+ * a release touch only `package.json` and the lockfile.
235
+ * @returns The `version` field of `package.json`.
236
+ */
237
+ async function packageVersion() {
238
+ const manifest = await readText(fileURLToPath(new URL('../../package.json', import.meta.url)));
239
+ if (manifest === undefined)
240
+ throw new Error('package.json is missing beside the client entry point');
241
+ const version = string(object(JSON.parse(manifest)).version);
242
+ if (version === '')
243
+ throw new Error('package.json has no version');
244
+ return version;
245
+ }
246
+ /** The identity a written verdict must declare, refused when the flag that carries it is missing.
247
+ * @param value - `--verdict-identity` value.
248
+ * @returns The identity.
249
+ */
250
+ function requireIdentity(value) {
251
+ if (value === undefined || value.trim() === '')
252
+ throw new Error('--verdict requires --verdict-identity');
253
+ return value;
254
+ }
255
+ /** Whole-run budget from `--deadline`/`DSHT_LOOP_DEADLINE`, in minutes.
256
+ *
257
+ * Absent means no budget, which keeps the old behaviour for callers that never set one. A value that
258
+ * is not a positive number is refused rather than silently ignored, because a run that was supposed
259
+ * to be bounded and is not is worse than a startup error.
260
+ * @param value - Flag or environment value.
261
+ * @returns The budget in milliseconds, or undefined when unbounded.
262
+ */
263
+ function loopDeadlineMs(value) {
264
+ if (value === undefined || value.trim() === '')
265
+ return undefined;
266
+ const minutes = Number(value);
267
+ if (!Number.isFinite(minutes) || minutes <= 0)
268
+ throw new Error(`--deadline must be a positive number of minutes: ${value}`);
269
+ return Math.round(minutes * 60_000);
270
+ }
271
+ /** How long one forked verification may run, from a minute count in the environment.
272
+ * @param value - `DSHT_VERIFY_TIMEOUT_MS` when set.
273
+ * @returns The timeout in milliseconds, defaulting to twenty minutes.
274
+ */
275
+ function verifyTimeoutMs(value) {
276
+ const parsed = Number(value);
277
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 20 * 60_000;
278
+ }
112
279
  /** Resolve the runtime memory log path: an explicit flag wins, then the environment, then the default.
113
280
  * @param stateRoot - Application state root used for the default path.
114
281
  * @param requested - `--memory-log` value, when given.
@@ -116,13 +283,34 @@ async function main() {
116
283
  * @returns Absolute log path, or undefined when the log is disabled.
117
284
  */
118
285
  function memoryLogPath(stateRoot, requested, disabled) {
286
+ return diagnosticLogPath('memory-log', 'memory.log', stateRoot, requested, disabled, process.env.DSHT_MEMORY_LOG);
287
+ }
288
+ /** Resolve the transition trace path: an explicit flag wins, then the environment, then the default.
289
+ * @param stateRoot - Application state root used for the default path.
290
+ * @param requested - `--trace` value, when given.
291
+ * @param disabled - `--no-trace` flag.
292
+ * @returns Absolute trace path, or undefined when the trace is disabled.
293
+ */
294
+ function tracePath(stateRoot, requested, disabled) {
295
+ return diagnosticLogPath('trace', 'trace.log', stateRoot, requested, disabled, process.env.DSHT_TRACE);
296
+ }
297
+ /** Resolve one diagnostic log path shared by the memory log and the transition trace.
298
+ * @param flag - Long option name, used in the error for an empty value.
299
+ * @param file - Default filename under the state root.
300
+ * @param stateRoot - Application state root.
301
+ * @param requested - Flag value, when given; an empty string is a mistyped flag, not a default.
302
+ * @param disabled - `--no-<flag>` flag.
303
+ * @param environment - Environment override, where `off` disables the log.
304
+ * @returns Absolute log path, or undefined when the log is disabled.
305
+ */
306
+ function diagnosticLogPath(flag, file, stateRoot, requested, disabled, environment) {
119
307
  if (requested !== undefined && requested.trim() === '')
120
- throw new Error('--memory-log requires a path');
308
+ throw new Error(`--${flag} requires a path`);
121
309
  if (disabled)
122
310
  return undefined;
123
- const chosen = (requested ?? process.env.DSHT_MEMORY_LOG)?.trim();
311
+ const chosen = (requested ?? environment)?.trim();
124
312
  if (chosen === undefined || chosen === '')
125
- return join(stateRoot, 'memory.log');
313
+ return join(stateRoot, file);
126
314
  return chosen === 'off' ? undefined : chosen;
127
315
  }
128
316
  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>;