@zgeoff/atc 3.4.2 → 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 (78) 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/client/daemon-client.ts +6 -1
  6. package/src/collect-restart-env.ts +6 -3
  7. package/src/daemon/build-scoped-context.ts +2 -0
  8. package/src/daemon/check-session-scope.ts +235 -0
  9. package/src/daemon/daemon-connection.ts +74 -0
  10. package/src/daemon/daemon-context.ts +21 -0
  11. package/src/daemon/daemon.ts +21 -0
  12. package/src/daemon/find-host-branch.ts +28 -0
  13. package/src/daemon/materialize-workspace.ts +6 -1
  14. package/src/daemon/merge-recorded-scope.ts +50 -0
  15. package/src/daemon/published-records.ts +269 -0
  16. package/src/daemon/request-access-classes.ts +1 -0
  17. package/src/daemon/run-host-git.ts +52 -0
  18. package/src/daemon/sessions.ts +185 -53
  19. package/src/daemon/start-headless-turn.ts +3 -0
  20. package/src/federation/id-rules.ts +11 -2
  21. package/src/mcp/mcp-tools.ts +50 -0
  22. package/src/mcp/require-daemon-features.ts +1 -0
  23. package/src/mcp/run-tool.ts +14 -0
  24. package/src/protocol/daemon-features.ts +5 -0
  25. package/src/protocol/parse-declared-scope.ts +158 -0
  26. package/src/protocol/protocol.ts +1 -0
  27. package/src/protocol/published-record.ts +62 -0
  28. package/src/protocol/request-param-schemas.ts +16 -0
  29. package/src/store/parse-published-record.ts +59 -0
  30. package/src/store/run-migrations.ts +23 -0
  31. package/src/store/state-store.ts +32 -0
  32. package/src/test-utils/build-mock-published-record.ts +39 -0
  33. package/src/test-utils/build-stub-brokered-agent-adapter.ts +3 -1
  34. package/src/test-utils/build-stub-daemon-context.ts +2 -0
  35. package/src/test-utils/build-stub-daemon-requests.ts +3 -3
  36. package/src/test-utils/build-stub-harness-relay.ts +2 -2
  37. package/src/test-utils/build-stub-held-inbox-source.ts +2 -2
  38. package/src/test-utils/build-stub-host-hold.ts +7 -6
  39. package/src/test-utils/build-stub-launch-admission.ts +20 -0
  40. package/src/test-utils/build-stub-pregranted-broker-host.ts +22 -0
  41. package/src/test-utils/build-stub-terminal.ts +3 -3
  42. package/src/test-utils/create-git-fixture.ts +2 -2
  43. package/src/test-utils/create-stub-imp-port.ts +37 -37
  44. package/src/test-utils/create-stub-systemd.ts +6 -6
  45. package/src/test-utils/isolate-home.ts +8 -5
  46. package/src/test-utils/read-query-plan.ts +10 -6
  47. package/src/test-utils/register-test-cleanup.ts +10 -10
  48. package/src/test-utils/setup-mcp-home.ts +7 -6
  49. package/src/test-utils/setup-mcp-http.ts +6 -6
  50. package/src/test-utils/setup-temp-dir.ts +4 -10
  51. package/src/test-utils/start-cut-proxy.ts +2 -4
  52. package/src/test-utils/start-daemon-process.ts +13 -13
  53. package/src/test-utils/start-git-http-server.ts +2 -4
  54. package/src/test-utils/start-mcp-stdio.ts +5 -5
  55. package/src/test-utils/start-stub-answering-listener.ts +3 -3
  56. package/src/test-utils/start-stub-closing-listener.ts +3 -3
  57. package/src/test-utils/start-stub-dropping-daemon.ts +2 -3
  58. package/src/test-utils/start-stub-impd-info.ts +3 -7
  59. package/src/test-utils/start-stub-impd.ts +3 -5
  60. package/src/test-utils/start-stub-legacy-daemon.ts +2 -3
  61. package/src/test-utils/start-stub-mcp-server.ts +3 -5
  62. package/src/test-utils/start-stub-recording-listener.ts +3 -3
  63. package/src/test-utils/start-stub-refusing-listener.ts +3 -3
  64. package/src/test-utils/start-stub-reporter-socket.ts +3 -9
  65. package/src/test-utils/start-stub-restore-daemon.ts +2 -7
  66. package/src/test-utils/start-stub-session-bridge.ts +3 -9
  67. package/src/test-utils/start-stub-silent-server.ts +3 -6
  68. package/src/test-utils/start-stub-source-daemon.ts +2 -2
  69. package/src/test-utils/start-stub-stalled-client.ts +3 -3
  70. package/src/test-utils/start-stub-stalled-listener.ts +3 -3
  71. package/src/test-utils/start-stub-tcp-daemon.ts +3 -3
  72. package/src/test-utils/start-stub-telemetry-collector.ts +2 -2
  73. package/src/test-utils/start-stub-unanswering-listener.ts +3 -3
  74. package/src/test-utils/start-stub-unanswering-unix-listener.ts +3 -3
  75. package/src/test-utils/start-test-daemon.ts +62 -18
  76. package/src/test-utils/start-tui-harness.ts +17 -10
  77. package/src/test-utils/subscribe-to-socket-lines.ts +5 -9
  78. package/src/test-utils/{can-bind-addresses.ts → try-bind-addresses.ts} +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.4.2",
3
+ "version": "3.5.0",
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",
@@ -169,6 +169,10 @@ export interface HeadlessRunRequest {
169
169
  // Environment variable names the run's process goes without, such as the
170
170
  // credential a session's workspace was cloned with.
171
171
  readonly withheldEnv?: readonly string[];
172
+
173
+ // Where the session's published record lies, which the run's process
174
+ // reads from ATC_SESSION_RECORD.
175
+ readonly recordPath?: string;
172
176
  }
173
177
 
174
178
  export interface HeadlessRunEvents {
@@ -55,6 +55,7 @@ export function buildClaudeQueryOptions(
55
55
  ...(opts.pluginDir === undefined ? {} : { pluginDir: opts.pluginDir }),
56
56
  ...(opts.sessionID === undefined ? {} : { sessionID: opts.sessionID }),
57
57
  ...(opts.withheldEnv === undefined ? {} : { withheldEnv: opts.withheldEnv }),
58
+ ...(opts.recordPath === undefined ? {} : { recordPath: opts.recordPath }),
58
59
  }),
59
60
  ...(opts.resume === undefined ? {} : { resume: opts.resume }),
60
61
  ...(mode === undefined ? {} : { permissionMode: mode }),
@@ -8,6 +8,9 @@ interface HeadlessEnvParams {
8
8
 
9
9
  // Variables the run goes without, such as a workspace credential.
10
10
  readonly withheldEnv?: readonly string[];
11
+
12
+ // Where the session's published record lies.
13
+ readonly recordPath?: string;
11
14
  }
12
15
 
13
16
  /**
@@ -23,6 +26,7 @@ export function buildHeadlessEnv(params: HeadlessEnvParams): Record<string, stri
23
26
  ...(params.sessionID === undefined
24
27
  ? {}
25
28
  : { ATC_SESSION_ID: params.sessionID, ATC_SOCKET: params.socketPath }),
29
+ ...(params.recordPath === undefined ? {} : { ATC_SESSION_RECORD: params.recordPath }),
26
30
  },
27
31
  params.withheldEnv,
28
32
  );
@@ -68,11 +68,16 @@ export class DaemonClient implements DaemonChannel {
68
68
  }
69
69
 
70
70
  // A handshake with a token presents it as a bearer token, which a TCP
71
- // listener requires.
71
+ // listener requires. A client started inside an atc session gives that
72
+ // session, so the daemon never takes a change to the session's own record
73
+ // from it.
72
74
  sendHello(build: string, token?: string): Promise<Readonly<Record<string, unknown>>> {
75
+ const session = process.env['ATC_SESSION_ID'];
76
+
73
77
  return this.sendRequest('daemon.hello', {
74
78
  client: build,
75
79
  auth: token === undefined ? { scheme: 'none' } : { scheme: 'bearer', token },
80
+ ...(session === undefined || session === '' ? {} : { session }),
76
81
  });
77
82
  }
78
83
 
@@ -1,6 +1,9 @@
1
+ // The variables atc sets for the harness of a session it hosts.
2
+ const SESSION_VARIABLES = new Set(['ATC_SESSION_ID', 'ATC_SOCKET', 'ATC_SESSION_RECORD']);
3
+
1
4
  /**
2
- * Copies an environment without the variables that name the session or
3
- * socket of whoever runs the restart, so the daemon it starts never mistakes
5
+ * Copies an environment without the variables that name the session, its
6
+ * record, or the socket of whoever runs the restart, so the daemon it starts never mistakes
4
7
  * itself for a hosted session. Entries with no value drop out.
5
8
  */
6
9
  export function collectRestartEnv(
@@ -9,7 +12,7 @@ export function collectRestartEnv(
9
12
  const copy: Record<string, string> = {};
10
13
 
11
14
  for (const [key, value] of Object.entries(env)) {
12
- if (value !== undefined && key !== 'ATC_SESSION_ID' && key !== 'ATC_SOCKET') {
15
+ if (value !== undefined && !SESSION_VARIABLES.has(key)) {
13
16
  copy[key] = value;
14
17
  }
15
18
  }
@@ -144,6 +144,8 @@ export function buildScopedContext(
144
144
  revokeSessionAuth: (id) => (canSee(id) ? ctx.revokeSessionAuth(id) : Promise.resolve(false)),
145
145
  updateSessionAuth: (id) => (canSee(id) ? ctx.updateSessionAuth(id) : Promise.resolve(null)),
146
146
  updateSession: (id, name, pinned) => canSee(id) && ctx.updateSession(id, name, pinned),
147
+ updateSessionScope: (id, scope) =>
148
+ canSee(id) ? ctx.updateSessionScope(id, scope) : Promise.resolve('missing' as const),
147
149
  ackSession: (id) => canSee(id) && ctx.ackSession(id),
148
150
  buildResumeCommand: (id) => (canSee(id) ? ctx.buildResumeCommand(id) : null),
149
151
  readSessionScreen: (id) =>
@@ -0,0 +1,235 @@
1
+ import { z } from 'zod';
2
+ import { DaemonError } from '../protocol/daemon-error';
3
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
4
+ import type {
5
+ RecordedBranch,
6
+ RecordedPullRequest,
7
+ RecordedWorktree,
8
+ } from '../protocol/published-record';
9
+ import { runGH } from '../sources/github/run-gh';
10
+ import type { ExecutionProvider } from './execution-provider';
11
+ import { findHostBranch } from './find-host-branch';
12
+ import { runHostGit } from './run-host-git';
13
+
14
+ /**
15
+ * Where a declared scope is checked: the session's host and directory,
16
+ * and the repository URL of its materialized workspace, null for a
17
+ * directory that runs as it stands.
18
+ */
19
+ export interface ScopeCheckRequest {
20
+ readonly provider: ExecutionProvider;
21
+ readonly host: string;
22
+ readonly dir: string;
23
+ readonly repoURL: string | null;
24
+ readonly declared: DeclaredScope;
25
+ }
26
+
27
+ export interface CheckedScope {
28
+ readonly worktrees: readonly RecordedWorktree[];
29
+ readonly branches: readonly RecordedBranch[];
30
+ readonly pullRequests: readonly RecordedPullRequest[];
31
+ }
32
+
33
+ // How long one gh call may take before the entry it checks is refused.
34
+ const GH_TIMEOUT_MS = 20_000;
35
+
36
+ /**
37
+ * Checks each declared entry against the session's host and returns what
38
+ * the record holds for it: a worktree's branch as git reads it there, a
39
+ * branch with the repository it exists in, and a pull request as GitHub
40
+ * holds it. The first entry that fails refuses the whole scope with
41
+ * `scope_invalid`, the entry in its message and `data.entry`.
42
+ */
43
+ export async function checkSessionScope(
44
+ request: ScopeCheckRequest,
45
+ ghBin: string,
46
+ ): Promise<CheckedScope> {
47
+ const declared = request.declared;
48
+ const needsHost = declared.worktrees.length > 0 || declared.branches.length > 0;
49
+
50
+ if (needsHost && request.provider.remote && !request.provider.capabilities.run) {
51
+ const first = declared.worktrees.length > 0 ? 'scope.worktrees[0]' : 'scope.branches[0]';
52
+
53
+ throw buildScopeRefusal(first, 'cannot be checked: the target runs no commands');
54
+ }
55
+
56
+ const worktrees: RecordedWorktree[] = [];
57
+
58
+ for (const [index, worktree] of declared.worktrees.entries()) {
59
+ const checked = await checkWorktree(request, `scope.worktrees[${index}]`, worktree.path);
60
+
61
+ worktrees.push(checked);
62
+ }
63
+
64
+ const branches: RecordedBranch[] = [];
65
+
66
+ for (const [index, branch] of declared.branches.entries()) {
67
+ const repo = branch.repo ?? request.dir;
68
+
69
+ const checked = await checkBranch(request, `scope.branches[${index}]`, branch.name, repo);
70
+
71
+ branches.push(checked);
72
+ }
73
+
74
+ const pullRequests: RecordedPullRequest[] = [];
75
+
76
+ for (const [index, pr] of declared.pullRequests.entries()) {
77
+ const entry = `scope.pullRequests[${index}]`;
78
+ let repo = pr.repo;
79
+
80
+ repo ??= await findGitHubRepo(request, entry);
81
+
82
+ const checked = await checkPullRequest(entry, repo, pr.number, ghBin);
83
+
84
+ pullRequests.push(checked);
85
+ }
86
+
87
+ return { worktrees, branches, pullRequests };
88
+ }
89
+
90
+ async function checkWorktree(
91
+ request: ScopeCheckRequest,
92
+ entry: string,
93
+ path: string,
94
+ ): Promise<RecordedWorktree> {
95
+ const top = await runHostGit(request.provider, request.host, path, [
96
+ 'rev-parse',
97
+ '--show-toplevel',
98
+ ]);
99
+
100
+ if (top.exitCode !== 0) {
101
+ throw buildScopeRefusal(entry, `${path} is not a git worktree on the session's host`);
102
+ }
103
+
104
+ if (top.stdout.trim() !== path.replace(/\/+$/u, '')) {
105
+ throw buildScopeRefusal(
106
+ entry,
107
+ `${path} is inside the worktree ${top.stdout.trim()}, not its top level`,
108
+ );
109
+ }
110
+
111
+ return { path, branch: await findHostBranch(request.provider, request.host, path) };
112
+ }
113
+
114
+ async function checkBranch(
115
+ request: ScopeCheckRequest,
116
+ entry: string,
117
+ name: string,
118
+ repo: string,
119
+ ): Promise<RecordedBranch> {
120
+ const format = await runHostGit(request.provider, request.host, repo, [
121
+ 'check-ref-format',
122
+ '--branch',
123
+ name,
124
+ ]);
125
+
126
+ if (format.exitCode !== 0) {
127
+ throw buildScopeRefusal(entry, `${name} is not a valid branch name`);
128
+ }
129
+
130
+ for (const ref of [`refs/heads/${name}`, `refs/remotes/origin/${name}`]) {
131
+ const found = await runHostGit(request.provider, request.host, repo, [
132
+ 'rev-parse',
133
+ '--verify',
134
+ '--quiet',
135
+ '--end-of-options',
136
+ `${ref}^{commit}`,
137
+ ]);
138
+
139
+ if (found.exitCode === 0) {
140
+ return { name, repo };
141
+ }
142
+ }
143
+
144
+ throw buildScopeRefusal(entry, `${repo} has no branch ${name}`);
145
+ }
146
+
147
+ // The GitHub repository a pull request defaults to: that of the workspace's
148
+ // origin.
149
+ async function findGitHubRepo(request: ScopeCheckRequest, entry: string): Promise<string> {
150
+ let url = request.repoURL;
151
+
152
+ if (url === null && (!request.provider.remote || request.provider.capabilities.run)) {
153
+ const origin = await runHostGit(request.provider, request.host, request.dir, [
154
+ 'remote',
155
+ 'get-url',
156
+ 'origin',
157
+ ]);
158
+
159
+ url = origin.exitCode === 0 ? origin.stdout.trim() : null;
160
+ }
161
+
162
+ const repo = url === null ? null : findGitHubRepoInURL(url);
163
+
164
+ if (repo === null) {
165
+ throw buildScopeRefusal(entry, "needs a repo: the workspace's origin is not on GitHub");
166
+ }
167
+
168
+ return repo;
169
+ }
170
+
171
+ const GITHUB_URL =
172
+ /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)(?<repo>[\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/u;
173
+
174
+ function findGitHubRepoInURL(url: string): string | null {
175
+ return GITHUB_URL.exec(url)?.groups?.['repo'] ?? null;
176
+ }
177
+
178
+ const BASE_REPO_SCHEMA = z.object({ full_name: z.string() });
179
+
180
+ const PULL_REQUEST_SCHEMA = z.object({
181
+ number: z.number(),
182
+ html_url: z.string(),
183
+ head: z.object({ ref: z.string() }),
184
+ base: z.object({ repo: BASE_REPO_SCHEMA }),
185
+ });
186
+
187
+ async function checkPullRequest(
188
+ entry: string,
189
+ repo: string,
190
+ number: number,
191
+ ghBin: string,
192
+ ): Promise<RecordedPullRequest> {
193
+ let run: Awaited<ReturnType<typeof runGH>>;
194
+
195
+ try {
196
+ run = await runGH(ghBin, AbortSignal.timeout(GH_TIMEOUT_MS), [
197
+ 'api',
198
+ `repos/${repo}/pulls/${number}`,
199
+ ]);
200
+ } catch {
201
+ throw buildScopeRefusal(entry, 'cannot be checked: gh is not installed on the daemon host');
202
+ }
203
+
204
+ if (run.timedOut) {
205
+ throw buildScopeRefusal(entry, 'cannot be checked: gh took too long');
206
+ }
207
+
208
+ const parsed =
209
+ run.exitCode === 0 ? PULL_REQUEST_SCHEMA.safeParse(tryParseJSON(run.stdout)) : null;
210
+
211
+ if (parsed === null || !parsed.success) {
212
+ throw buildScopeRefusal(entry, `${repo} has no pull request #${number}`);
213
+ }
214
+
215
+ if (parsed.data.base.repo.full_name.toLowerCase() !== repo.toLowerCase()) {
216
+ throw buildScopeRefusal(
217
+ entry,
218
+ `pull request #${number} belongs to ${parsed.data.base.repo.full_name}, not ${repo}`,
219
+ );
220
+ }
221
+
222
+ return { repo, number, url: parsed.data.html_url, branch: parsed.data.head.ref };
223
+ }
224
+
225
+ function tryParseJSON(text: string): unknown {
226
+ try {
227
+ return JSON.parse(text);
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ function buildScopeRefusal(entry: string, reason: string): DaemonError {
234
+ return new DaemonError('scope_invalid', `${entry} ${reason}`, { entry });
235
+ }
@@ -6,6 +6,8 @@ import { encodeCursor } from '../protocol/encode-cursor';
6
6
  import { LineDecoder } from '../protocol/line-decoder';
7
7
  import { OutboundQueue } from '../protocol/outbound-queue';
8
8
  import type { SocketWriter } from '../protocol/outbound-queue';
9
+ import { parseDeclaredScope } from '../protocol/parse-declared-scope';
10
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
9
11
  import { parseRequestParams } from '../protocol/parse-request-params';
10
12
  import type { RequestMethod } from '../protocol/parse-request-params';
11
13
  import {
@@ -103,6 +105,11 @@ export class DaemonConnection {
103
105
 
104
106
  private access: TargetAccess | null = null;
105
107
 
108
+ // The atc session the client runs inside, from its handshake; null for a
109
+ // client that gave none. A session never changes its own record or that
110
+ // of a session it is a sub-session of through it.
111
+ private callerSession: SessionID | null = null;
112
+
106
113
  // The sessions in this limited connection's view, the sessions the daemon
107
114
  // holds that the view leaves out, and the permission requests the
108
115
  // connection was shown, so the events that end them reach it after the
@@ -629,6 +636,11 @@ export class DaemonConnection {
629
636
 
630
637
  return;
631
638
  }
639
+ case 'session.scope.add': {
640
+ await this.applyScopeAdd(req, ctx);
641
+
642
+ return;
643
+ }
632
644
  case 'session.kill': {
633
645
  await this.applySessionVerb(req, 'session.kill', ctx.killSession);
634
646
 
@@ -919,6 +931,48 @@ export class DaemonConnection {
919
931
  }
920
932
  }
921
933
 
934
+ // A client inside a session never adds to the record of that session or
935
+ // of a session it is a sub-session of: only a caller outside the tree
936
+ // vouches for what the session may touch.
937
+ private async applyScopeAdd(req: RequestMsg, ctx: DaemonContext): Promise<void> {
938
+ const parsed = parseRequestParams('session.scope.add', req.p);
939
+
940
+ if (!parsed.ok) {
941
+ this.sendErr(req.id, 'bad_args', parsed.message);
942
+
943
+ return;
944
+ }
945
+
946
+ const sessionID = parsed.data.session;
947
+ const declared = parseDeclaredScope(parsed.data.scope);
948
+
949
+ if (!declared.ok) {
950
+ this.sendErr(req.id, 'scope_invalid', declared.message, { entry: declared.entry });
951
+
952
+ return;
953
+ }
954
+
955
+ if (this.callerSession !== null && ctx.isCallerTree(sessionID, this.callerSession)) {
956
+ this.sendErr(
957
+ req.id,
958
+ 'unauthorized',
959
+ `session '${this.callerSession}' cannot add to the scope of session '${sessionID}': a session never widens its own scope or that of a session above it`,
960
+ );
961
+
962
+ return;
963
+ }
964
+
965
+ const record = await ctx.updateSessionScope(sessionID, declared.scope);
966
+
967
+ if (record === 'missing') {
968
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
969
+
970
+ return;
971
+ }
972
+
973
+ this.sendOk(req.id, { record });
974
+ }
975
+
922
976
  private async applySpawn(req: RequestMsg, ctx: DaemonContext): Promise<void> {
923
977
  const parsed = parseRequestParams('session.spawn', req.p);
924
978
 
@@ -1041,6 +1095,7 @@ export class DaemonConnection {
1041
1095
  target,
1042
1096
  workspace: data.workspace ?? null,
1043
1097
  autoDir: cwd === undefined,
1098
+ scope: requireDeclaredScope(data.scope),
1044
1099
  };
1045
1100
  };
1046
1101
 
@@ -1668,6 +1723,8 @@ export class DaemonConnection {
1668
1723
  this.setPrincipal(principal);
1669
1724
  }
1670
1725
 
1726
+ this.callerSession = parsedHello.ok ? (parsedHello.data.session ?? null) : null;
1727
+
1671
1728
  if (req.v !== PROTOCOL_V) {
1672
1729
  this.sendErr(
1673
1730
  req.id,
@@ -1745,6 +1802,7 @@ export class DaemonConnection {
1745
1802
  this.setPrincipal(principal);
1746
1803
  }
1747
1804
 
1805
+ this.callerSession = parsedHello.data.session ?? null;
1748
1806
  this.fingerprint = fingerprint;
1749
1807
 
1750
1808
  await this.sendHelloOk(req.id);
@@ -1851,3 +1909,19 @@ function isPlainWorkspaceDir(dir: string): boolean {
1851
1909
  !CONTROL_CHARACTER.test(dir) && !dir.split('/').some((part) => part === '.' || part === '..')
1852
1910
  );
1853
1911
  }
1912
+
1913
+ // A spawn without a scope declares none; one whose scope does not read
1914
+ // refuses before anything starts, with the entry at fault.
1915
+ function requireDeclaredScope(raw: unknown): DeclaredScope | null {
1916
+ if (raw === undefined) {
1917
+ return null;
1918
+ }
1919
+
1920
+ const parsed = parseDeclaredScope(raw);
1921
+
1922
+ if (!parsed.ok) {
1923
+ throw new DaemonError('scope_invalid', parsed.message, { entry: parsed.entry });
1924
+ }
1925
+
1926
+ return parsed.scope;
1927
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AgentAdapter, SpawnOptions, SpawnOverrides } from '../agents/agent-adapter';
2
+ import type { DeclaredScope } from '../protocol/parse-declared-scope';
2
3
  import type { EventMsg } from '../protocol/protocol';
4
+ import type { PublishedRecord } from '../protocol/published-record';
3
5
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
4
6
  import type { AgentID } from '../shared/agent-id';
5
7
  import type { TargetConfigError } from '../shared/collect-targets';
@@ -40,6 +42,10 @@ export interface SpawnParams {
40
42
  // session in cwd as it stands.
41
43
  readonly workspace: SpawnWorkspaceSource | null;
42
44
 
45
+ // The scope beyond its workspace the session's record holds once each
46
+ // entry is checked on its host; null declares none.
47
+ readonly scope: DeclaredScope | null;
48
+
43
49
  // Whether the daemon picked cwd for a git workspace the spawn gave no
44
50
  // directory for, so a held directory moves the spawn to a numbered one
45
51
  // beside it.
@@ -52,6 +58,10 @@ interface SessionRecord {
52
58
  readonly lastActivityAt: number;
53
59
  readonly pending: { readonly message: string } | null;
54
60
  readonly result: string | null;
61
+
62
+ // The record atc publishes for the session; null for a session that has
63
+ // none yet.
64
+ readonly sessionRecord: PublishedRecord | null;
55
65
  }
56
66
 
57
67
  /**
@@ -248,6 +258,17 @@ export interface DaemonContext {
248
258
  // answers the new revision, or null for no such session.
249
259
  readonly updateSessionAuth: (id: SessionID) => Promise<number | null>;
250
260
  readonly updateSession: (id: SessionID, name?: string, pinned?: boolean) => boolean | 'child_pin';
261
+
262
+ // Adds a checked scope to a session's published record and returns the
263
+ // record as it stands after; 'missing' for no such session.
264
+ readonly updateSessionScope: (
265
+ id: SessionID,
266
+ scope: DeclaredScope,
267
+ ) => Promise<PublishedRecord | 'missing'>;
268
+
269
+ // Whether the session is the caller itself or a session the caller is a
270
+ // sub-session of, at any depth.
271
+ readonly isCallerTree: (id: SessionID, caller: SessionID) => boolean;
251
272
  readonly quitDaemon: () => void;
252
273
  readonly ackSession: (id: SessionID) => boolean;
253
274
  readonly buildResumeCommand: (id: SessionID) => string | null;
@@ -82,6 +82,7 @@ import { mintMessageID } from './mint-message-id';
82
82
  import { mintSessionID } from './mint-session-id';
83
83
  import { parseReport } from './parse-report';
84
84
  import { PermissionRegistry } from './permission-registry';
85
+ import { PublishedRecords } from './published-records';
85
86
  import { requireGitTransports } from './require-git-transports';
86
87
  import { restoreFleet } from './restore-fleet';
87
88
  import type { RestoreSettled } from './restore-fleet';
@@ -228,6 +229,10 @@ export interface DaemonOptions {
228
229
  // services it uses; none when unset.
229
230
  readonly sources?: readonly SourceProvider[];
230
231
 
232
+ // The gh executable that checks a pull request a session's scope
233
+ // declares; `gh` on PATH when unset.
234
+ readonly ghBin?: string;
235
+
231
236
  // The transports a git workspace source may use and git may fetch over,
232
237
  // or the invalid list the config holds, which refuses every git
233
238
  // operation; https and ssh when unset.
@@ -422,6 +427,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
422
427
  mgr.log = opts.log;
423
428
  }
424
429
 
430
+ const records = new PublishedRecords({
431
+ store,
432
+ daemonID: store.daemonID,
433
+ localDir: join(stateDir, 'records'),
434
+ ghBin: opts.ghBin ?? 'gh',
435
+ now: () => Date.now(),
436
+ });
437
+
438
+ mgr.records = records;
439
+
425
440
  const authBinder = new RuntimeAuthBinder(store);
426
441
 
427
442
  mgr.authBinder = authBinder;
@@ -1159,6 +1174,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1159
1174
  targetIdentity: string,
1160
1175
  ) => Promise<Readonly<{
1161
1176
  workspace: SessionWorkspace;
1177
+ branch: string | null;
1162
1178
  withheldEnv: readonly string[];
1163
1179
  }> | null>)
1164
1180
  | null,
@@ -1180,6 +1196,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1180
1196
  materialize,
1181
1197
  requireInReach,
1182
1198
  p.autoDir,
1199
+ p.scope,
1183
1200
  );
1184
1201
 
1185
1202
  const runtime = runtimes.get(s.id);
@@ -1623,6 +1640,8 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1623
1640
  });
1624
1641
  },
1625
1642
  updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
1643
+ updateSessionScope: (id, scope) => mgr.updateScope(id, scope),
1644
+ isCallerTree: (id, caller) => mgr.isCallerTree(id, caller),
1626
1645
  quitDaemon: () => {
1627
1646
  // The ok response for the quit request must flush before the sockets
1628
1647
  // close under it.
@@ -1960,6 +1979,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1960
1979
  const linked = access !== null || shared ? undefined : s.agentSessionID;
1961
1980
 
1962
1981
  const lastEventAt = await store.loadLastActivityAt(s.id, linked);
1982
+ const sessionRecord = await records.findRecord(s.id);
1963
1983
 
1964
1984
  return {
1965
1985
  session,
@@ -1967,6 +1987,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1967
1987
  lastActivityAt: lastEventAt ?? createdAt,
1968
1988
  pending,
1969
1989
  result,
1990
+ sessionRecord,
1970
1991
  };
1971
1992
  },
1972
1993
  loadSessionTranscript: async (id, from, limit) => {
@@ -0,0 +1,28 @@
1
+ import type { ExecutionProvider } from './execution-provider';
2
+ import { runHostGit } from './run-host-git';
3
+
4
+ /**
5
+ * The branch a directory on a session's host has checked out; null for a
6
+ * detached checkout, a directory outside git, or a remote host that runs
7
+ * no commands.
8
+ */
9
+ export async function findHostBranch(
10
+ provider: ExecutionProvider,
11
+ host: string,
12
+ dir: string,
13
+ ): Promise<string | null> {
14
+ if (provider.remote && !provider.capabilities.run) {
15
+ return null;
16
+ }
17
+
18
+ const head = await runHostGit(provider, host, dir, [
19
+ 'symbolic-ref',
20
+ '--quiet',
21
+ '--short',
22
+ 'HEAD',
23
+ ]);
24
+
25
+ const branch = head.stdout.trim();
26
+
27
+ return head.exitCode === 0 && branch !== '' ? branch : null;
28
+ }
@@ -76,6 +76,9 @@ type MaterializedWorkspace = { readonly kind: 'in_place' } | ReadyWorkspace;
76
76
  interface ReadyWorkspace {
77
77
  readonly kind: 'ready';
78
78
  readonly workspace: SessionWorkspace;
79
+
80
+ // The branch the checkout is on, null for a detached checkout.
81
+ readonly branch: string | null;
79
82
  readonly warnings: readonly string[];
80
83
 
81
84
  // The variables every harness the session starts goes without.
@@ -298,6 +301,7 @@ async function runMaterialization(
298
301
  ...(ref === null ? {} : { ref }),
299
302
  materializedAt,
300
303
  },
304
+ branch: clone.branch,
301
305
  warnings: pinned.warnings,
302
306
  };
303
307
  }
@@ -532,6 +536,7 @@ async function recordPhase(
532
536
 
533
537
  interface CleanClone {
534
538
  readonly sha: string;
539
+ readonly branch: string | null;
535
540
  readonly archive: Uint8Array;
536
541
  }
537
542
 
@@ -580,7 +585,7 @@ async function createCleanClone(
580
585
  throw new DaemonError(outcome.code, outcome.message, { phase: 'cloning' });
581
586
  }
582
587
 
583
- return { sha: clone.sha, archive };
588
+ return { sha: clone.sha, branch: clone.branch, archive };
584
589
  }
585
590
 
586
591
  function toDaemonError(error: unknown, code: ErrorCode, phase: MaterializationPhase): DaemonError {