@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,85 @@
1
+ /**
2
+ * One file in an archive: its path relative to the directory it unpacks
3
+ * into, its bytes, and its permission bits.
4
+ */
5
+ export interface ArchiveFile {
6
+ readonly path: string;
7
+ readonly content: string | Uint8Array;
8
+ readonly mode?: number;
9
+ }
10
+
11
+ const BLOCK = 512;
12
+
13
+ /**
14
+ * Packs files into a POSIX ustar archive that `tar -x` unpacks, each with
15
+ * its parent directories created on the way. A path longer than ustar
16
+ * holds is refused.
17
+ */
18
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- file bytes have no readonly form
19
+ export function buildTarArchive(files: readonly ArchiveFile[]): Uint8Array {
20
+ const encoder = new TextEncoder();
21
+
22
+ const blocks: Uint8Array[] = [];
23
+
24
+ for (const file of files) {
25
+ const content = typeof file.content === 'string' ? encoder.encode(file.content) : file.content;
26
+
27
+ blocks.push(
28
+ buildHeader(file.path, content.length, file.mode ?? 0o644),
29
+ content,
30
+ new Uint8Array((BLOCK - (content.length % BLOCK)) % BLOCK),
31
+ );
32
+ }
33
+
34
+ // Two empty blocks end the archive.
35
+ blocks.push(new Uint8Array(BLOCK * 2));
36
+
37
+ const archive = new Uint8Array(blocks.reduce((total, block) => total + block.length, 0));
38
+
39
+ let at = 0;
40
+
41
+ for (const block of blocks) {
42
+ archive.set(block, at);
43
+
44
+ at += block.length;
45
+ }
46
+
47
+ return archive;
48
+ }
49
+
50
+ function buildHeader(path: string, size: number, mode: number): Uint8Array {
51
+ const encoder = new TextEncoder();
52
+
53
+ const name = encoder.encode(path);
54
+
55
+ if (name.length > 100) {
56
+ throw new Error(`archive path is longer than 100 bytes: ${path}`);
57
+ }
58
+
59
+ const header = new Uint8Array(BLOCK);
60
+
61
+ header.set(name, 0);
62
+ header.set(encoder.encode(formatOctal(mode, 8)), 100);
63
+ header.set(encoder.encode(formatOctal(0, 8)), 108);
64
+ header.set(encoder.encode(formatOctal(0, 8)), 116);
65
+ header.set(encoder.encode(formatOctal(size, 12)), 124);
66
+ header.set(encoder.encode(formatOctal(0, 12)), 136);
67
+
68
+ // The checksum counts its own field as spaces.
69
+ header.set(encoder.encode(' '), 148);
70
+
71
+ header[156] = '0'.codePointAt(0) ?? 0;
72
+
73
+ header.set(encoder.encode('ustar\u000000'), 257);
74
+
75
+ const checksum = header.reduce((total, byte) => total + byte, 0);
76
+
77
+ header.set(encoder.encode(`${checksum.toString(8).padStart(6, '0')}\u0000 `), 148);
78
+
79
+ return header;
80
+ }
81
+
82
+ // An octal number padded to fill its field, ending in a NUL.
83
+ function formatOctal(value: number, width: number): string {
84
+ return `${value.toString(8).padStart(width - 1, '0')}\u0000`;
85
+ }
@@ -1,4 +1,4 @@
1
- import { basename } from 'node:path';
1
+ import { basename, isAbsolute } from 'node:path';
2
2
  import type { AgentAdapter, AgentID, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
3
3
  import { DaemonError } from '../protocol/daemon-error';
4
4
  import { DAEMON_FEATURES } from '../protocol/daemon-features';
@@ -15,6 +15,7 @@ import {
15
15
  encodeMessage,
16
16
  } from '../protocol/protocol';
17
17
  import type { ErrorCode, EventMsg, RequestMsg } from '../protocol/protocol';
18
+ import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
18
19
  import type { TargetConfigError } from '../shared/collect-targets';
19
20
  import type { DaemonID } from '../shared/daemon-id';
20
21
  import type { MessageID } from '../shared/message-id';
@@ -49,6 +50,10 @@ export interface SpawnParams {
49
50
  readonly parent: SessionID | null;
50
51
  readonly overrides: SpawnOverrides;
51
52
  readonly target: string;
53
+
54
+ // Where the session's working directory comes from; null runs the
55
+ // session in cwd as it stands.
56
+ readonly workspace: SpawnWorkspaceSource | null;
52
57
  }
53
58
 
54
59
  interface SessionRecord {
@@ -61,6 +66,15 @@ interface SessionRecord {
61
66
 
62
67
  // A transcript page with the file it came from, so a cursor into a replaced
63
68
  // transcript is distinguishable from one into a grown transcript.
69
+ /**
70
+ * What `session.forget` answers: the token a forget that destroys a host
71
+ * must carry, with the time it stops being taken; or the forgotten session,
72
+ * with whether its host was destroyed.
73
+ */
74
+ type ForgetResult =
75
+ | { readonly confirmToken: string; readonly expiresAt: number }
76
+ | { readonly forgotten: true; readonly destroyed: boolean };
77
+
64
78
  interface SessionTranscriptRead {
65
79
  readonly path: string;
66
80
  readonly page: TranscriptPage;
@@ -99,6 +113,10 @@ export interface DaemonContext {
99
113
  // without a target when the config gives no default.
100
114
  readonly resolveSpawnTarget: (requested: string | undefined) => string;
101
115
 
116
+ // Throws the refusal for a target that cannot materialize a workspace:
117
+ // one whose provider cannot both transfer an archive and run a command.
118
+ readonly requireWorkspaceTarget: (target: string) => void;
119
+
102
120
  // Runs the plan, which throws the refusal for a spawn it refuses, then
103
121
  // spawns. Answers with the `session.spawn` ok payload, which a keyed
104
122
  // retry replays as the first spawn answered it while the access, when
@@ -109,6 +127,13 @@ export interface DaemonContext {
109
127
  access: TargetAccess | null,
110
128
  ) => Promise<Readonly<Record<string, unknown>>>;
111
129
  readonly killSession: (id: SessionID) => Promise<boolean>;
130
+
131
+ // Forgets a session, or answers with the token a forget that destroys a
132
+ // host must carry. Throws the refusal for a token it does not take.
133
+ readonly forgetSession: (
134
+ id: SessionID,
135
+ confirmToken: string | undefined,
136
+ ) => Promise<ForgetResult | 'missing'>;
112
137
  readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
113
138
  readonly quitDaemon: () => void;
114
139
  readonly ackSession: (id: SessionID) => boolean;
@@ -127,6 +152,13 @@ export interface DaemonContext {
127
152
  sessionID: SessionID,
128
153
  data: string,
129
154
  ) => 'busy' | 'ok' | 'missing' | 'dead';
155
+
156
+ // Types a line into the session and submits it the way the session's
157
+ // agent accepts a line, as opposed to the raw bytes the input write takes.
158
+ readonly writeSessionLine: (
159
+ sessionID: SessionID,
160
+ text: string,
161
+ ) => 'busy' | 'ok' | 'missing' | 'dead';
130
162
  readonly ejectSession: (
131
163
  id: SessionID,
132
164
  prompt: string,
@@ -135,7 +167,7 @@ export interface DaemonContext {
135
167
  id: SessionID,
136
168
  cols: number,
137
169
  rows: number,
138
- ) => 'ok' | 'missing' | 'no_transcript';
170
+ ) => Promise<'ok' | 'missing' | 'no_transcript'>;
139
171
  readonly resizeSession: (client: OutputClient, sessionID: SessionID, dims: Dims) => boolean;
140
172
  readonly resyncClient: (sessionID: SessionID, client: OutputClient) => Promise<void>;
141
173
  readonly queueBytes?: number;
@@ -551,6 +583,27 @@ export class DaemonConnection {
551
583
 
552
584
  return;
553
585
  }
586
+ case 'session.forget': {
587
+ const parsed = parseRequestParams('session.forget', req.p);
588
+
589
+ if (!parsed.ok) {
590
+ this.sendErr(req.id, 'bad_args', parsed.message);
591
+
592
+ return;
593
+ }
594
+
595
+ const id = parsed.data.session;
596
+
597
+ const forgotten = await this.ctx.forgetSession(id, parsed.data.confirmToken);
598
+
599
+ if (forgotten === 'missing') {
600
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
601
+ } else {
602
+ this.sendOk(req.id, { ...forgotten });
603
+ }
604
+
605
+ return;
606
+ }
554
607
  case 'session.resumeCommand': {
555
608
  const parsed = parseRequestParams('session.resumeCommand', req.p);
556
609
 
@@ -636,7 +689,8 @@ export class DaemonConnection {
636
689
  }
637
690
 
638
691
  const sessionID = parsed.data.session;
639
- const adoptResult = ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
692
+
693
+ const adoptResult = await ctx.adoptSession(sessionID, parsed.data.cols, parsed.data.rows);
640
694
 
641
695
  if (adoptResult === 'ok') {
642
696
  this.sendOk(req.id, {});
@@ -680,6 +734,11 @@ export class DaemonConnection {
680
734
 
681
735
  return;
682
736
  }
737
+ case 'session.submit': {
738
+ this.applySubmit(req, ctx);
739
+
740
+ return;
741
+ }
683
742
  case 'session.resize': {
684
743
  this.applyResize(req, ctx);
685
744
 
@@ -787,6 +846,18 @@ export class DaemonConnection {
787
846
  throw new DaemonError(overrides.code, overrides.message);
788
847
  }
789
848
 
849
+ // Checked before the spawn starts, so a target that cannot take a
850
+ // workspace refuses it before any git command runs.
851
+ if (data.workspace !== undefined) {
852
+ this.ctx.requireWorkspaceTarget(target);
853
+ }
854
+
855
+ // A workspace is materialized at cwd on the target, which only an
856
+ // absolute path names.
857
+ if (data.workspace !== undefined && !isAbsolute(data.cwd)) {
858
+ throw new DaemonError('bad_args', 'a spawn with a workspace requires an absolute cwd');
859
+ }
860
+
790
861
  let parent: SessionID | null = null;
791
862
 
792
863
  if (data.parent !== undefined) {
@@ -813,6 +884,7 @@ export class DaemonConnection {
813
884
  parent,
814
885
  overrides: overrides.overrides,
815
886
  target,
887
+ workspace: data.workspace ?? null,
816
888
  };
817
889
  };
818
890
 
@@ -869,8 +941,29 @@ export class DaemonConnection {
869
941
  }
870
942
 
871
943
  const sessionID = parsed.data.session;
872
- const result = ctx.writeSessionInput(sessionID, parsed.data.d);
873
944
 
945
+ this.sendInputResult(req, sessionID, ctx.writeSessionInput(sessionID, parsed.data.d));
946
+ }
947
+
948
+ private applySubmit(req: RequestMsg, ctx: DaemonContext): void {
949
+ const parsed = parseRequestParams('session.submit', req.p);
950
+
951
+ if (!parsed.ok) {
952
+ this.sendErr(req.id, 'bad_args', parsed.message);
953
+
954
+ return;
955
+ }
956
+
957
+ const sessionID = parsed.data.session;
958
+
959
+ this.sendInputResult(req, sessionID, ctx.writeSessionLine(sessionID, parsed.data.text));
960
+ }
961
+
962
+ private sendInputResult(
963
+ req: RequestMsg,
964
+ sessionID: SessionID,
965
+ result: 'busy' | 'ok' | 'missing' | 'dead',
966
+ ): void {
874
967
  if (result === 'missing') {
875
968
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
876
969