@zgeoff/atc 2.12.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 (71) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/index.ts +50 -1
  23. package/src/client/ui.ts +8 -0
  24. package/src/daemon/build-execution-targets.ts +49 -10
  25. package/src/daemon/build-imp-provider.ts +68 -0
  26. package/src/daemon/build-report-trail-entry.ts +4 -2
  27. package/src/daemon/build-scoped-context.ts +4 -1
  28. package/src/daemon/build-session-lifecycle.ts +52 -0
  29. package/src/daemon/build-tar-archive.ts +85 -0
  30. package/src/daemon/daemon-connection.ts +97 -4
  31. package/src/daemon/daemon.ts +298 -41
  32. package/src/daemon/execution-provider.ts +109 -4
  33. package/src/daemon/hooks.ts +5 -17
  34. package/src/daemon/imp-client-port.ts +343 -0
  35. package/src/daemon/imp-harness.ts +618 -0
  36. package/src/daemon/imp-port-error.ts +18 -0
  37. package/src/daemon/imp-port.ts +246 -0
  38. package/src/daemon/imp-provider.ts +601 -0
  39. package/src/daemon/is-binding-current.ts +43 -0
  40. package/src/daemon/local-pty-provider.ts +62 -3
  41. package/src/daemon/materialize-workspace.ts +571 -0
  42. package/src/daemon/parse-hook-line.ts +31 -0
  43. package/src/daemon/pick-session-state.ts +15 -0
  44. package/src/daemon/restore-fleet.ts +41 -23
  45. package/src/daemon/screen-model.ts +7 -0
  46. package/src/daemon/sessions.ts +574 -61
  47. package/src/daemon/start-headless-turn.ts +1 -1
  48. package/src/daemon/start-session-bridge.ts +294 -0
  49. package/src/mcp/mcp-tools.ts +9 -2
  50. package/src/mcp/require-daemon-features.ts +3 -0
  51. package/src/mcp/run-tool.ts +27 -8
  52. package/src/protocol/daemon-features.ts +11 -0
  53. package/src/protocol/protocol.ts +23 -0
  54. package/src/protocol/request-param-schemas.ts +66 -0
  55. package/src/report.ts +37 -2
  56. package/src/run-bridge-tap.ts +241 -0
  57. package/src/shared/collect-clean-env.ts +9 -1
  58. package/src/shared/open-bridge-socket.ts +86 -0
  59. package/src/shared/send-bridge-request.ts +47 -0
  60. package/src/statusline.ts +34 -3
  61. package/src/store/fleet-entry.ts +19 -0
  62. package/src/store/run-migrations.ts +76 -0
  63. package/src/store/state-store.ts +182 -21
  64. package/src/store/trail-entry.ts +4 -0
  65. package/src/store/workspace-materialization.ts +70 -0
  66. package/src/tap.ts +11 -0
  67. package/src/workspace/check-url-credentials.ts +59 -0
  68. package/src/workspace/create-workspace-clone.ts +13 -1
  69. package/src/workspace/repository-env-vars.ts +22 -0
  70. package/src/workspace/run-git.ts +2 -18
  71. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -0,0 +1,46 @@
1
+ import type { HeadlessRunEvents, HeadlessRunner } from './agent-adapter';
2
+ import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
3
+
4
+ /**
5
+ * Runs one headless turn through the Claude CLI; the daemon command supplies
6
+ * the Agent SDK implementation, and tests supply a recorder.
7
+ */
8
+ export type ClaudeHeadlessRun = (
9
+ opts: ClaudeHeadlessRunRequest,
10
+ hooks: HeadlessRunEvents,
11
+ ) => { readonly stop: () => void };
12
+
13
+ /**
14
+ * Where and how a Claude CLI headless turn runs: the binary, the permission
15
+ * mode its adapter resolved, the mod folder, and, for a gateway, the
16
+ * settings file that carries its backend. The folder and file are written
17
+ * on first use.
18
+ */
19
+ interface ClaudeHeadlessTarget {
20
+ readonly claudeBin: string;
21
+ readonly permissionMode: string;
22
+ readonly pluginDir: () => string;
23
+ readonly settings?: () => string;
24
+ }
25
+
26
+ /**
27
+ * Turns the Claude CLI's headless run into the neutral runner the daemon
28
+ * calls. Every headless turn runs under the permission mode its adapter
29
+ * resolved for the agent's sessions.
30
+ */
31
+ export function makeClaudeHeadlessRunner(
32
+ run: ClaudeHeadlessRun,
33
+ target: ClaudeHeadlessTarget,
34
+ ): HeadlessRunner {
35
+ return (opts, hooks) =>
36
+ run(
37
+ {
38
+ ...opts,
39
+ claudeBin: target.claudeBin,
40
+ permissionMode: target.permissionMode,
41
+ pluginDir: target.pluginDir(),
42
+ ...(target.settings === undefined ? {} : { settings: target.settings() }),
43
+ },
44
+ hooks,
45
+ );
46
+ }
@@ -0,0 +1,22 @@
1
+ import type { TerminalInputModes } from './agent-adapter';
2
+
3
+ const PASTE_START = '\u001B[200~';
4
+ const PASTE_END = '\u001B[201~';
5
+
6
+ /**
7
+ * Pastes a line into a TUI that tells a paste from typing, then submits it
8
+ * with a carriage return as its own write. Such a TUI takes a newline inside
9
+ * a burst of input as part of the text, so a line typed with its newline
10
+ * stays unsent in the composer. Between bracketed paste markers the whole
11
+ * text is one paste event, newlines included, and the carriage return after
12
+ * the closing marker is a separate Enter key event however the two writes
13
+ * arrive. Paste markers inside the text are dropped, so the text can neither
14
+ * end the paste early nor start one of its own. A TUI that has not turned
15
+ * bracketed paste on gets the text unmarked, and may still read both writes
16
+ * as one burst.
17
+ */
18
+ export function planPastedLineInput(text: string, modes: TerminalInputModes): readonly string[] {
19
+ const unmarked = text.replaceAll(PASTE_START, '').replaceAll(PASTE_END, '');
20
+
21
+ return modes.bracketedPaste ? [`${PASTE_START}${unmarked}${PASTE_END}`, '\r'] : [unmarked, '\r'];
22
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Types a line the way a terminal user does: the text and a newline in one
3
+ * write. A TUI that reads input a line at a time takes the newline as the
4
+ * submit key.
5
+ */
6
+ export function planTypedLineInput(text: string): readonly string[] {
7
+ return [`${text}\n`];
8
+ }
@@ -0,0 +1,13 @@
1
+ import { findClaudePermissionMode } from './find-claude-permission-mode';
2
+
3
+ /**
4
+ * The permission mode a Claude CLI agent's headless turns run in: the mode
5
+ * its configuration sets, else auto, since no human is at a terminal to
6
+ * answer a prompt.
7
+ */
8
+ export function resolveClaudePermissionMode(
9
+ args: readonly string[],
10
+ settings: Readonly<Record<string, unknown>> | undefined,
11
+ ): string {
12
+ return findClaudePermissionMode(args, settings) ?? 'auto';
13
+ }
@@ -1,36 +1,8 @@
1
- import type { AgentSessionID } from '../shared/agent-session-id';
2
- import { socketPath } from '../shared/config';
1
+ import { truncateSummary } from '../daemon/truncate-summary';
3
2
  import { isCompiledBinary } from '../shared/is-compiled-binary';
4
3
  import { isRecord } from '../shared/report';
5
- import type { SessionID } from '../shared/session-id';
6
- import { buildHeadlessEnv } from './build-headless-env';
7
- import { resolveHeadlessExecutable } from './resolve-headless-executable';
8
- import { truncateSummary } from './truncate-summary';
9
-
10
- const PERMISSION_MODES = [
11
- 'default',
12
- 'acceptEdits',
13
- 'bypassPermissions',
14
- 'plan',
15
- 'auto',
16
- 'dontAsk',
17
- ] as const;
18
-
19
- // The effort levels the Agent SDK accepts.
20
- const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
21
-
22
- interface HeadlessRunOptions {
23
- readonly claudeBin: string;
24
- readonly cwd: string;
25
- readonly prompt: string;
26
- readonly resume?: AgentSessionID;
27
- readonly permissionMode?: string;
28
- readonly settings?: string;
29
- readonly sessionID?: SessionID;
30
- readonly pluginDir?: string;
31
- readonly model?: string;
32
- readonly effort?: string;
33
- }
4
+ import { buildClaudeQueryOptions } from './build-claude-query-options';
5
+ import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
34
6
 
35
7
  interface HeadlessRunHooks {
36
8
  readonly onOutput: (text: string) => void;
@@ -48,16 +20,14 @@ interface HeadlessRunHandle {
48
20
  * done (with the turn's whole result) or needs_you (errors, turn limits,
49
21
  * anything a human must look at).
50
22
  */
51
- export function startHeadlessRun(
52
- opts: HeadlessRunOptions,
23
+ export function startClaudeHeadlessRun(
24
+ opts: ClaudeHeadlessRunRequest,
53
25
  hooks: HeadlessRunHooks,
54
26
  ): HeadlessRunHandle {
55
27
  const controller = new AbortController();
56
28
 
57
29
  void (async () => {
58
30
  try {
59
- const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
60
- const effort = EFFORT_LEVELS.find((level) => level === opts.effort);
61
31
  let stderrTail = '';
62
32
 
63
33
  // The SDK is the heaviest module in the daemon's graph, and only a
@@ -68,28 +38,11 @@ export function startHeadlessRun(
68
38
  const stream = sdk.query({
69
39
  prompt: opts.prompt,
70
40
  options: {
71
- cwd: opts.cwd,
72
- env: buildHeadlessEnv({
73
- socketPath,
74
- ...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
75
- ...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
76
- }),
41
+ ...buildClaudeQueryOptions(opts, isCompiledBinary()),
77
42
  abortController: controller,
78
43
  stderr: (data: string) => {
79
44
  stderrTail = `${stderrTail}${data}`.slice(-2000);
80
45
  },
81
- ...(opts.resume === undefined ? {} : { resume: opts.resume }),
82
- ...(mode === undefined ? {} : { permissionMode: mode }),
83
-
84
- // The session's own model and effort, so the turn runs as its
85
- // terminal did.
86
- ...(opts.model === undefined ? {} : { model: opts.model }),
87
- ...(effort === undefined ? {} : { effort }),
88
-
89
- // The same generated file the terminal spawn passes, so the turn
90
- // runs against the session's own backend and instrumentation.
91
- ...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
92
- ...resolveHeadlessExecutable(opts.claudeBin, isCompiledBinary()),
93
46
  },
94
47
  });
95
48
 
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { stateDir } from '../shared/config';
4
- import { ATC_BRIDGE_FILES } from './atc-bridge-files';
4
+ import { buildATCBridgeFiles } from './build-atc-bridge-files';
5
5
  import { buildCLIArgv } from './build-cli-argv';
6
6
 
7
7
  /**
@@ -13,7 +13,7 @@ import { buildCLIArgv } from './build-cli-argv';
13
13
  * there.
14
14
  */
15
15
  export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): string {
16
- const files = { ...ATC_BRIDGE_FILES, 'hooks/atc-cli.ts': renderATCCLIModule(buildCLIArgv()) };
16
+ const files = buildATCBridgeFiles(buildCLIArgv());
17
17
 
18
18
  for (const [path, content] of Object.entries(files)) {
19
19
  const file = join(dir, path);
@@ -29,10 +29,6 @@ export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): stri
29
29
  return dir;
30
30
  }
31
31
 
32
- function renderATCCLIModule(argv: readonly string[]): string {
33
- return `export const ATC_CLI: readonly string[] = ${JSON.stringify(argv)};\n`;
34
- }
35
-
36
32
  function tryReadFileText(file: string): string | null {
37
33
  try {
38
34
  return readFileSync(file, 'utf8');
package/src/cli.ts CHANGED
@@ -159,15 +159,17 @@ const main = defineCommand({
159
159
  const grok = await import('./agents/grok-adapter');
160
160
  const codex = await import('./agents/codex-adapter');
161
161
  const gateway = await import('./agents/gateway-adapter');
162
- const headless = await import('./daemon/start-headless-run');
162
+ const headless = await import('./agents/start-claude-headless-run');
163
163
  const targets = await import('./daemon/build-execution-targets');
164
164
 
165
165
  // Test harnesses shrink the outbound queue to force overflow
166
166
  // deterministically; unset means the production default.
167
167
  const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
168
168
  const cfg = config.loadConfig();
169
+ const built = targets.buildExecutionTargets(cfg.targets);
170
+ const targetErrors = [...cfg.targetErrors, ...built.errors];
169
171
 
170
- for (const error of cfg.targetErrors) {
172
+ for (const error of targetErrors) {
171
173
  const line =
172
174
  error.scope === 'config'
173
175
  ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
@@ -192,18 +194,12 @@ const main = defineCommand({
192
194
  // to it is refused. Tests pin it to 0 to reach the refusal at once.
193
195
  const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
194
196
 
195
- const claudeAdapter = new claude.ClaudeAdapter(cfg, (runOpts, hooks) =>
196
- headless.startHeadlessRun({ ...runOpts, claudeBin: cfg.claudeBin }, hooks),
197
- );
198
-
197
+ const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
199
198
  const grokAdapter = new grok.GrokAdapter(cfg);
200
199
  const codexAdapter = new codex.CodexAdapter(cfg);
201
200
 
202
201
  const gatewayAdapters = cfg.gateways.map(
203
- (entry) =>
204
- new gateway.GatewayAdapter(entry, cfg, (runOpts, hooks) =>
205
- headless.startHeadlessRun({ ...runOpts, claudeBin: entry.bin }, hooks),
206
- ),
202
+ (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
207
203
  );
208
204
 
209
205
  let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
@@ -220,9 +216,9 @@ const main = defineCommand({
220
216
  legacyFleetPath: config.legacyFleetFile,
221
217
  pidPath: config.daemonPidFile,
222
218
  hooks: cfg.hooks,
223
- targets: targets.buildExecutionTargets(cfg.targets),
219
+ targets: built.targets,
224
220
  defaultTarget: cfg.defaultTarget,
225
- targetErrors: cfg.targetErrors,
221
+ targetErrors,
226
222
  principals: cfg.principals,
227
223
  restoreBootTimeoutMs,
228
224
  ...(Number.isFinite(graceOverride) && graceOverride >= 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
  }
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,5 @@
1
- import type { TargetConfig } from '../shared/collect-targets';
1
+ import type { TargetConfig, TargetConfigError } from '../shared/collect-targets';
2
+ import { buildImpProvider } from './build-imp-provider';
2
3
  import { buildTargetIdentity } from './build-target-identity';
3
4
  import type { ExecutionProvider } from './execution-provider';
4
5
  import { LocalPTYProvider } from './local-pty-provider';
@@ -19,16 +20,54 @@ export interface ExecutionTarget {
19
20
  readonly provider: ExecutionProvider | null;
20
21
  }
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
+
22
32
  /**
23
33
  * Builds one target per configured entry, in config order, each with a
24
- * provider of its kind when this daemon has one.
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.
25
36
  */
26
- export function buildExecutionTargets(configs: readonly TargetConfig[]): ExecutionTarget[] {
27
- return configs.map((config) => ({
28
- id: config.id,
29
- kind: config.provider,
30
- options: config.options,
31
- identity: buildTargetIdentity(config.provider, config.options),
32
- provider: config.provider === 'local-pty' ? new LocalPTYProvider() : null,
33
- }));
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 };
34
73
  }
@@ -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
+ }
@@ -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
  }
@@ -187,8 +187,11 @@ export function buildScopedContext(
187
187
  },
188
188
  writeSessionInput: (sessionID, data) =>
189
189
  canSee(sessionID) ? ctx.writeSessionInput(sessionID, data) : 'missing',
190
+ writeSessionLine: (sessionID, text) =>
191
+ canSee(sessionID) ? ctx.writeSessionLine(sessionID, text) : 'missing',
190
192
  ejectSession: (id, prompt) => (canSee(id) ? ctx.ejectSession(id, prompt) : 'missing'),
191
- adoptSession: (id, cols, rows) => (canSee(id) ? ctx.adoptSession(id, cols, rows) : 'missing'),
193
+ adoptSession: (id, cols, rows) =>
194
+ canSee(id) ? ctx.adoptSession(id, cols, rows) : Promise.resolve('missing' as const),
192
195
  resizeSession: (client, sessionID, dims) =>
193
196
  canSee(sessionID) && ctx.resizeSession(client, sessionID, dims),
194
197
  readSessionRecord: (id) =>
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A session's lifecycle as four layers, each owned by one actor:
3
+ *
4
+ * - `desired`: what the operator asked for. `run` keeps the harness
5
+ * running, `sleep` keeps its host asleep with the harness inside it, and
6
+ * `stop` leaves the harness ended.
7
+ * - `vm`: the host the harness runs on. `none` is the daemon's own machine,
8
+ * which has no lifecycle of its own; `awake`, `asleep`, and `unknown` are
9
+ * the last state the daemon saw of a remote host.
10
+ * - `harness`: the agent process. `suspended` is a process kept inside a
11
+ * sleeping host, which a revive brings back as it was.
12
+ * - `attachment`: the daemon's own connection to the harness's output.
13
+ * `local` is a harness in the daemon's process tree; `attached`,
14
+ * `reattaching`, and `detached` follow the connection to a remote one.
15
+ */
16
+ export interface SessionLifecycle {
17
+ readonly desired: 'run' | 'sleep' | 'stop';
18
+ readonly vm: 'none' | 'awake' | 'asleep' | 'unknown';
19
+ readonly harness: 'running' | 'suspended' | 'exited';
20
+ readonly attachment: 'local' | 'attached' | 'reattaching' | 'detached';
21
+ }
22
+
23
+ // The session facts the layers derive from.
24
+ interface LifecycleFacts {
25
+ readonly desired: SessionLifecycle['desired'];
26
+ readonly vm: SessionLifecycle['vm'];
27
+ readonly attachment: SessionLifecycle['attachment'];
28
+
29
+ // Whether the harness is kept inside a sleeping host.
30
+ readonly suspended: boolean;
31
+ readonly hasHarness: boolean;
32
+ readonly kind: 'pty' | 'headless';
33
+ readonly state: 'running' | 'needs_you' | 'done' | 'exited';
34
+ }
35
+
36
+ /**
37
+ * Builds the four layers from a session's facts. The harness runs while the
38
+ * session holds a terminal or a live headless run. A harness without one is
39
+ * suspended while it is kept inside a sleeping host, and exited otherwise.
40
+ */
41
+ export function buildSessionLifecycle(facts: Readonly<LifecycleFacts>): SessionLifecycle {
42
+ const running = facts.hasHarness || (facts.kind === 'headless' && facts.state !== 'exited');
43
+ let harness: SessionLifecycle['harness'] = 'exited';
44
+
45
+ if (running) {
46
+ harness = 'running';
47
+ } else if (facts.suspended && facts.vm === 'asleep') {
48
+ harness = 'suspended';
49
+ }
50
+
51
+ return { desired: facts.desired, vm: facts.vm, harness, attachment: facts.attachment };
52
+ }