@zgeoff/atc 2.14.0 → 2.15.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 (71) hide show
  1. package/package.json +2 -1
  2. package/src/agents/agent-adapter.ts +3 -38
  3. package/src/agents/build-hook-settings.ts +1 -1
  4. package/src/agents/claude-adapter.ts +3 -3
  5. package/src/agents/codex-adapter.ts +3 -3
  6. package/src/agents/gateway-adapter.ts +3 -3
  7. package/src/agents/grok-adapter.ts +3 -3
  8. package/src/agents/start-claude-headless-run.ts +1 -1
  9. package/src/client/boot-daemon.ts +2 -2
  10. package/src/client/collect-agent-picks.ts +1 -1
  11. package/src/client/daemon-client.ts +7 -23
  12. package/src/client/format-overlay-agent-mark.ts +1 -1
  13. package/src/client/index.ts +4 -2
  14. package/src/client/pick-tab-target.ts +2 -2
  15. package/src/client/spawn-picker.ts +1 -1
  16. package/src/client/to-mirror-session.ts +3 -3
  17. package/src/client/ui.ts +3 -3
  18. package/src/daemon/build-imp-provider.ts +70 -23
  19. package/src/daemon/build-message-trail-entry.ts +1 -1
  20. package/src/daemon/build-report-trail-entry.ts +1 -1
  21. package/src/daemon/build-scoped-context.ts +1 -1
  22. package/src/daemon/build-session-message-event.ts +1 -1
  23. package/src/daemon/daemon-connection.ts +9 -253
  24. package/src/daemon/daemon-context.ts +240 -0
  25. package/src/daemon/daemon.ts +9 -5
  26. package/src/daemon/execution-provider.ts +12 -0
  27. package/src/daemon/hooks.ts +6 -24
  28. package/src/daemon/idempotency-ledger.ts +78 -18
  29. package/src/daemon/imp-client-port.ts +83 -27
  30. package/src/daemon/imp-harness.ts +41 -0
  31. package/src/daemon/imp-provider.ts +5 -11
  32. package/src/daemon/local-pty-provider.ts +45 -0
  33. package/src/daemon/parse-hook-line.ts +1 -1
  34. package/src/daemon/read-imp-token.ts +30 -0
  35. package/src/daemon/sessions.ts +134 -107
  36. package/src/daemon/start-headless-turn.ts +3 -3
  37. package/src/daemon/start-session-bridge.ts +2 -2
  38. package/src/mcp/collect-clients.ts +1 -1
  39. package/src/mcp/collect-grants.ts +1 -1
  40. package/src/mcp/find-client-name.ts +1 -1
  41. package/src/mcp/mcp-auth-schema.ts +37 -0
  42. package/src/mcp/open-mcp-auth.ts +2 -2
  43. package/src/mcp/reconnecting-caller.ts +15 -6
  44. package/src/mcp/remove-client.ts +1 -1
  45. package/src/mcp/revoke-grant.ts +1 -1
  46. package/src/mcp/types.ts +0 -38
  47. package/src/mcp-http-server.ts +2 -1
  48. package/src/mcp-server.ts +3 -14
  49. package/src/protocol/adapter-event.ts +25 -0
  50. package/src/protocol/count-session-states.ts +16 -0
  51. package/src/protocol/daemon-channel.ts +17 -0
  52. package/src/protocol/hook-event.ts +11 -0
  53. package/src/protocol/line-decoder.ts +34 -0
  54. package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
  55. package/src/protocol/pinned-group-key.ts +2 -0
  56. package/src/protocol/session-state.ts +5 -0
  57. package/src/protocol/sort-grouped-session-views.ts +29 -0
  58. package/src/protocol/sort-session-views.ts +41 -0
  59. package/src/protocol/sortable-session-view.ts +13 -0
  60. package/src/report.ts +1 -1
  61. package/src/run-bridge-tap.ts +2 -2
  62. package/src/shared/agent-id.ts +6 -0
  63. package/src/shared/collect-gateways.ts +1 -1
  64. package/src/shared/to-agent-id.ts +11 -0
  65. package/src/statusline.ts +1 -1
  66. package/src/store/fleet-entry.ts +6 -3
  67. package/src/store/state-store.ts +80 -19
  68. /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
  69. /package/src/{store → shared}/bun-sqlite-driver.ts +0 -0
  70. /package/src/{agents → shared}/truncate-detail.ts +0 -0
  71. /package/src/{daemon → shared}/truncate-summary.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.14.0",
3
+ "version": "2.15.1",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -24,6 +24,7 @@
24
24
  "build:atc-bridge": "bun scripts/build-atc-bridge.ts && oxfmt src/agents/atc-bridge-files.ts && format-codemod --quiet src/agents/atc-bridge-files.ts",
25
25
  "build:binaries": "bash scripts/build-binaries.sh",
26
26
  "audit": "bun audit",
27
+ "check:imports": "bun scripts/check-imports.ts",
27
28
  "deadcode": "knip",
28
29
  "lint": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error",
29
30
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
@@ -1,24 +1,9 @@
1
- import type { HookEvent } from '../daemon/hooks';
1
+ import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import type { HookEvent } from '../protocol/hook-event';
3
+ import type { AgentID } from '../shared/agent-id';
2
4
  import type { AgentSessionID } from '../shared/agent-session-id';
3
5
  import type { SessionID } from '../shared/session-id';
4
6
 
5
- /**
6
- * Which agent a session runs under: the key the adapter registry is looked
7
- * up by. Every agent CLI supplies one, and so does every configured backend
8
- * that drives a CLI it does not own, so two ids can share one kind.
9
- */
10
- export type AgentID = string;
11
-
12
- /**
13
- * Missing and empty values become Claude so a fleet written before the agent
14
- * column still restores as Claude. Any other string is returned as it stands,
15
- * registered or not: an id whose adapter is gone must reach the caller intact
16
- * so the session can be shown and refused, never quietly run as Claude.
17
- */
18
- export function toAgentID(raw: unknown): AgentID {
19
- return typeof raw === 'string' && raw !== '' ? raw : 'claude';
20
- }
21
-
22
7
  export interface SpawnOptions {
23
8
  readonly prompt: string;
24
9
 
@@ -64,26 +49,6 @@ export interface GuestSpawnPlan extends SpawnPlan {
64
49
  readonly files: Readonly<Record<string, string>>;
65
50
  }
66
51
 
67
- export interface AdapterEvent {
68
- kind: 'started' | 'needs-input' | 'turn-done' | 'prompt-submitted' | 'ended' | 'heartbeat';
69
- agentSessionID?: AgentSessionID;
70
- message?: string;
71
-
72
- // Fuller activity text than message: what the agent last said or was
73
- // asked, for briefing. Bounded by the adapter.
74
- detail?: string;
75
-
76
- // Opaque handle the adapter can later pull a session name from.
77
- nameSource?: string;
78
-
79
- // Claude resume-existence path. Distinct from nameSource: a naming
80
- // handle is not a resume gate.
81
- transcriptSource?: string;
82
-
83
- // The agent's whole final message for a finished turn; detail holds a bounded preview of it.
84
- result?: string;
85
- }
86
-
87
52
  export interface TranscriptToolUse {
88
53
  readonly name: string;
89
54
  readonly input: string;
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { AgentID } from './agent-adapter';
2
+ import type { AgentID } from '../shared/agent-id';
3
3
  import { buildCLICommand } from './build-cli-command';
4
4
 
5
5
  /**
@@ -1,14 +1,15 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { z } from 'zod';
3
- import type { HookEvent } from '../daemon/hooks';
3
+ import type { AdapterEvent } from '../protocol/adapter-event';
4
+ import type { HookEvent } from '../protocol/hook-event';
4
5
  import type { AgentSessionID } from '../shared/agent-session-id';
5
6
  import { buildOptionalString } from '../shared/build-optional-string';
6
7
  import type { Config } from '../shared/config';
7
8
  import { isRecord } from '../shared/report';
8
9
  import { toAgentSessionID } from '../shared/to-agent-session-id';
9
10
  import { toShellArg } from '../shared/to-shell-arg';
11
+ import { truncateDetail } from '../shared/truncate-detail';
10
12
  import type {
11
- AdapterEvent,
12
13
  AgentAdapter,
13
14
  AgentProfile,
14
15
  GuestPaths,
@@ -31,7 +32,6 @@ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
31
32
  import { parseClaudeTranscriptLine } from './parse-claude-transcript-line';
32
33
  import { planTypedLineInput } from './plan-typed-line-input';
33
34
  import { resolveClaudePermissionMode } from './resolve-claude-permission-mode';
34
- import { truncateDetail } from './truncate-detail';
35
35
  import { writeATCBridge } from './write-atc-bridge';
36
36
  import { writeHookSettings } from './write-hook-settings';
37
37
 
@@ -1,15 +1,16 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import type { HookEvent } from '../daemon/hooks';
4
+ import type { AdapterEvent } from '../protocol/adapter-event';
5
+ import type { HookEvent } from '../protocol/hook-event';
5
6
  import type { AgentSessionID } from '../shared/agent-session-id';
6
7
  import { buildOptionalString } from '../shared/build-optional-string';
7
8
  import type { Config } from '../shared/config';
8
9
  import { isRecord } from '../shared/report';
9
10
  import { toAgentSessionID } from '../shared/to-agent-session-id';
10
11
  import { toShellArg } from '../shared/to-shell-arg';
12
+ import { truncateDetail } from '../shared/truncate-detail';
11
13
  import type {
12
- AdapterEvent,
13
14
  AgentAdapter,
14
15
  AgentProfile,
15
16
  NameUpdate,
@@ -22,7 +23,6 @@ import { buildArgsWithoutFlags } from './build-args-without-flags';
22
23
  import { findFlagValue } from './find-flag-value';
23
24
  import { planPastedLineInput } from './plan-pasted-line-input';
24
25
  import { resolveAgentHome } from './resolve-agent-home';
25
- import { truncateDetail } from './truncate-detail';
26
26
 
27
27
  // Codex's hook payload keys, snake_case. An absent or wrong-typed field
28
28
  // parses to undefined rather than failing the payload, so a broken reporter
@@ -1,12 +1,12 @@
1
- import type { HookEvent } from '../daemon/hooks';
1
+ import type { AdapterEvent } from '../protocol/adapter-event';
2
+ import type { HookEvent } from '../protocol/hook-event';
3
+ import type { AgentID } from '../shared/agent-id';
2
4
  import type { AgentSessionID } from '../shared/agent-session-id';
3
5
  import type { GatewayConfig } from '../shared/collect-gateways';
4
6
  import type { Config } from '../shared/config';
5
7
  import { toShellArg } from '../shared/to-shell-arg';
6
8
  import type {
7
- AdapterEvent,
8
9
  AgentAdapter,
9
- AgentID,
10
10
  AgentProfile,
11
11
  HeadlessRunner,
12
12
  NameUpdate,
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
- import type { HookEvent } from '../daemon/hooks';
4
+ import type { AdapterEvent } from '../protocol/adapter-event';
5
+ import type { HookEvent } from '../protocol/hook-event';
5
6
  import type { AgentSessionID } from '../shared/agent-session-id';
6
7
  import { buildOptionalString } from '../shared/build-optional-string';
7
8
  import type { Config } from '../shared/config';
@@ -9,8 +10,8 @@ import { isRecord } from '../shared/report';
9
10
  import type { SessionID } from '../shared/session-id';
10
11
  import { toAgentSessionID } from '../shared/to-agent-session-id';
11
12
  import { toShellArg } from '../shared/to-shell-arg';
13
+ import { truncateDetail } from '../shared/truncate-detail';
12
14
  import type {
13
- AdapterEvent,
14
15
  AgentAdapter,
15
16
  AgentProfile,
16
17
  NameUpdate,
@@ -22,7 +23,6 @@ import type {
22
23
  import { normalizeHookEventName } from './normalize-hook-event';
23
24
  import { planPastedLineInput } from './plan-pasted-line-input';
24
25
  import { resolveAgentHome } from './resolve-agent-home';
25
- import { truncateDetail } from './truncate-detail';
26
26
 
27
27
  interface GrokSessionHookState {
28
28
  latestPromptID?: string;
@@ -1,6 +1,6 @@
1
- import { truncateSummary } from '../daemon/truncate-summary';
2
1
  import { isCompiledBinary } from '../shared/is-compiled-binary';
3
2
  import { isRecord } from '../shared/report';
3
+ import { truncateSummary } from '../shared/truncate-summary';
4
4
  import { buildClaudeQueryOptions } from './build-claude-query-options';
5
5
  import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
6
6
 
@@ -2,17 +2,17 @@ import { spawn as spawnChild } from 'node:child_process';
2
2
  import type { ChildProcess } from 'node:child_process';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { toAgentID } from '../agents/agent-adapter';
6
- import type { AgentID } from '../agents/agent-adapter';
7
5
  import { DaemonError } from '../protocol/daemon-error';
8
6
  import type { DaemonFeature } from '../protocol/daemon-features';
9
7
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
10
8
  import { PROTOCOL_V } from '../protocol/protocol';
9
+ import type { AgentID } from '../shared/agent-id';
11
10
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
12
11
  import { findDaemonRecord } from '../shared/find-daemon-record';
13
12
  import { getBuild } from '../shared/get-build';
14
13
  import { isCompiledBinary } from '../shared/is-compiled-binary';
15
14
  import { makeSingleFlight } from '../shared/make-single-flight';
15
+ import { toAgentID } from '../shared/to-agent-id';
16
16
  import { DaemonClient } from './daemon-client';
17
17
  import { formatProtocolMismatch } from './format-protocol-mismatch';
18
18
  import type { ProtocolMismatch } from './format-protocol-mismatch';
@@ -1,4 +1,4 @@
1
- import type { AgentID } from '../agents/agent-adapter';
1
+ import type { AgentID } from '../shared/agent-id';
2
2
  import type { Config } from '../shared/config';
3
3
 
4
4
  export interface AgentPick {
@@ -1,4 +1,6 @@
1
+ import type { DaemonChannel } from '../protocol/daemon-channel';
1
2
  import { DaemonError } from '../protocol/daemon-error';
3
+ import { LineDecoder } from '../protocol/line-decoder';
2
4
  import { OutboundQueue } from '../protocol/outbound-queue';
3
5
  import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
4
6
  import type { EventMsg, ResponseMsg } from '../protocol/protocol';
@@ -13,16 +15,14 @@ interface Pending {
13
15
  * event callback. `open` connects the socket; `sendHello` completes the
14
16
  * handshake and must come first.
15
17
  */
16
- export class DaemonClient {
18
+ export class DaemonClient implements DaemonChannel {
17
19
  onEvent: (event: EventMsg) => void = () => {};
18
20
 
19
21
  onClose: () => void = () => {};
20
22
 
21
23
  private queue: OutboundQueue | null = null;
22
24
 
23
- private buffer = '';
24
-
25
- private readonly decoder = new TextDecoder();
25
+ private readonly lines = new LineDecoder();
26
26
 
27
27
  private nextID = 1;
28
28
 
@@ -41,7 +41,7 @@ export class DaemonClient {
41
41
  unix: socketPath,
42
42
  socket: {
43
43
  data(_s, buf) {
44
- client.applyChunk(client.decodeChunk(buf));
44
+ client.applyChunk(buf);
45
45
  },
46
46
  drain() {
47
47
  client.queue?.drain();
@@ -99,25 +99,9 @@ export class DaemonClient {
99
99
  this.drainPending('client closed');
100
100
  }
101
101
 
102
- // Decodes with state kept across reads, so a multi-byte character split
103
- // between two reads decodes whole.
104
102
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
105
- private decodeChunk(buf: Uint8Array): string {
106
- return this.decoder.decode(buf, { stream: true });
107
- }
108
-
109
- private applyChunk(chunk: string): void {
110
- this.buffer += chunk;
111
-
112
- const lines = this.buffer.split('\n');
113
-
114
- this.buffer = lines.pop() ?? '';
115
-
116
- for (const line of lines) {
117
- if (line.trim() === '') {
118
- continue;
119
- }
120
-
103
+ private applyChunk(buf: Uint8Array): void {
104
+ for (const line of this.lines.splitChunk(buf)) {
121
105
  this.applyLine(line);
122
106
  }
123
107
  }
@@ -1,4 +1,4 @@
1
- import type { AgentID } from '../agents/agent-adapter';
1
+ import type { AgentID } from '../shared/agent-id';
2
2
 
3
3
  const BUILT_IN_MARKS: Readonly<Record<AgentID, string>> = { grok: 'g', codex: 'x' };
4
4
 
@@ -1,9 +1,11 @@
1
1
  import { match } from 'ts-pattern';
2
2
  import { createActor } from 'xstate';
3
- import type { AgentID } from '../agents/agent-adapter';
4
- import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
3
+ import { countSessionStates } from '../protocol/count-session-states';
5
4
  import { DaemonError } from '../protocol/daemon-error';
6
5
  import type { EventMsg } from '../protocol/protocol';
6
+ import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
7
+ import { sortSessionViews } from '../protocol/sort-session-views';
8
+ import type { AgentID } from '../shared/agent-id';
7
9
  import { loadConfig } from '../shared/config';
8
10
  import { makeSingleFlight } from '../shared/make-single-flight';
9
11
  import { bootDaemonClient } from './boot-daemon';
@@ -1,5 +1,5 @@
1
- import type { SessionState } from '../daemon/sessions';
2
- import { sortSessionViews } from '../daemon/sessions';
1
+ import type { SessionState } from '../protocol/session-state';
2
+ import { sortSessionViews } from '../protocol/sort-session-views';
3
3
 
4
4
  interface TabCandidate {
5
5
  readonly id: string;
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
- import type { AgentID } from '../agents/agent-adapter';
3
2
  import { DaemonError } from '../protocol/daemon-error';
3
+ import type { AgentID } from '../shared/agent-id';
4
4
  import { loadConfig } from '../shared/config';
5
5
  import { collectAgentPicks } from './collect-agent-picks';
6
6
  import type { AgentPick } from './collect-agent-picks';
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
- import { toAgentID } from '../agents/agent-adapter';
3
- import type { AgentID } from '../agents/agent-adapter';
4
- import type { SessionState } from '../daemon/sessions';
2
+ import type { SessionState } from '../protocol/session-state';
3
+ import type { AgentID } from '../shared/agent-id';
4
+ import { toAgentID } from '../shared/to-agent-id';
5
5
 
6
6
  export interface MirrorSession {
7
7
  id: string;
package/src/client/ui.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { AgentID } from '../agents/agent-adapter';
2
- import { PINNED_GROUP_KEY } from '../daemon/sessions';
3
- import type { SessionState } from '../daemon/sessions';
1
+ import { PINNED_GROUP_KEY } from '../protocol/pinned-group-key';
2
+ import type { SessionState } from '../protocol/session-state';
3
+ import type { AgentID } from '../shared/agent-id';
4
4
  import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
5
5
  import { formatDir } from './dirs';
6
6
  import { formatOverlayAgentMark } from './format-overlay-agent-mark';
@@ -1,9 +1,10 @@
1
1
  import { ImpClientPort } from './imp-client-port';
2
2
  import { ImpProvider } from './imp-provider';
3
+ import { readImpToken } from './read-imp-token';
3
4
 
4
5
  /**
5
6
  * 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
+ * A problem never includes a token or any other environment value.
7
8
  */
8
9
  export interface ImpProviderBuild {
9
10
  readonly provider: ImpProvider | null;
@@ -12,13 +13,16 @@ export interface ImpProviderBuild {
12
13
 
13
14
  /**
14
15
  * 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.
16
+ * listens, and at most one of `tokenEnv` and `tokenFile` gives the token,
17
+ * so no credential sits in the config. `tokenEnv` holds the name of the
18
+ * daemon's environment variable that carries the token, read once here.
19
+ * `tokenFile` holds the path of a file that carries it, read here and again
20
+ * before each impd call and connection. `image`, `memoryMib`, `guestDir`,
21
+ * and `guestATC` pass through. No provider when `url` is missing, so the
22
+ * target lists and refuses every spawn. Both token options, either one not
23
+ * a non-empty string, an unset or empty variable, or an empty or unreadable
24
+ * file is a problem and leaves no provider; a target without either calls
25
+ * impd with no token.
22
26
  */
23
27
  export function buildImpProvider(
24
28
  id: string,
@@ -26,29 +30,18 @@ export function buildImpProvider(
26
30
  env: Readonly<Record<string, string | undefined>> = process.env,
27
31
  ): ImpProviderBuild {
28
32
  const url = options['url'];
29
- const tokenEnv = options['tokenEnv'];
30
33
 
31
34
  if (typeof url !== 'string' || url === '') {
32
35
  return { provider: null, problem: null };
33
36
  }
34
37
 
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];
38
+ const source = loadTokenSource(id, options, env);
43
39
 
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
- };
40
+ if (typeof source === 'string') {
41
+ return { provider: null, problem: source };
49
42
  }
50
43
 
51
- const provider = new ImpProvider(new ImpClientPort({ url, token }), {
44
+ const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
52
45
  ...pickString(options, 'image'),
53
46
  ...pickString(options, 'guestDir'),
54
47
  ...pickString(options, 'guestATC'),
@@ -58,6 +51,60 @@ export function buildImpProvider(
58
51
  return { provider, problem: null };
59
52
  }
60
53
 
54
+ // How the port reads the token: a function, so a file token rereads.
55
+ interface TokenSource {
56
+ readonly readToken: () => string | null;
57
+ }
58
+
59
+ // The token source the options give, or the problem that keeps the target
60
+ // from one. A problem holds a variable's name or a file's path, never a
61
+ // token.
62
+ function loadTokenSource(
63
+ id: string,
64
+ options: Readonly<Record<string, unknown>>,
65
+ env: Readonly<Record<string, string | undefined>>,
66
+ ): TokenSource | string {
67
+ const tokenEnv = options['tokenEnv'];
68
+ const tokenFile = options['tokenFile'];
69
+ const target = JSON.stringify(id);
70
+
71
+ if (tokenEnv !== undefined && tokenFile !== undefined) {
72
+ return `target ${target} must give its impd token through tokenEnv or tokenFile, not both`;
73
+ }
74
+
75
+ if (tokenFile !== undefined) {
76
+ if (typeof tokenFile !== 'string' || tokenFile === '') {
77
+ return `target ${target} must give tokenFile as a non-empty string`;
78
+ }
79
+
80
+ try {
81
+ readImpToken(tokenFile);
82
+ } catch (error) {
83
+ const detail = error instanceof Error ? error.message : String(error);
84
+
85
+ return `target ${target} reads its impd token from a file it cannot use: ${detail}`;
86
+ }
87
+
88
+ return { readToken: () => readImpToken(tokenFile) };
89
+ }
90
+
91
+ if (tokenEnv === undefined) {
92
+ return { readToken: () => null };
93
+ }
94
+
95
+ if (typeof tokenEnv !== 'string' || tokenEnv === '') {
96
+ return `target ${target} must give tokenEnv as a non-empty string`;
97
+ }
98
+
99
+ const token = env[tokenEnv];
100
+
101
+ if (token === undefined || token === '') {
102
+ return `target ${target} reads its impd token from ${tokenEnv}, which is unset or empty in the daemon's environment`;
103
+ }
104
+
105
+ return { readToken: () => token };
106
+ }
107
+
61
108
  function pickString(
62
109
  options: Readonly<Record<string, unknown>>,
63
110
  key: 'image' | 'guestDir' | 'guestATC',
@@ -1,6 +1,6 @@
1
- import { truncateDetail } from '../agents/truncate-detail';
2
1
  import type { AgentSessionID } from '../shared/agent-session-id';
3
2
  import type { SessionID } from '../shared/session-id';
3
+ import { truncateDetail } from '../shared/truncate-detail';
4
4
  import type { MessageRecord, MessageStatus } from '../store/message-record';
5
5
  import type { TrailEntry } from '../store/trail-entry';
6
6
 
@@ -1,6 +1,6 @@
1
- import { truncateDetail } from '../agents/truncate-detail';
2
1
  import type { AgentSessionID } from '../shared/agent-session-id';
3
2
  import type { SessionID } from '../shared/session-id';
3
+ import { truncateDetail } from '../shared/truncate-detail';
4
4
  import type { TrailEntry } from '../store/trail-entry';
5
5
  import type { NoteReport } from './parse-report';
6
6
 
@@ -3,7 +3,7 @@ import type { SessionID } from '../shared/session-id';
3
3
  import { toSessionID } from '../shared/to-session-id';
4
4
  import { buildTargetForbiddenError } from './build-target-forbidden-error';
5
5
  import { buildTargetIdentity } from './build-target-identity';
6
- import type { DaemonContext } from './daemon-connection';
6
+ import type { DaemonContext } from './daemon-context';
7
7
  import type { KeyedRequest } from './idempotency-ledger';
8
8
  import type { TargetAccess, TargetGrant } from './target-access';
9
9
 
@@ -1,7 +1,7 @@
1
- import { truncateDetail } from '../agents/truncate-detail';
2
1
  import { PROTOCOL_V } from '../protocol/protocol';
3
2
  import type { EventMsg } from '../protocol/protocol';
4
3
  import type { SessionID } from '../shared/session-id';
4
+ import { truncateDetail } from '../shared/truncate-detail';
5
5
  import type { MessageRecord } from '../store/message-record';
6
6
 
7
7
  /**