@zgeoff/atc 2.11.0 → 2.13.0

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 (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -2,6 +2,7 @@ import { match } from 'ts-pattern';
2
2
  import { createActor } from 'xstate';
3
3
  import type { AgentID } from '../agents/agent-adapter';
4
4
  import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
5
+ import { DaemonError } from '../protocol/daemon-error';
5
6
  import type { EventMsg } from '../protocol/protocol';
6
7
  import { loadConfig } from '../shared/config';
7
8
  import { makeSingleFlight } from '../shared/make-single-flight';
@@ -20,6 +21,10 @@ import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar,
20
21
  let overlaySelected = 0;
21
22
  let confirmKill = false;
22
23
 
24
+ // The session whose forget waits on a confirm because it destroys the
25
+ // session's host, or null.
26
+ let confirmDestroyID: string | null = null;
27
+
23
28
  // The client's mirror of the daemon's fleet, kept fresh by events.
24
29
  let fleet: MirrorSession[] = [];
25
30
 
@@ -95,6 +100,36 @@ async function sendQuiet(m: string, p?: Readonly<Record<string, unknown>>) {
95
100
  } catch {}
96
101
  }
97
102
 
103
+ // A kill the daemon refuses because forgetting the session destroys its
104
+ // host asks once more before the forget.
105
+ async function sendKill(sessionID: string) {
106
+ try {
107
+ await client.sendRequest('session.kill', { session: sessionID });
108
+ } catch (error) {
109
+ if (error instanceof DaemonError && error.code === 'confirmation_required') {
110
+ confirmDestroyID = sessionID;
111
+
112
+ if (service.getSnapshot().value === 'overlay') {
113
+ renderOverlay();
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ // Takes the confirm token the daemon offers and hands it straight back:
120
+ // the operator already confirmed in the overlay.
121
+ async function sendForget(sessionID: string) {
122
+ try {
123
+ const offered = await client.sendRequest('session.forget', { session: sessionID });
124
+
125
+ const confirmToken = offered['confirmToken'];
126
+
127
+ if (typeof confirmToken === 'string') {
128
+ await client.sendRequest('session.forget', { session: sessionID, confirmToken });
129
+ }
130
+ } catch {}
131
+ }
132
+
98
133
  async function attach(sessionID: string) {
99
134
  service.send({ type: 'ATTACH', sessionID });
100
135
 
@@ -215,6 +250,7 @@ function renderOverlay() {
215
250
  agentMarks,
216
251
  selected: overlaySelected,
217
252
  confirmKill,
253
+ confirmDestroy: confirmDestroyID !== null,
218
254
  filter: overlayFilter,
219
255
  stale: daemonStale && !daemonRestarting,
220
256
  grouped: overlayGrouped,
@@ -225,6 +261,7 @@ function renderOverlay() {
225
261
 
226
262
  function openOverlay() {
227
263
  confirmKill = false;
264
+ confirmDestroyID = null;
228
265
  overlayFilter = null;
229
266
 
230
267
  const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
@@ -510,7 +547,7 @@ function applyOverlayKey(buf: Buffer) {
510
547
 
511
548
  if (confirmKill) {
512
549
  if (buf[0] === 0x79 /* y */ && sel !== undefined) {
513
- void sendQuiet('session.kill', { session: sel.id });
550
+ void sendKill(sel.id);
514
551
  }
515
552
 
516
553
  confirmKill = false;
@@ -520,6 +557,18 @@ function applyOverlayKey(buf: Buffer) {
520
557
  return;
521
558
  }
522
559
 
560
+ if (confirmDestroyID !== null) {
561
+ if (buf[0] === 0x79 /* y */) {
562
+ void sendForget(confirmDestroyID);
563
+ }
564
+
565
+ confirmDestroyID = null;
566
+
567
+ renderOverlay();
568
+
569
+ return;
570
+ }
571
+
523
572
  if (applyOverlayFilterKey(buf)) {
524
573
  return;
525
574
  }
@@ -1,5 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import type { AgentID } from '../agents/agent-adapter';
3
+ import { DaemonError } from '../protocol/daemon-error';
3
4
  import { loadConfig } from '../shared/config';
4
5
  import { collectAgentPicks } from './collect-agent-picks';
5
6
  import type { AgentPick } from './collect-agent-picks';
@@ -32,7 +33,8 @@ export interface SpawnPickerDeps<TMirror> {
32
33
  /**
33
34
  * The modal flow behind n and r: agent, then directory, then name, then an
34
35
  * optional first prompt. Every path out of it either attaches the new
35
- * session or returns the client to the screen it came from.
36
+ * session or returns the client to the screen it came from. A spawn the
37
+ * daemon refuses is no path out: the flow stays open and shows why.
36
38
  */
37
39
  export class SpawnPicker<TMirror extends { readonly id: string }> {
38
40
  private readonly deps: SpawnPickerDeps<TMirror>;
@@ -60,6 +62,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
60
62
 
61
63
  private agent: AgentID = 'claude';
62
64
 
65
+ // The daemon's refusal of the last spawn, shown in place of the hint until
66
+ // the next key.
67
+ private refusal: string | null = null;
68
+
63
69
  constructor(deps: SpawnPickerDeps<TMirror>) {
64
70
  this.deps = deps;
65
71
  }
@@ -82,12 +88,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
82
88
 
83
89
  this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
84
90
  this.step = 'agent';
91
+ this.refusal = null;
85
92
 
86
93
  process.stdout.write(ansi.clear);
87
94
  this.render();
88
95
  }
89
96
 
90
97
  applyKey(buf: Buffer) {
98
+ this.refusal = null;
99
+
91
100
  const edit = planTextEdit(buf, this.input, {
92
101
  isLeaderKey: this.deps.isLeaderKey,
93
102
  moves: this.step === 'agent' || this.step === 'dir',
@@ -168,7 +177,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
168
177
  selected: -1,
169
178
  input: this.input,
170
179
  placeholder: formatDirName(this.dir),
171
- hint: `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
180
+ hint: this.refusal ?? `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
172
181
  });
173
182
  } else {
174
183
  drawPicker({
@@ -177,7 +186,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
177
186
  selected: -1,
178
187
  input: this.input,
179
188
  placeholder: 'optional — ⏎ to start interactive',
180
- hint: 'first message for the session · ⏎ spawn · esc back',
189
+ hint: this.refusal ?? 'first message for the session · ⏎ spawn · esc back',
181
190
  });
182
191
  }
183
192
 
@@ -320,7 +329,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
320
329
 
321
330
  return;
322
331
  }
323
- } catch {}
332
+ } catch (error) {
333
+ // A refused spawn keeps the picker on the step that sent it, with the
334
+ // entered text back in the input, so the user can fix the cause and
335
+ // retry or back out.
336
+ const reason =
337
+ error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
338
+
339
+ this.refusal = `${reason} · ⏎ retry · esc back`;
340
+ this.input = this.resume ? this.name : prompt;
341
+
342
+ process.stdout.write(ansi.clear);
343
+ this.render();
344
+
345
+ return;
346
+ }
324
347
 
325
348
  this.deps.toBase();
326
349
  }
package/src/client/ui.ts CHANGED
@@ -196,6 +196,10 @@ export interface OverlayView {
196
196
  agentMarks: Readonly<Record<AgentID, string>>;
197
197
  selected: number;
198
198
  confirmKill: boolean;
199
+
200
+ // A kill the daemon refused because forgetting the session destroys its
201
+ // host waits on a second confirm.
202
+ confirmDestroy: boolean;
199
203
  filter: string | null;
200
204
  stale: boolean;
201
205
  grouped: boolean;
@@ -271,6 +275,10 @@ export function drawOverlay(view: OverlayView) {
271
275
  hint = formatKillConfirm(selected, view.sessions);
272
276
  }
273
277
 
278
+ if (view.confirmDestroy) {
279
+ hint = 'forget destroys its host and everything on it? y / n';
280
+ }
281
+
274
282
  rowsList.push(dimRow(width, hint), boxBottom(width));
275
283
 
276
284
  drawBox(rowsList);
@@ -1,4 +1,4 @@
1
- import type { AgentAdapter } from '../agents/agent-adapter';
1
+ import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
2
2
 
3
3
  interface AgentCapabilities {
4
4
  // Only an installed agent can start a session.
@@ -21,6 +21,19 @@ export interface AgentEntry {
21
21
  readonly installed: boolean;
22
22
  readonly capabilities: AgentCapabilities;
23
23
  readonly models: Readonly<Record<string, string>> | null;
24
+ readonly spawnOptions: SpawnOptionEntries;
25
+ }
26
+
27
+ // A spawn option as `agents.list` shows it: the agent's own spec, plus
28
+ // whether a spawn on this host can pass it now. Only an installed agent's
29
+ // supported option is available.
30
+ export interface SpawnOptionEntry extends SpawnOptionSpec {
31
+ readonly available: boolean;
32
+ }
33
+
34
+ interface SpawnOptionEntries {
35
+ readonly model: SpawnOptionEntry;
36
+ readonly effort: SpawnOptionEntry;
24
37
  }
25
38
 
26
39
  /**
@@ -53,6 +66,29 @@ export function buildAgentList(
53
66
  input: true,
54
67
  },
55
68
  models: profile?.models ?? null,
69
+ spawnOptions: {
70
+ model: buildSpawnOptionEntry(profile?.spawnOptions.model, installed),
71
+ effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, installed),
72
+ },
56
73
  };
57
74
  });
58
75
  }
76
+
77
+ // A stand-in adapter declares no spawn options, so it takes none.
78
+ const NO_SPAWN_OPTION: SpawnOptionSpec = {
79
+ supported: false,
80
+ values: null,
81
+ examples: [],
82
+ default: null,
83
+ backendEffect: null,
84
+ note: null,
85
+ };
86
+
87
+ function buildSpawnOptionEntry(
88
+ spec: SpawnOptionSpec | undefined,
89
+ installed: boolean,
90
+ ): SpawnOptionEntry {
91
+ const resolved = spec ?? NO_SPAWN_OPTION;
92
+
93
+ return { ...resolved, available: installed && resolved.supported };
94
+ }
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { TargetConfigError } from '../shared/collect-targets';
3
+ import type { ExecutionTarget } from './build-execution-targets';
4
+
5
+ /**
6
+ * A short digest of the target config a daemon runs with: each target's id
7
+ * and identity, the default target, and the config errors. Two daemons with
8
+ * the same targets return the same revision, and any change to them
9
+ * changes it.
10
+ */
11
+ export function buildConfigRevision(
12
+ targets: readonly ExecutionTarget[],
13
+ defaultTarget: string | null,
14
+ errors: readonly TargetConfigError[],
15
+ ): string {
16
+ const canonical = JSON.stringify({
17
+ targets: targets.map((target) => [target.id, target.identity]),
18
+ defaultTarget,
19
+ errors: errors.map((error) =>
20
+ error.scope === 'config'
21
+ ? [error.scope, error.path, error.problem, error.detail]
22
+ : [error.scope, error.target ?? null, error.problem],
23
+ ),
24
+ });
25
+
26
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
27
+ }
@@ -0,0 +1,73 @@
1
+ import type { TargetConfig, TargetConfigError } from '../shared/collect-targets';
2
+ import { buildImpProvider } from './build-imp-provider';
3
+ import { buildTargetIdentity } from './build-target-identity';
4
+ import type { ExecutionProvider } from './execution-provider';
5
+ import { LocalPTYProvider } from './local-pty-provider';
6
+
7
+ /**
8
+ * A configured place sessions run, with the provider that serves it. The
9
+ * provider is null when this daemon has no provider of the configured kind:
10
+ * the target still lists, and a spawn to it fails rather than running
11
+ * anywhere else.
12
+ */
13
+ export interface ExecutionTarget {
14
+ readonly id: string;
15
+ readonly kind: string;
16
+ readonly options: Readonly<Record<string, unknown>>;
17
+
18
+ // The identity a session spawned here binds to.
19
+ readonly identity: string;
20
+ readonly provider: ExecutionProvider | null;
21
+ }
22
+
23
+ /**
24
+ * The targets the daemon serves, and the problems with their entries that
25
+ * only the daemon's environment shows.
26
+ */
27
+ export interface ExecutionTargetsBuild {
28
+ readonly targets: ExecutionTarget[];
29
+ readonly errors: TargetConfigError[];
30
+ }
31
+
32
+ /**
33
+ * Builds one target per configured entry, in config order, each with a
34
+ * provider of its kind when this daemon has one. An entry whose provider
35
+ * cannot start from `env` keeps no provider and adds a target error.
36
+ */
37
+ export function buildExecutionTargets(
38
+ configs: readonly TargetConfig[],
39
+ env: Readonly<Record<string, string | undefined>> = process.env,
40
+ ): ExecutionTargetsBuild {
41
+ const errors: TargetConfigError[] = [];
42
+
43
+ const targets = configs.map((config) => {
44
+ const built = buildProvider(config, env);
45
+
46
+ if (built.problem !== null) {
47
+ errors.push({ scope: 'target', target: config.id, problem: built.problem });
48
+ }
49
+
50
+ return {
51
+ id: config.id,
52
+ kind: config.provider,
53
+ options: config.options,
54
+ identity: buildTargetIdentity(config.provider, config.options),
55
+ provider: built.provider,
56
+ };
57
+ });
58
+
59
+ return { targets, errors };
60
+ }
61
+
62
+ function buildProvider(
63
+ config: TargetConfig,
64
+ env: Readonly<Record<string, string | undefined>>,
65
+ ): Readonly<{ provider: ExecutionProvider | null; problem: string | null }> {
66
+ if (config.provider === 'local-pty') {
67
+ return { provider: new LocalPTYProvider(), problem: null };
68
+ }
69
+
70
+ return config.provider === 'imp'
71
+ ? buildImpProvider(config.id, config.options, env)
72
+ : { provider: null, problem: null };
73
+ }
@@ -23,7 +23,8 @@ export function buildFleetEvents(
23
23
  sessions: readonly SessionDescriptor[],
24
24
  ): FleetEvent[] {
25
25
  return rows.map((row) => {
26
- // A restore re-mints atc ids, so the agent session id is the stable link.
26
+ // Rows written before atc session ids stayed stable across restores
27
+ // carry an earlier atc id, so the agent session id links them.
27
28
  const live =
28
29
  (row.agentSessionID === null
29
30
  ? undefined
@@ -0,0 +1,68 @@
1
+ import { ImpClientPort } from './imp-client-port';
2
+ import { ImpProvider } from './imp-provider';
3
+
4
+ /**
5
+ * The provider of an `imp` target, or the problem that keeps it from one.
6
+ * A problem never includes the token or any other environment value.
7
+ */
8
+ export interface ImpProviderBuild {
9
+ readonly provider: ImpProvider | null;
10
+ readonly problem: string | null;
11
+ }
12
+
13
+ /**
14
+ * The provider of target `id` from its `imp` options: `url` is where impd
15
+ * listens, and `tokenEnv` holds the name of the daemon's environment
16
+ * variable that carries the token, so no credential sits in the config.
17
+ * `image`, `memoryMib`, `guestDir`, and `guestATC` pass through. No
18
+ * provider when `url` is missing, so the target lists and refuses every
19
+ * spawn. A `tokenEnv` that is not a non-empty string, or whose variable is
20
+ * unset or empty in `env`, is a problem and leaves no provider; a target
21
+ * without `tokenEnv` calls impd with no token.
22
+ */
23
+ export function buildImpProvider(
24
+ id: string,
25
+ options: Readonly<Record<string, unknown>>,
26
+ env: Readonly<Record<string, string | undefined>> = process.env,
27
+ ): ImpProviderBuild {
28
+ const url = options['url'];
29
+ const tokenEnv = options['tokenEnv'];
30
+
31
+ if (typeof url !== 'string' || url === '') {
32
+ return { provider: null, problem: null };
33
+ }
34
+
35
+ if (tokenEnv !== undefined && (typeof tokenEnv !== 'string' || tokenEnv === '')) {
36
+ return {
37
+ provider: null,
38
+ problem: `target ${JSON.stringify(id)} must give tokenEnv as a non-empty string`,
39
+ };
40
+ }
41
+
42
+ const token = tokenEnv === undefined ? null : env[tokenEnv];
43
+
44
+ if (token === undefined || token === '') {
45
+ return {
46
+ provider: null,
47
+ problem: `target ${JSON.stringify(id)} reads its impd token from ${String(tokenEnv)}, which is unset or empty in the daemon's environment`,
48
+ };
49
+ }
50
+
51
+ const provider = new ImpProvider(new ImpClientPort({ url, token }), {
52
+ ...pickString(options, 'image'),
53
+ ...pickString(options, 'guestDir'),
54
+ ...pickString(options, 'guestATC'),
55
+ ...(typeof options['memoryMib'] === 'number' ? { memoryMib: options['memoryMib'] } : {}),
56
+ });
57
+
58
+ return { provider, problem: null };
59
+ }
60
+
61
+ function pickString(
62
+ options: Readonly<Record<string, unknown>>,
63
+ key: 'image' | 'guestDir' | 'guestATC',
64
+ ): Partial<Record<typeof key, string>> {
65
+ const value = options[key];
66
+
67
+ return typeof value === 'string' && value !== '' ? { [key]: value } : {};
68
+ }
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isRecord } from '../shared/report';
3
+
4
+ /**
5
+ * The SHA-256 of a request's params as canonical JSON: object keys sorted
6
+ * at every depth, with the idempotency key itself left out. Two requests
7
+ * that differ only in key order or in their idempotency key hash the same.
8
+ */
9
+ export function buildPayloadHash(params: Readonly<Record<string, unknown>>): string {
10
+ const { idempotencyKey: _ignored, ...payload } = params;
11
+
12
+ return createHash('sha256')
13
+ .update(JSON.stringify(sortKeys(payload)))
14
+ .digest('hex');
15
+ }
16
+
17
+ function sortKeys(value: unknown): unknown {
18
+ if (Array.isArray(value)) {
19
+ return value.map((item) => sortKeys(item));
20
+ }
21
+
22
+ if (!isRecord(value)) {
23
+ return value;
24
+ }
25
+
26
+ return Object.fromEntries(
27
+ Object.keys(value)
28
+ .toSorted()
29
+ .map((key) => [key, sortKeys(value[key])]),
30
+ );
31
+ }
@@ -5,14 +5,15 @@ import type { TrailEntry } from '../store/trail-entry';
5
5
  import type { NoteReport } from './parse-report';
6
6
 
7
7
  /**
8
- * The trail entry for one note a session reported, carrying its label and a
9
- * preview of its text.
8
+ * The trail entry for one note a session reported, carrying its label, a
9
+ * preview of its text, and the id its reporter gave it, when it gave one.
10
10
  */
11
11
  export function buildReportTrailEntry(
12
12
  sessionID: SessionID,
13
13
  agentSessionID: AgentSessionID | undefined,
14
14
  report: Readonly<NoteReport>,
15
15
  reportedAt: number,
16
+ reportID?: string,
16
17
  ): TrailEntry {
17
18
  return {
18
19
  at: reportedAt,
@@ -21,5 +22,6 @@ export function buildReportTrailEntry(
21
22
  kind: 'report',
22
23
  label: report.label,
23
24
  detail: truncateDetail(report.text),
25
+ ...(reportID === undefined ? {} : { reportID }),
24
26
  };
25
27
  }