@zgeoff/atc 3.4.1 → 3.5.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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +4 -0
  3. package/src/agents/build-claude-query-options.ts +1 -0
  4. package/src/agents/build-headless-env.ts +4 -0
  5. package/src/agents/claude-adapter.ts +4 -3
  6. package/src/agents/gateway-adapter.ts +2 -2
  7. package/src/agents/plan-claude-line-input.ts +44 -0
  8. package/src/client/daemon-client.ts +6 -1
  9. package/src/collect-restart-env.ts +6 -3
  10. package/src/daemon/build-scoped-context.ts +2 -0
  11. package/src/daemon/check-session-scope.ts +235 -0
  12. package/src/daemon/daemon-connection.ts +74 -0
  13. package/src/daemon/daemon-context.ts +21 -0
  14. package/src/daemon/daemon.ts +21 -0
  15. package/src/daemon/find-host-branch.ts +28 -0
  16. package/src/daemon/materialize-workspace.ts +6 -1
  17. package/src/daemon/merge-recorded-scope.ts +50 -0
  18. package/src/daemon/published-records.ts +269 -0
  19. package/src/daemon/request-access-classes.ts +1 -0
  20. package/src/daemon/run-host-git.ts +52 -0
  21. package/src/daemon/sessions.ts +185 -53
  22. package/src/daemon/start-headless-turn.ts +3 -0
  23. package/src/federation/id-rules.ts +11 -2
  24. package/src/mcp/mcp-tools.ts +51 -1
  25. package/src/mcp/require-daemon-features.ts +1 -0
  26. package/src/mcp/run-tool.ts +14 -0
  27. package/src/protocol/daemon-features.ts +5 -0
  28. package/src/protocol/parse-declared-scope.ts +158 -0
  29. package/src/protocol/protocol.ts +1 -0
  30. package/src/protocol/published-record.ts +62 -0
  31. package/src/protocol/request-param-schemas.ts +16 -0
  32. package/src/store/parse-published-record.ts +59 -0
  33. package/src/store/run-migrations.ts +23 -0
  34. package/src/store/state-store.ts +32 -0
  35. package/src/test-utils/build-mock-published-record.ts +39 -0
  36. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  37. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  38. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  39. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  40. package/src/test-utils/build-stub-held-gh.ts +5 -7
  41. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  42. package/src/test-utils/build-stub-held-zoxide.ts +4 -6
  43. package/src/test-utils/build-stub-host-hold.ts +7 -6
  44. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  45. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  46. package/src/test-utils/build-stub-terminal.ts +3 -3
  47. package/src/test-utils/create-git-fixture.ts +2 -2
  48. package/src/test-utils/create-stub-claude.ts +3 -1
  49. package/src/test-utils/create-stub-imp-port.ts +46 -37
  50. package/src/test-utils/create-stub-systemd.ts +6 -6
  51. package/src/test-utils/isolate-home.ts +8 -5
  52. package/src/test-utils/read-query-plan.ts +10 -6
  53. package/src/test-utils/register-test-cleanup.ts +10 -10
  54. package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +1 -1
  55. package/src/test-utils/setup-mcp-home.ts +7 -6
  56. package/src/test-utils/setup-mcp-http.ts +6 -6
  57. package/src/test-utils/setup-temp-dir.ts +4 -10
  58. package/src/test-utils/start-cut-proxy.ts +2 -4
  59. package/src/test-utils/start-daemon-process.ts +13 -13
  60. package/src/test-utils/start-git-http-server.ts +2 -4
  61. package/src/test-utils/start-mcp-stdio.ts +5 -5
  62. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  63. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  64. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  65. package/src/test-utils/start-stub-impd-info.ts +3 -7
  66. package/src/test-utils/start-stub-impd.ts +3 -5
  67. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  68. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  69. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  70. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  71. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  72. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  73. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  74. package/src/test-utils/start-stub-silent-server.ts +3 -6
  75. package/src/test-utils/start-stub-source-daemon.ts +3 -3
  76. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  77. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  78. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  79. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  80. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  81. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  82. package/src/test-utils/start-test-daemon.ts +62 -18
  83. package/src/test-utils/start-tui-harness.ts +17 -10
  84. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  85. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
  86. /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
@@ -12,7 +12,9 @@ import type { AdapterEvent } from '../protocol/adapter-event';
12
12
  import { countSessionStates } from '../protocol/count-session-states';
13
13
  import { DaemonError } from '../protocol/daemon-error';
14
14
  import type { HookEvent } from '../protocol/hook-event';
15
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
15
16
  import type { ErrorCode } from '../protocol/protocol';
17
+ import type { PublishedRecord } from '../protocol/published-record';
16
18
  import type { SessionState } from '../protocol/session-state';
17
19
  import { sortSessionViews } from '../protocol/sort-session-views';
18
20
  import type { AgentID } from '../shared/agent-id';
@@ -50,6 +52,7 @@ import { loadOAuthStates } from './load-oauth-states';
50
52
  import { LocalPTYProvider } from './local-pty-provider';
51
53
  import { mintSessionID } from './mint-session-id';
52
54
  import { pickSessionState } from './pick-session-state';
55
+ import type { PublishedRecords } from './published-records';
53
56
  import type { RuntimeAuthBinder } from './runtime-auth-binder';
54
57
 
55
58
  export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
@@ -191,6 +194,9 @@ export interface Session {
191
194
  // A session's ready workspace and the variables its harnesses go without.
192
195
  interface MaterializedSpawn {
193
196
  readonly workspace: SessionWorkspace;
197
+
198
+ // The branch the checkout is on, null for a detached checkout.
199
+ readonly branch: string | null;
194
200
  readonly withheldEnv: readonly string[];
195
201
  }
196
202
 
@@ -354,6 +360,10 @@ export class SessionManager {
354
360
  // from impd's broker; with none, every such start is refused.
355
361
  authBinder: RuntimeAuthBinder | null = null;
356
362
 
363
+ // Publishes each session's record before its harness starts; with none,
364
+ // sessions start without a record.
365
+ records: PublishedRecords | null = null;
366
+
357
367
  // Whether any registered adapter has a screen detector, decided once at
358
368
  // construction since the registry never changes afterward. Lets a hot path
359
369
  // that only cares about this answer skip walking live sessions to find it.
@@ -709,25 +719,48 @@ export class SessionManager {
709
719
  this.adopting.add(id);
710
720
 
711
721
  let plan: HarnessPlan;
722
+ let recordPath: string | null;
723
+ const resume = s.agentSessionID;
712
724
 
725
+ // The record is placed again while the host still counts as readying,
726
+ // so a host that lost its copy, such as one that booted cold, has it
727
+ // back before the harness starts.
713
728
  try {
714
- const setup = await this.setupHarness(
715
- adapter,
716
- provider,
717
- s.id,
718
- s.hostKey,
719
- s.target,
720
- {
721
- prompt: '',
722
- resume: s.agentSessionID,
723
- ...(s.model === undefined ? {} : { model: s.model }),
724
- ...(s.effort === undefined ? {} : { effort: s.effort }),
725
- },
726
- authSetup,
727
- s.cwd,
728
- );
729
+ const setup = await this.withHostReadying(s.hostKey, async () => {
730
+ const planned = await this.setupHarnessOnHost(
731
+ adapter,
732
+ provider,
733
+ s.id,
734
+ s.hostKey,
735
+ s.target,
736
+ {
737
+ prompt: '',
738
+ resume,
739
+ ...(s.model === undefined ? {} : { model: s.model }),
740
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
741
+ },
742
+ authSetup,
743
+ s.cwd,
744
+ false,
745
+ );
746
+
747
+ const path =
748
+ this.records === null
749
+ ? null
750
+ : await this.records.restoreCopy({
751
+ session: s.id,
752
+ target: s.target,
753
+ provider,
754
+ host: s.hostKey,
755
+ dir: s.cwd,
756
+ workspace: s.workspace ?? null,
757
+ });
758
+
759
+ return { plan: planned.plan, recordPath: path };
760
+ });
729
761
 
730
762
  plan = setup.plan;
763
+ recordPath = setup.recordPath;
731
764
  } finally {
732
765
  this.adopting.delete(id);
733
766
  }
@@ -753,7 +786,12 @@ export class SessionManager {
753
786
  bin: plan.bin,
754
787
  args: plan.args,
755
788
  cwd: s.cwd,
756
- env: { ...plan.env, ATC_SESSION_ID: s.id, ATC_SOCKET: socketPath },
789
+ env: {
790
+ ...plan.env,
791
+ ATC_SESSION_ID: s.id,
792
+ ATC_SOCKET: socketPath,
793
+ ...(recordPath === null ? {} : { ATC_SESSION_RECORD: recordPath }),
794
+ },
757
795
  withheldEnv: s.withheldEnv,
758
796
  cols,
759
797
  rows,
@@ -842,6 +880,67 @@ export class SessionManager {
842
880
  });
843
881
  }
844
882
 
883
+ // Adds a checked scope to a session's published record and returns the
884
+ // record as it stands after. The checks run on the session's host, so a
885
+ // remote host that is not awake refuses the change until a revive wakes
886
+ // it.
887
+ async updateScope(id: SessionID, scope: DeclaredScope): Promise<PublishedRecord | 'missing'> {
888
+ const s = this.sessions.find((x) => x.id === id);
889
+
890
+ if (s === undefined) {
891
+ return 'missing';
892
+ }
893
+
894
+ if (this.records === null) {
895
+ throw new DaemonError('unsupported', 'this daemon publishes no session records');
896
+ }
897
+
898
+ const provider = this.requireExecution(s, 'spawn').provider;
899
+
900
+ if (provider.remote && s.vm !== 'awake') {
901
+ throw new DaemonError(
902
+ 'host_unavailable',
903
+ `the host of session ${id} is not awake; revive the session before adding to its scope`,
904
+ { provider: provider.kind, problem: 'host_asleep', host: s.hostKey },
905
+ );
906
+ }
907
+
908
+ const subject = {
909
+ session: s.id,
910
+ target: s.target,
911
+ provider,
912
+ host: s.hostKey,
913
+ dir: s.cwd,
914
+ workspace: s.workspace ?? null,
915
+ };
916
+
917
+ const record = await this.records.updateScope(subject, scope);
918
+
919
+ return record;
920
+ }
921
+
922
+ // Whether the session is the caller or a session the caller sits under,
923
+ // following each parent link the fleet holds.
924
+ isCallerTree(id: SessionID, caller: SessionID): boolean {
925
+ let current: SessionID | null = caller;
926
+
927
+ const seen = new Set<SessionID>();
928
+
929
+ while (current !== null && !seen.has(current)) {
930
+ if (current === id) {
931
+ return true;
932
+ }
933
+
934
+ seen.add(current);
935
+
936
+ const at: SessionID = current;
937
+
938
+ current = this.sessions.find((x) => x.id === at)?.parent ?? null;
939
+ }
940
+
941
+ return false;
942
+ }
943
+
845
944
  /**
846
945
  * Renames and/or pins a session on a caller's behalf. A rename lands at
847
946
  * user strength, so auto-summaries stop overwriting it while an
@@ -957,6 +1056,7 @@ export class SessionManager {
957
1056
  materialize: SpawnMaterializer | null = null,
958
1057
  requireInReach: () => void = () => {},
959
1058
  autoDir = false,
1059
+ scope: DeclaredScope | null = null,
960
1060
  ): Promise<Session> {
961
1061
  const adapter = this.findAdapter(agent);
962
1062
 
@@ -1091,23 +1191,51 @@ export class SessionManager {
1091
1191
 
1092
1192
  // The host stays readying until its workspace is in place, so nothing
1093
1193
  // gives its lease back or puts it to sleep in between.
1194
+ // The record is published while the host still counts as readying, so
1195
+ // nothing sleeps it between the record and the harness start.
1196
+ const published: { path: string | null; failure: { readonly error: unknown } | null } = {
1197
+ path: null,
1198
+ failure: null,
1199
+ };
1200
+
1094
1201
  const prepared = await this.withHostReadying(hostKey, async () => {
1095
- if (materialize === null) {
1096
- return { setup: await setupHost(), materialized: null, root: null };
1202
+ const ready =
1203
+ materialize === null
1204
+ ? { setup: await setupHost(), materialized: null, root: null }
1205
+ : await this.materializeOnSpawnHost(
1206
+ provider,
1207
+ id,
1208
+ hostKey,
1209
+ target,
1210
+ cwd,
1211
+ execution.identity,
1212
+ materialize,
1213
+ setupHost,
1214
+ checkWorkspace,
1215
+ trustWorkspace,
1216
+ );
1217
+
1218
+ try {
1219
+ published.path =
1220
+ this.records === null
1221
+ ? null
1222
+ : await this.records.createRecord(
1223
+ {
1224
+ session: id,
1225
+ target,
1226
+ provider,
1227
+ host: hostKey,
1228
+ dir: autoDir && ready.root !== null ? ready.root : cwd,
1229
+ workspace: ready.materialized?.workspace ?? null,
1230
+ ...(ready.materialized === null ? {} : { branch: ready.materialized.branch }),
1231
+ },
1232
+ scope,
1233
+ );
1234
+ } catch (error) {
1235
+ published.failure = { error };
1097
1236
  }
1098
1237
 
1099
- return this.materializeOnSpawnHost(
1100
- provider,
1101
- id,
1102
- hostKey,
1103
- target,
1104
- cwd,
1105
- execution.identity,
1106
- materialize,
1107
- setupHost,
1108
- checkWorkspace,
1109
- trustWorkspace,
1110
- );
1238
+ return ready;
1111
1239
  });
1112
1240
 
1113
1241
  // A spawn whose directory the daemon picked runs in the one it claimed,
@@ -1116,6 +1244,17 @@ export class SessionManager {
1116
1244
  const setup = prepared.setup;
1117
1245
  const materialized = prepared.materialized;
1118
1246
  const readied: SpawnReadied = { attemptID: setup.attemptID, root: prepared.root };
1247
+
1248
+ // A scope that fails its check, or a record that cannot be placed,
1249
+ // takes back what the spawn readied before any harness starts.
1250
+ if (published.failure !== null) {
1251
+ await this.removeLocalTrust(localTrust.remove, id);
1252
+ await this.records?.remove(id);
1253
+ await this.removeFailedSpawnEffects(provider, id, hostKey, target, readied);
1254
+
1255
+ throw published.failure.error;
1256
+ }
1257
+
1119
1258
  const plan = setup.plan;
1120
1259
  const binding = this.mintBridgeBinding(id, target, execution.identity, hostKey);
1121
1260
  let pty: HarnessHandle;
@@ -1132,7 +1271,12 @@ export class SessionManager {
1132
1271
  bin: plan.bin,
1133
1272
  args: plan.args,
1134
1273
  cwd: dir,
1135
- env: { ...plan.env, ATC_SESSION_ID: id, ATC_SOCKET: socketPath },
1274
+ env: {
1275
+ ...plan.env,
1276
+ ATC_SESSION_ID: id,
1277
+ ATC_SOCKET: socketPath,
1278
+ ...(published.path === null ? {} : { ATC_SESSION_RECORD: published.path }),
1279
+ },
1136
1280
  withheldEnv: materialized?.withheldEnv ?? [],
1137
1281
  cols,
1138
1282
  rows,
@@ -1143,6 +1287,7 @@ export class SessionManager {
1143
1287
  });
1144
1288
  } catch (error) {
1145
1289
  await this.removeLocalTrust(localTrust.remove, id);
1290
+ await this.records?.remove(id);
1146
1291
  await this.removeFailedSpawnEffects(provider, id, hostKey, target, readied);
1147
1292
 
1148
1293
  throw error;
@@ -1937,28 +2082,6 @@ export class SessionManager {
1937
2082
  }
1938
2083
 
1939
2084
  // Readies the host a harness is about to start on and plans the harness.
1940
- // On a remote host the agent plans a guest spawn, whose files unpack into
1941
- // the session's own guest folder, and the agent's sign-in check runs
1942
- // there first. Every refusal comes before the harness starts. A harness
1943
- // behind the broker has its binding created or verified before the host
1944
- // is readied, and a binding this call created is taken back when a later
1945
- // step fails; the attempt that created it comes back with the plan. A
1946
- // spawn's new host of its own without a binding is destroyed instead.
1947
- private setupHarness(
1948
- adapter: AgentAdapter,
1949
- provider: ExecutionProvider,
1950
- id: SessionID,
1951
- hostKey: SessionID,
1952
- target: string,
1953
- options: SpawnOptions,
1954
- auth: HarnessAuthSetup | null,
1955
- dir: string,
1956
- ): Promise<{ readonly plan: HarnessPlan; readonly attemptID: string | null }> {
1957
- return this.withHostReadying(hostKey, () =>
1958
- this.setupHarnessOnHost(adapter, provider, id, hostKey, target, options, auth, dir, false),
1959
- );
1960
- }
1961
-
1962
2085
  // Counts a host as readying while run runs, so it is not idle then.
1963
2086
  private async withHostReadying<T>(hostKey: SessionID, run: () => Promise<T>): Promise<T> {
1964
2087
  this.readying.set(hostKey, (this.readying.get(hostKey) ?? 0) + 1);
@@ -1976,6 +2099,13 @@ export class SessionManager {
1976
2099
  }
1977
2100
  }
1978
2101
 
2102
+ // On a remote host the agent plans a guest spawn, whose files unpack into
2103
+ // the session's own guest folder, and the agent's sign-in check runs
2104
+ // there first. Every refusal comes before the harness starts. A harness
2105
+ // behind the broker has its binding created or verified before the host
2106
+ // is readied, and a binding this call created is taken back when a later
2107
+ // step fails; the attempt that created it comes back with the plan. A
2108
+ // spawn's new host of its own without a binding is destroyed instead.
1979
2109
  private async setupHarnessOnHost(
1980
2110
  adapter: AgentAdapter,
1981
2111
  provider: ExecutionProvider,
@@ -3075,6 +3205,8 @@ export class SessionManager {
3075
3205
 
3076
3206
  for (const id of removed) {
3077
3207
  this.removedIDs.delete(id);
3208
+
3209
+ await this.records?.remove(id);
3078
3210
  }
3079
3211
  }
3080
3212
 
@@ -48,6 +48,9 @@ export function startHeadlessTurn(
48
48
  ...(s.model === undefined ? {} : { model: s.model }),
49
49
  ...(s.effort === undefined ? {} : { effort: s.effort }),
50
50
  ...(s.withheldEnv.length === 0 ? {} : { withheldEnv: s.withheldEnv }),
51
+ ...(mgr.records === null || mgr.findProvider(s)?.remote !== false
52
+ ? {}
53
+ : { recordPath: mgr.records.getLocalPath(sessionID) }),
51
54
  },
52
55
  {
53
56
  onOutput: (text) => {
@@ -6,7 +6,8 @@
6
6
  * `keep` passes a value that looks like an id but is none of atc's, such as
7
7
  * an agent's own session id, while still rewriting ruled fields below it.
8
8
  * `opaque` passes a whole value unread: text an agent or a terminal wrote,
9
- * or a cursor only the same daemon reads back.
9
+ * a cursor only the same daemon reads back, or the daemon id a session
10
+ * record holds as written.
10
11
  */
11
12
  export type IDRule = 'id' | 'locator' | 'cursor' | 'keep' | 'opaque';
12
13
 
@@ -41,7 +42,15 @@ export const ID_RULES: Readonly<Record<string, ReadonlyMap<string, IDRule>>> = {
41
42
  'session.forget': new Map([['confirmToken', 'opaque']]),
42
43
  'session.list': buildPrefixedRules('sessions[].', DESCRIPTOR_RULES),
43
44
  'session.spawn': buildPrefixedRules('session.', DESCRIPTOR_RULES),
44
- 'session.get': buildPrefixedRules('session.', DESCRIPTOR_RULES),
45
+ 'session.get': new Map([
46
+ ...buildPrefixedRules('session.', DESCRIPTOR_RULES),
47
+ ['sessionRecord.session', 'id'],
48
+ ['sessionRecord.daemonID', 'opaque'],
49
+ ]),
50
+ 'session.scope.add': new Map([
51
+ ['record.session', 'id'],
52
+ ['record.daemonID', 'opaque'],
53
+ ]),
45
54
  'session.message': new Map([['message', 'id']]),
46
55
  'message.get': new Map([
47
56
  ['message', 'id'],
@@ -41,6 +41,40 @@ const DIRS_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
41
41
  { io: 'input' },
42
42
  );
43
43
 
44
+ // The scope a session may touch beyond its own workspace, which atc checks
45
+ // entry by entry on the session's host.
46
+ const SCOPE_WORKTREE = z.strictObject({ path: z.string() });
47
+ const SCOPE_BRANCH = z.strictObject({ name: z.string(), repo: z.string().optional() });
48
+ const SCOPE_PULL_REQUEST_NUMBER = z.number().int();
49
+
50
+ const SCOPE_PULL_REQUEST = z.strictObject({
51
+ number: SCOPE_PULL_REQUEST_NUMBER,
52
+ repo: z.string().optional(),
53
+ });
54
+
55
+ const SCOPE_FIELD = z
56
+ .strictObject({
57
+ worktrees: z
58
+ .array(SCOPE_WORKTREE)
59
+ .optional()
60
+ .describe('Absolute paths of git worktrees on the session host, each its worktree top level'),
61
+ branches: z
62
+ .array(SCOPE_BRANCH)
63
+ .optional()
64
+ .describe(
65
+ "Branches that exist in repo, an absolute repository path on the session host; repo defaults to the session's directory",
66
+ ),
67
+ pullRequests: z
68
+ .array(SCOPE_PULL_REQUEST)
69
+ .optional()
70
+ .describe(
71
+ "GitHub pull requests of repo, as owner/name; repo defaults to the GitHub repository of the workspace's origin",
72
+ ),
73
+ })
74
+ .describe(
75
+ "Worktrees, branches, and pull requests the session may touch beyond its own workspace. atc checks each entry on the session's host, refuses an invalid or unknown entry with scope_invalid naming it, and records the rest in the session's record, which the session reads at $ATC_SESSION_RECORD.",
76
+ );
77
+
44
78
  const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
45
79
  z.strictObject({
46
80
  daemon: DAEMON_FIELD,
@@ -65,6 +99,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
65
99
  trustClonedWorkspace: SPAWN_SCHEMA.shape.trustClonedWorkspace.describe(
66
100
  "Trust the exact verified clone for this launch. An explicit true or false overrides the configured target trustClonedWorkspace default; omitting both keeps trust off. Requires a workspace source and either stock Claude on the local target, which adds trust for the clone root alone to the user's Claude config, or, on an imp target, a brokered Claude gateway or stock Claude signed in through the broker, each with isolated guest config; other launches are refused. Accepts repository configuration and helpers without changing tool permission mode. Existing guest config is preserved.",
67
101
  ),
102
+ scope: SCOPE_FIELD.optional(),
68
103
  detached: z
69
104
  .boolean()
70
105
  .optional()
@@ -76,6 +111,11 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
76
111
  { io: 'input' },
77
112
  );
78
113
 
114
+ const SCOPE_ADD_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
115
+ SESSION_ID_BASE.extend({ scope: SCOPE_FIELD }).strict(),
116
+ { io: 'input' },
117
+ );
118
+
79
119
  const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
80
120
  SESSION_ID_BASE.extend({
81
121
  cursor: z
@@ -420,6 +460,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
420
460
  target: 'spawn.target',
421
461
  workspace: 'spawn.workspace',
422
462
  trustClonedWorkspace: 'spawn.workspace.trust',
463
+ scope: 'session.record',
423
464
  },
424
465
  },
425
466
  },
@@ -428,7 +469,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
428
469
  annotations: AGENT_FACING_DESTRUCTIVE,
429
470
  scope: 'spawn',
430
471
  description:
431
- "Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
472
+ "Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. atc pastes the line, so its newlines stay in it, and Claude takes a line of about 800 characters or more as pasted text, not as typed words. On a Claude session, a line that starts with a slash command and an argument, such as /goal finish the release, has the command name typed and only the argument pasted, so the command runs at any length. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
432
473
  inputSchema: {
433
474
  type: 'object',
434
475
  properties: {
@@ -450,6 +491,15 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
450
491
  'Read the current terminal screen of a session as plain text, without attaching to it. Use it to see what a session printed or what it is waiting on before answering it with atc_session_input. A killed session keeps its last screen.',
451
492
  inputSchema: SESSION_INPUT,
452
493
  },
494
+ {
495
+ name: 'atc_session_scope_add',
496
+ annotations: ADDITIVE,
497
+ scope: 'spawn',
498
+ description:
499
+ "Add worktrees, branches, or pull requests to the scope a session's record holds, as checked by atc on the session's host. Entries the record already holds change nothing, and atc never removes an entry. A session can never add to its own scope or to that of a session it is a sub-session of; ask whoever started it. Returns the record as it stands after.",
500
+ inputSchema: SCOPE_ADD_INPUT,
501
+ requires: { tool: 'session.record' },
502
+ },
453
503
  {
454
504
  name: 'atc_session_update',
455
505
  annotations: ADDITIVE,
@@ -27,6 +27,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
27
27
  'transport.tcp': 'a TCP connection to the daemon',
28
28
  'idempotency.replayOnly': 'a resend that only replays a held idempotency key',
29
29
  'session.auth': 'session.auth.revoke and session.auth.rebind',
30
+ 'session.record': "atc_session_scope_add and atc_session_spawn's scope",
30
31
  };
31
32
 
32
33
  /**
@@ -58,6 +58,7 @@ export function runTool(
58
58
  ...(args['trustClonedWorkspace'] === undefined
59
59
  ? {}
60
60
  : { trustClonedWorkspace: args['trustClonedWorkspace'] }),
61
+ ...(args['scope'] === undefined ? {} : { scope: args['scope'] }),
61
62
  cols: 100,
62
63
  rows: 30,
63
64
  };
@@ -85,6 +86,9 @@ export function runTool(
85
86
  ? ['spawn.workspace.autoDir']
86
87
  : [];
87
88
 
89
+ const scopeFeatures: readonly DaemonFeature[] =
90
+ args['scope'] === undefined ? [] : ['session.record'];
91
+
88
92
  const required = [
89
93
  ...optionFeatures,
90
94
  ...keyFeatures,
@@ -92,6 +96,7 @@ export function runTool(
92
96
  ...workspaceFeatures,
93
97
  ...trustFeatures,
94
98
  ...autoDirFeatures,
99
+ ...scopeFeatures,
95
100
  ];
96
101
 
97
102
  const ok =
@@ -141,6 +146,15 @@ export function runTool(
141
146
 
142
147
  return { text: 'updated', structured: null };
143
148
  })
149
+ .with('atc_session_scope_add', async () => {
150
+ const ok = await caller.sendRequest(
151
+ 'session.scope.add',
152
+ { session: args['session'], scope: args['scope'] },
153
+ ['session.record'],
154
+ );
155
+
156
+ return { text: JSON.stringify(ok), structured: ok };
157
+ })
144
158
  .with('atc_session_kill', async () => {
145
159
  await caller.sendRequest('session.kill', { session: args['session'] });
146
160
 
@@ -90,6 +90,11 @@ export const DAEMON_FEATURES = [
90
90
  // `session.auth.revoke` and `session.auth.rebind` exist, open to the
91
91
  // daemon's owner only.
92
92
  'session.auth',
93
+
94
+ // Every session has a published record: `session.spawn` takes `scope`,
95
+ // `session.scope.add` exists, `session.get` returns `sessionRecord`, and
96
+ // `daemon.hello` takes `session`, the session the client runs inside.
97
+ 'session.record',
93
98
  ] as const;
94
99
 
95
100
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];