@zgeoff/atc 2.16.0 → 2.19.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +24 -1
  3. package/src/client/build-workspace-destination.ts +38 -0
  4. package/src/client/collect-target-picks.ts +53 -0
  5. package/src/client/dirs.ts +1 -1
  6. package/src/client/pick-refusal-step.ts +29 -0
  7. package/src/client/resolve-workspace-root.ts +49 -0
  8. package/src/client/spawn-picker.ts +1683 -84
  9. package/src/client/ui.ts +6 -1
  10. package/src/daemon/daemon-connection.ts +129 -0
  11. package/src/daemon/daemon-context.ts +23 -0
  12. package/src/daemon/daemon.ts +29 -0
  13. package/src/daemon/materialize-workspace.ts +42 -18
  14. package/src/daemon/require-git-transports.ts +23 -0
  15. package/src/mcp/require-daemon-features.ts +2 -0
  16. package/src/protocol/daemon-features.ts +8 -0
  17. package/src/protocol/protocol.ts +2 -0
  18. package/src/protocol/request-param-schemas.ts +67 -15
  19. package/src/shared/collect-workspaces-config.ts +120 -0
  20. package/src/shared/config.ts +30 -1
  21. package/src/shared/default-git-transports.ts +5 -0
  22. package/src/shared/is-git-url.ts +11 -0
  23. package/src/sources/build-sources.ts +40 -0
  24. package/src/sources/collect-builtin-sources.ts +41 -0
  25. package/src/sources/dirs/build-dirs-source.ts +70 -0
  26. package/src/sources/git/build-git-source.ts +25 -0
  27. package/src/sources/github/build-github-source.ts +113 -0
  28. package/src/sources/github/collect-github-repos.ts +165 -0
  29. package/src/sources/github/find-github-alternate-url.ts +20 -0
  30. package/src/sources/github/read-git-protocol.ts +11 -0
  31. package/src/sources/github/run-gh.ts +66 -0
  32. package/src/sources/types.ts +70 -0
  33. package/src/workspace/check-git-transport.ts +61 -0
  34. package/src/workspace/check-repository-access.ts +108 -0
  35. package/src/workspace/collect-remote-refs.ts +154 -0
  36. package/src/workspace/create-git-askpass.ts +84 -0
  37. package/src/workspace/create-workspace-clone.ts +29 -76
  38. package/src/workspace/expand-git-shorthand.ts +13 -0
  39. package/src/workspace/find-remote-ref.ts +34 -0
  40. package/src/workspace/normalize-git-url.ts +2 -7
  41. package/src/workspace/resolve-git-url.ts +46 -0
  42. package/src/workspace/resolve-path-source.ts +11 -4
  43. package/src/workspace/run-git.ts +52 -4
  44. package/src/workspace/workspace-source.ts +10 -2
  45. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  46. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.16.0",
3
+ "version": "2.19.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",
package/src/cli.ts CHANGED
@@ -161,6 +161,10 @@ const main = defineCommand({
161
161
  const gateway = await import('./agents/gateway-adapter');
162
162
  const headless = await import('./agents/start-claude-headless-run');
163
163
  const targets = await import('./daemon/build-execution-targets');
164
+ const sourceOrder = await import('./sources/build-sources');
165
+ const builtinSources = await import('./sources/collect-builtin-sources');
166
+ const zoxide = await import('./shared/collect-zoxide-dirs');
167
+ const home = await import('./shared/resolve-home-dir');
164
168
 
165
169
  // Test harnesses shrink the outbound queue to force overflow
166
170
  // deterministically; unset means the production default.
@@ -178,10 +182,27 @@ const main = defineCommand({
178
182
  console.error(`atc daemon: config: ${line}`);
179
183
  }
180
184
 
181
- for (const problem of cfg.principalErrors) {
185
+ for (const problem of [...cfg.principalErrors, ...cfg.workspaceErrors]) {
182
186
  console.error(`atc daemon: config: ${problem}`);
183
187
  }
184
188
 
189
+ const sources = sourceOrder.buildSources(
190
+ builtinSources.collectBuiltinSources({
191
+ roots: cfg.dirs.roots,
192
+ githubOwner: cfg.workspaces.githubOwner,
193
+ ghBin: 'gh',
194
+ homeDir: home.resolveHomeDir(),
195
+ collectZoxideDirs: zoxide.collectZoxideDirs,
196
+ }),
197
+ cfg.workspaces.sources,
198
+ );
199
+
200
+ for (const id of sources.missing) {
201
+ console.error(
202
+ `atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
203
+ );
204
+ }
205
+
185
206
  // Cap on how long a fleet restore waits for one revived session to
186
207
  // report it has booted before moving to the next. Tests pin it to
187
208
  // keep timing deterministic; unset means the production default.
@@ -220,6 +241,8 @@ const main = defineCommand({
220
241
  defaultTarget: cfg.defaultTarget,
221
242
  targetErrors,
222
243
  principals: cfg.principals,
244
+ sources: sources.sources,
245
+ gitTransports: cfg.workspaces.gitTransports,
223
246
  restoreBootTimeoutMs,
224
247
  ...(Number.isFinite(graceOverride) && graceOverride >= 0
225
248
  ? { tapGraceMs: graceOverride }
@@ -0,0 +1,38 @@
1
+ interface DestinationParts {
2
+ // The root the checkout lands under, absolute on its target.
3
+ readonly root: string;
4
+
5
+ // The repository URL the checkout comes from, as the daemon resolved it.
6
+ readonly url: string;
7
+
8
+ // The branch or tag the commit was chosen by, or null for a bare commit.
9
+ readonly ref: string | null;
10
+ readonly sha: string;
11
+ }
12
+
13
+ /**
14
+ * The default directory a git workspace lands in: `<repo>-<ref>-<short
15
+ * sha>` under the root, with the ref left out for a bare commit. The repo
16
+ * is the URL's last path segment without `.git`, and each part keeps only
17
+ * `[A-Za-z0-9._-]`, with `/` turned into `-`, so the name is one path
18
+ * segment on any target.
19
+ */
20
+ export function buildWorkspaceDestination(parts: DestinationParts): string {
21
+ const repo =
22
+ parts.url
23
+ .replace(/\/+$/u, '')
24
+ .split(/[/:]/u)
25
+ .at(-1)
26
+ ?.replace(/\.git$/u, '') ?? '';
27
+
28
+ const name = [repo, ...(parts.ref === null ? [] : [parts.ref]), parts.sha.slice(0, 7)]
29
+ .map((part) => toSafeSegment(part))
30
+ .filter((part) => part !== '')
31
+ .join('-');
32
+
33
+ return `${parts.root.replace(/\/+$/u, '')}/${name}`;
34
+ }
35
+
36
+ function toSafeSegment(part: string): string {
37
+ return part.replaceAll('/', '-').replaceAll(/[^\w.-]/gu, '');
38
+ }
@@ -0,0 +1,53 @@
1
+ import { isRecord } from '../shared/report';
2
+
3
+ export interface TargetPick {
4
+ readonly id: string;
5
+ readonly provider: string;
6
+ readonly available: boolean;
7
+ readonly isDefault: boolean;
8
+
9
+ // Whether a workspace can be materialized there: the daemon has the
10
+ // provider, and it can both transfer an archive and run a command.
11
+ readonly takesWorkspace: boolean;
12
+
13
+ // Whether a session there runs on the daemon's own machine, where a
14
+ // local directory runs in place.
15
+ readonly inPlace: boolean;
16
+ }
17
+
18
+ /**
19
+ * The execution targets an `agents.list` answer holds, in config order. An
20
+ * answer from a daemon without targets, or a malformed entry, yields none.
21
+ */
22
+ export function collectTargetPicks(answer: Readonly<Record<string, unknown>>): TargetPick[] {
23
+ const targets = answer['targets'];
24
+
25
+ if (!Array.isArray(targets)) {
26
+ return [];
27
+ }
28
+
29
+ return targets.flatMap((entry: unknown): TargetPick[] => {
30
+ if (
31
+ !isRecord(entry) ||
32
+ typeof entry['id'] !== 'string' ||
33
+ typeof entry['provider'] !== 'string'
34
+ ) {
35
+ return [];
36
+ }
37
+
38
+ const capabilities = isRecord(entry['capabilities']) ? entry['capabilities'] : {};
39
+ const available = entry['available'] === true;
40
+
41
+ return [
42
+ {
43
+ id: entry['id'],
44
+ provider: entry['provider'],
45
+ available,
46
+ isDefault: entry['default'] === true,
47
+ takesWorkspace:
48
+ available && capabilities['transfer'] === true && capabilities['run'] === true,
49
+ inPlace: entry['provider'] === 'local-pty',
50
+ },
51
+ ];
52
+ });
53
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename } from 'node:path';
4
- import { collectRootDirs } from './collect-root-dirs';
4
+ import { collectRootDirs } from '../shared/collect-root-dirs';
5
5
 
6
6
  /**
7
7
  * Every source the picker lists, merged in the order the reader expects
@@ -0,0 +1,29 @@
1
+ // The steps of the git spawn flow a refused spawn can return to.
2
+ type RefusalStep = 'confirm' | 'destination' | 'ref' | 'source';
3
+
4
+ const REFUSAL_STEPS: ReadonlyMap<string, RefusalStep> = new Map<string, RefusalStep>([
5
+ ['workspace_exists', 'destination'],
6
+ ['ref_not_found', 'ref'],
7
+ ['invalid_git_url', 'source'],
8
+ ['credential_in_url', 'source'],
9
+ ['credential_missing', 'source'],
10
+ ['clone_failed', 'source'],
11
+ ['has_submodules', 'confirm'],
12
+ ['lfs_unsupported', 'confirm'],
13
+ ['unreadable_tree', 'confirm'],
14
+ ['sanitize_failed', 'confirm'],
15
+ ['tar_failed', 'confirm'],
16
+ ['transfer_failed', 'confirm'],
17
+ ['workspace_mismatch', 'confirm'],
18
+ ]);
19
+
20
+ /**
21
+ * The step of the git spawn flow that can fix a workspace spawn's
22
+ * refusal: the destination for one that already exists, the ref for one
23
+ * the upstream lacks, the source step for a URL or clone failure, and the
24
+ * confirm screen for a refusal of the commit or the target's copy of it.
25
+ * Null for any other refusal, which stays on the step that sent the spawn.
26
+ */
27
+ export function pickRefusalStep(code: string): RefusalStep | null {
28
+ return REFUSAL_STEPS.get(code) ?? null;
29
+ }
@@ -0,0 +1,49 @@
1
+ import type { WorkspacesConfig } from '../shared/collect-workspaces-config';
2
+ import { resolveHomeDir } from '../shared/resolve-home-dir';
3
+
4
+ type WorkspaceRoot =
5
+ | { readonly ok: true; readonly root: string }
6
+ | { readonly ok: false; readonly message: string };
7
+
8
+ interface RootTarget {
9
+ readonly id: string;
10
+
11
+ // Whether the target is the daemon's own machine, where `~` is this
12
+ // user's home.
13
+ readonly inPlace: boolean;
14
+ }
15
+
16
+ // Where checkouts land on the daemon's own machine without a configured root.
17
+ const DEFAULT_ROOT = '~/.local/share/atc/workspaces';
18
+
19
+ /**
20
+ * The directory git workspaces land under on a target: the target's own
21
+ * root, else the global root, else the default. On the daemon's own
22
+ * machine a leading `~` expands to the home directory. On any other target
23
+ * the root must be absolute, since `~` there is a home this client cannot
24
+ * see, so a root that is not is refused with the config key to set.
25
+ */
26
+ export function resolveWorkspaceRoot(config: WorkspacesConfig, target: RootTarget): WorkspaceRoot {
27
+ const configured = config.targetRoots.get(target.id) ?? config.root;
28
+
29
+ if (target.inPlace) {
30
+ const root = expandHome(configured ?? DEFAULT_ROOT);
31
+
32
+ return root.startsWith('/')
33
+ ? { ok: true, root }
34
+ : { ok: false, message: `workspaces root '${root}' must be an absolute path` };
35
+ }
36
+
37
+ if (configured === null || !configured.startsWith('/')) {
38
+ return {
39
+ ok: false,
40
+ message: `set workspaces.targets.${target.id} in config.json to an absolute path on that target`,
41
+ };
42
+ }
43
+
44
+ return { ok: true, root: configured };
45
+ }
46
+
47
+ function expandHome(dir: string): string {
48
+ return dir === '~' || dir.startsWith('~/') ? `${resolveHomeDir()}${dir.slice(1)}` : dir;
49
+ }