@zgeoff/atc 2.11.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.
- package/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isRecord } from './report';
|
|
2
|
+
|
|
3
|
+
interface PrincipalsConfig {
|
|
4
|
+
// The targets each principal may use, by principal; null when the config
|
|
5
|
+
// has no `principals` key.
|
|
6
|
+
readonly principals: ReadonlyMap<string, readonly string[]> | null;
|
|
7
|
+
readonly errors: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads the `principals` map: each principal's id to the names of the
|
|
12
|
+
* targets it may use. No `principals` key is null. Otherwise the config
|
|
13
|
+
* fails closed: a malformed map grants nothing to anyone, and a malformed
|
|
14
|
+
* entry grants nothing to its principal. Each problem is an error.
|
|
15
|
+
*/
|
|
16
|
+
export function collectPrincipals(raw: unknown): PrincipalsConfig {
|
|
17
|
+
if (raw === undefined) {
|
|
18
|
+
return { principals: null, errors: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
22
|
+
return {
|
|
23
|
+
principals: new Map(),
|
|
24
|
+
errors: ['principals must be an object of principal ids, so no principal gets a target'],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const principals = new Map<string, readonly string[]>();
|
|
29
|
+
|
|
30
|
+
const errors: string[] = [];
|
|
31
|
+
|
|
32
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
33
|
+
const targets = isRecord(entry) ? entry['targets'] : undefined;
|
|
34
|
+
|
|
35
|
+
if (
|
|
36
|
+
id === '' ||
|
|
37
|
+
!Array.isArray(targets) ||
|
|
38
|
+
!targets.every((target) => typeof target === 'string' && target !== '')
|
|
39
|
+
) {
|
|
40
|
+
errors.push(
|
|
41
|
+
`principal ${JSON.stringify(id)} must be an object whose targets is an array of target names, so it gets no target`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
principals.set(id, targets);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { principals, errors };
|
|
51
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { formatJSONKind } from './format-json-kind';
|
|
2
|
+
import { isRecord } from './report';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A named place sessions run: the execution provider kind that serves it,
|
|
6
|
+
* and the options that kind takes.
|
|
7
|
+
*/
|
|
8
|
+
export interface TargetConfig {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly provider: string;
|
|
11
|
+
readonly options: Readonly<Record<string, unknown>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A config problem that leaves a target, or every target, unusable: the
|
|
16
|
+
* config file as a whole, the `targets` map, one entry of it, or
|
|
17
|
+
* `defaultTarget`.
|
|
18
|
+
*/
|
|
19
|
+
export type TargetConfigError = TargetSectionError | ConfigFileError;
|
|
20
|
+
|
|
21
|
+
interface TargetSectionError {
|
|
22
|
+
readonly scope: 'targets' | 'target' | 'defaultTarget';
|
|
23
|
+
|
|
24
|
+
// The entry the problem is in; present only for an entry's problem.
|
|
25
|
+
readonly target?: string;
|
|
26
|
+
readonly problem: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A config file that exists but cannot be used, so no target in it can be
|
|
31
|
+
* trusted: `config_malformed` when its text is not JSON or its root is not
|
|
32
|
+
* an object, `config_unreadable` when reading it fails for any reason but
|
|
33
|
+
* its absence. `detail` holds the parse message, the root's kind, or the
|
|
34
|
+
* read's error code.
|
|
35
|
+
*/
|
|
36
|
+
interface ConfigFileError {
|
|
37
|
+
readonly scope: 'config';
|
|
38
|
+
readonly problem: 'config_malformed' | 'config_unreadable';
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly detail: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TargetsConfig {
|
|
44
|
+
// Every well-formed entry, in config order.
|
|
45
|
+
readonly targets: readonly TargetConfig[];
|
|
46
|
+
|
|
47
|
+
// The target a spawn without a target runs on, or null when the config
|
|
48
|
+
// gives none it can use.
|
|
49
|
+
readonly defaultTarget: string | null;
|
|
50
|
+
readonly errors: readonly TargetConfigError[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The target a config holds when it sets no targets of its own: the
|
|
54
|
+
// daemon's own machine.
|
|
55
|
+
const LOCAL_TARGET: TargetConfig = { id: 'local', provider: 'local-pty', options: {} };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reads the `targets` map and `defaultTarget`. No `targets` key holds the
|
|
59
|
+
* one implicit `local` target as the default. Otherwise the config fails
|
|
60
|
+
* closed: a malformed map holds no targets, a malformed entry is left out,
|
|
61
|
+
* and a `defaultTarget` that is not a string or matches no well-formed
|
|
62
|
+
* entry leaves no default. Each problem is an error, so a spawn that
|
|
63
|
+
* resolves through it fails with that error instead of running on `local`.
|
|
64
|
+
* Without `defaultTarget`, the default is the `local` entry when the map
|
|
65
|
+
* holds a well-formed one, and none otherwise.
|
|
66
|
+
*/
|
|
67
|
+
export function collectTargets(rawTargets: unknown, rawDefault: unknown): TargetsConfig {
|
|
68
|
+
if (rawTargets === undefined && rawDefault === undefined) {
|
|
69
|
+
return { targets: [LOCAL_TARGET], defaultTarget: 'local', errors: [] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const collected = collectTargetEntries(rawTargets);
|
|
73
|
+
|
|
74
|
+
const ids = new Set(collected.targets.map((target) => target.id));
|
|
75
|
+
|
|
76
|
+
if (rawDefault === undefined) {
|
|
77
|
+
return {
|
|
78
|
+
targets: collected.targets,
|
|
79
|
+
defaultTarget: ids.has('local') ? 'local' : null,
|
|
80
|
+
errors: collected.errors,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof rawDefault === 'string' && ids.has(rawDefault)) {
|
|
85
|
+
return { targets: collected.targets, defaultTarget: rawDefault, errors: collected.errors };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The problem gives the value's kind, never the value: a wrongly typed
|
|
89
|
+
// defaultTarget can hold anything the file holds.
|
|
90
|
+
const problem =
|
|
91
|
+
typeof rawDefault === 'string'
|
|
92
|
+
? 'defaultTarget: matches no well-formed target in targets'
|
|
93
|
+
: `defaultTarget: expected a string, got ${formatJSONKind(rawDefault)}`;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
targets: collected.targets,
|
|
97
|
+
defaultTarget: null,
|
|
98
|
+
errors: [...collected.errors, { scope: 'defaultTarget', problem }],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface TargetEntries {
|
|
103
|
+
readonly targets: readonly TargetConfig[];
|
|
104
|
+
readonly errors: readonly TargetSectionError[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function collectTargetEntries(raw: unknown): TargetEntries {
|
|
108
|
+
// A defaultTarget without a targets map names one of the implicit targets.
|
|
109
|
+
if (raw === undefined) {
|
|
110
|
+
return { targets: [LOCAL_TARGET], errors: [] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!isRecord(raw) || Array.isArray(raw) || Object.keys(raw).length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
targets: [],
|
|
116
|
+
errors: [
|
|
117
|
+
{ scope: 'targets', problem: 'targets must be a non-empty object of named targets' },
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const targets: TargetConfig[] = [];
|
|
123
|
+
const errors: TargetSectionError[] = [];
|
|
124
|
+
|
|
125
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
126
|
+
const provider = isRecord(entry) ? entry['provider'] : undefined;
|
|
127
|
+
|
|
128
|
+
if (id === '' || !isRecord(entry) || typeof provider !== 'string' || provider === '') {
|
|
129
|
+
errors.push({
|
|
130
|
+
scope: 'target',
|
|
131
|
+
target: id,
|
|
132
|
+
problem: `target ${JSON.stringify(id)} must be an object with a non-empty string provider`,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { provider: _, ...options } = entry;
|
|
139
|
+
|
|
140
|
+
targets.push({ id, provider, options });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { targets, errors };
|
|
144
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { buildOptionalString } from './build-optional-string';
|
|
5
5
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
@@ -8,6 +8,11 @@ import { collectGateways } from './collect-gateways';
|
|
|
8
8
|
import type { GatewayConfig } from './collect-gateways';
|
|
9
9
|
import { collectHooks } from './collect-hooks';
|
|
10
10
|
import type { HooksConfig } from './collect-hooks';
|
|
11
|
+
import { collectPrincipals } from './collect-principals';
|
|
12
|
+
import { collectTargets } from './collect-targets';
|
|
13
|
+
import type { TargetConfig, TargetConfigError } from './collect-targets';
|
|
14
|
+
import { formatJSONKind } from './format-json-kind';
|
|
15
|
+
import { isRecord } from './report';
|
|
11
16
|
import { resolveHomeDir } from './resolve-home-dir';
|
|
12
17
|
|
|
13
18
|
export interface Config {
|
|
@@ -21,6 +26,20 @@ export interface Config {
|
|
|
21
26
|
gateways: GatewayConfig[];
|
|
22
27
|
hooks: HooksConfig;
|
|
23
28
|
leader: LeaderKey;
|
|
29
|
+
|
|
30
|
+
// Where sessions run, and the one a spawn without a target runs on: null
|
|
31
|
+
// when the config gives none it can use.
|
|
32
|
+
targets: readonly TargetConfig[];
|
|
33
|
+
defaultTarget: string | null;
|
|
34
|
+
|
|
35
|
+
// The target config problems that leave a target, or every target,
|
|
36
|
+
// unusable.
|
|
37
|
+
targetErrors: readonly TargetConfigError[];
|
|
38
|
+
|
|
39
|
+
// The targets each principal may use: null when the config has no
|
|
40
|
+
// principals, which leaves every principal the implicit local target.
|
|
41
|
+
principals: ReadonlyMap<string, readonly string[]> | null;
|
|
42
|
+
principalErrors: readonly string[];
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
/**
|
|
@@ -47,6 +66,11 @@ const DEFAULTS: Config = {
|
|
|
47
66
|
gateways: [],
|
|
48
67
|
hooks: {},
|
|
49
68
|
leader: { code: 0, label: '^Space' },
|
|
69
|
+
targets: [{ id: 'local', provider: 'local-pty', options: {} }],
|
|
70
|
+
defaultTarget: 'local',
|
|
71
|
+
targetErrors: [],
|
|
72
|
+
principals: null,
|
|
73
|
+
principalErrors: [],
|
|
50
74
|
};
|
|
51
75
|
|
|
52
76
|
const configDir = join(resolveHomeDir(), '.config', 'atc');
|
|
@@ -80,38 +104,132 @@ const CONFIG_SCHEMA = z.object({
|
|
|
80
104
|
gateways: z.unknown().optional(),
|
|
81
105
|
hooks: z.unknown().optional(),
|
|
82
106
|
leader: buildOptionalString(),
|
|
107
|
+
targets: z.unknown().optional(),
|
|
108
|
+
defaultTarget: z.unknown().optional(),
|
|
109
|
+
principals: z.unknown().optional(),
|
|
83
110
|
});
|
|
84
111
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Reads and parses config.json. Only an absent file means every default,
|
|
114
|
+
* the implicit `local` target included, and a first run writes those
|
|
115
|
+
* defaults out. A file that exists but cannot be read or parsed loads as
|
|
116
|
+
* unusable: every default but the targets, which it leaves empty with the
|
|
117
|
+
* problem as the one target error, so nothing runs until the file is fixed.
|
|
118
|
+
* Never throws.
|
|
119
|
+
*/
|
|
120
|
+
export function loadConfig(file: string = configFile): Config {
|
|
121
|
+
// Every atc process writes under the state directory; a failure here
|
|
122
|
+
// comes back from the first write into it.
|
|
123
|
+
try {
|
|
124
|
+
mkdirSync(stateDir, { recursive: true });
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
let text: string;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
text = readFileSync(file, 'utf8');
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code: unknown = error instanceof Error ? Reflect.get(error, 'code') : undefined;
|
|
133
|
+
|
|
134
|
+
if (code !== 'ENOENT') {
|
|
135
|
+
const detail = typeof code === 'string' ? code : 'the file could not be read';
|
|
88
136
|
|
|
89
|
-
|
|
137
|
+
return buildUnusableConfig('config_unreadable', file, detail);
|
|
138
|
+
}
|
|
90
139
|
|
|
91
|
-
|
|
92
|
-
writeFileSync(file, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
|
|
140
|
+
tryWriteDefaultConfig(file);
|
|
93
141
|
|
|
94
142
|
return { ...DEFAULTS };
|
|
95
143
|
}
|
|
96
144
|
|
|
145
|
+
let raw: unknown;
|
|
146
|
+
|
|
97
147
|
try {
|
|
98
|
-
|
|
148
|
+
raw = JSON.parse(text);
|
|
99
149
|
} catch {
|
|
100
|
-
|
|
150
|
+
// The parser's own message can quote the file's text, so the detail is
|
|
151
|
+
// a fixed phrase instead.
|
|
152
|
+
return buildUnusableConfig('config_malformed', file, 'the file is not valid JSON');
|
|
101
153
|
}
|
|
154
|
+
|
|
155
|
+
return parseConfig(raw, file);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A config for a file that exists but cannot be used: every default but the
|
|
160
|
+
* targets, which are empty, with no default target and the file's problem
|
|
161
|
+
* as the one target error, and the principals, which get no target, since
|
|
162
|
+
* the file's own principals cannot be read.
|
|
163
|
+
*/
|
|
164
|
+
function buildUnusableConfig(
|
|
165
|
+
problem: 'config_malformed' | 'config_unreadable',
|
|
166
|
+
path: string,
|
|
167
|
+
detail: string,
|
|
168
|
+
): Config {
|
|
169
|
+
return {
|
|
170
|
+
...DEFAULTS,
|
|
171
|
+
targets: [],
|
|
172
|
+
defaultTarget: null,
|
|
173
|
+
targetErrors: [{ scope: 'config', problem, path, detail }],
|
|
174
|
+
principals: new Map(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Writes the first-run config, never over a file that appeared since the
|
|
180
|
+
* read. A failure leaves the defaults in effect for this run, so it is not
|
|
181
|
+
* an error.
|
|
182
|
+
*/
|
|
183
|
+
function tryWriteDefaultConfig(file: string): void {
|
|
184
|
+
try {
|
|
185
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
186
|
+
writeFileSync(file, renderDefaultConfig(), { flag: 'wx' });
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The config.json text a first run writes. It leaves out the targets and
|
|
192
|
+
* principals, so the file holds the one implicit `local` target and no
|
|
193
|
+
* principals until the user sets their own, and the errors a parse
|
|
194
|
+
* reports, which belong to no file.
|
|
195
|
+
*/
|
|
196
|
+
export function renderDefaultConfig(): string {
|
|
197
|
+
const {
|
|
198
|
+
targets: _targets,
|
|
199
|
+
defaultTarget: _default,
|
|
200
|
+
targetErrors: _errors,
|
|
201
|
+
principals: _principals,
|
|
202
|
+
principalErrors: _principalErrors,
|
|
203
|
+
...written
|
|
204
|
+
} = DEFAULTS;
|
|
205
|
+
|
|
206
|
+
return `${JSON.stringify(written, null, 2)}\n`;
|
|
102
207
|
}
|
|
103
208
|
|
|
104
209
|
/**
|
|
105
210
|
* Parses a user-written config.json's already-decoded JSON value into a
|
|
106
211
|
* Config, applying every default a malformed or absent field falls back to.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
212
|
+
* A root that is not an object leaves the config unusable, with no targets,
|
|
213
|
+
* and `file` is the path its error holds. Total: no shape of `raw` throws,
|
|
214
|
+
* so a hand-edited config never stops atc starting.
|
|
109
215
|
*/
|
|
110
|
-
export function parseConfig(raw: unknown): Config {
|
|
216
|
+
export function parseConfig(raw: unknown, file: string = configFile): Config {
|
|
217
|
+
if (!isRecord(raw) || Array.isArray(raw)) {
|
|
218
|
+
return buildUnusableConfig(
|
|
219
|
+
'config_malformed',
|
|
220
|
+
file,
|
|
221
|
+
`the root is ${formatJSONKind(raw)}, not an object`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
111
225
|
const parsed = CONFIG_SCHEMA.safeParse(raw);
|
|
112
226
|
|
|
113
227
|
if (!parsed.success) {
|
|
114
|
-
return
|
|
228
|
+
return buildUnusableConfig(
|
|
229
|
+
'config_malformed',
|
|
230
|
+
file,
|
|
231
|
+
'the file does not match the config schema',
|
|
232
|
+
);
|
|
115
233
|
}
|
|
116
234
|
|
|
117
235
|
const claudeBin = parsed.data.claudeBin ?? DEFAULTS.claudeBin;
|
|
@@ -123,6 +241,8 @@ export function parseConfig(raw: unknown): Config {
|
|
|
123
241
|
const dirs = { roots: collectDirRoots(parsed.data.dirs) };
|
|
124
242
|
const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
|
|
125
243
|
const hooks = collectHooks(parsed.data.hooks);
|
|
244
|
+
const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
|
|
245
|
+
const principals = collectPrincipals(parsed.data.principals);
|
|
126
246
|
|
|
127
247
|
const leader =
|
|
128
248
|
(parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
|
|
@@ -138,6 +258,11 @@ export function parseConfig(raw: unknown): Config {
|
|
|
138
258
|
gateways,
|
|
139
259
|
hooks,
|
|
140
260
|
leader,
|
|
261
|
+
targets: targets.targets,
|
|
262
|
+
defaultTarget: targets.defaultTarget,
|
|
263
|
+
targetErrors: targets.errors,
|
|
264
|
+
principals: principals.principals,
|
|
265
|
+
principalErrors: principals.errors,
|
|
141
266
|
};
|
|
142
267
|
}
|
|
143
268
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Tagged } from 'type-fest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The id a daemon holds for its whole life, minted once into its state
|
|
5
|
+
* store. It names the daemon in `daemon.hello`, in every session locator,
|
|
6
|
+
* and in the ownership row of each session the daemon persists.
|
|
7
|
+
*/
|
|
8
|
+
export type DaemonID = Tagged<string, 'DaemonID'>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kind of a decoded JSON value, as an error detail: "null", "an array",
|
|
3
|
+
* "an object", "a string", "a number", or "a boolean". It never includes
|
|
4
|
+
* the value itself, so a config diagnostic built from it holds nothing the
|
|
5
|
+
* file holds.
|
|
6
|
+
*/
|
|
7
|
+
export function formatJSONKind(raw: unknown): string {
|
|
8
|
+
if (raw === null) {
|
|
9
|
+
return 'null';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (Array.isArray(raw)) {
|
|
13
|
+
return 'an array';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (typeof raw === 'object') {
|
|
17
|
+
return 'an object';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return `a ${typeof raw}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One line-framed connection to a session bridge, from inside a remote
|
|
3
|
+
* session: each line the bridge writes arrives whole, and each line written
|
|
4
|
+
* here goes out in order, across as many socket writes as it takes.
|
|
5
|
+
*/
|
|
6
|
+
export interface BridgeSocket {
|
|
7
|
+
readonly writeLine: (value: Readonly<Record<string, unknown>>) => void;
|
|
8
|
+
readonly end: () => void;
|
|
9
|
+
|
|
10
|
+
// Settles once the connection has closed, from either side.
|
|
11
|
+
readonly closed: Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Connects to the session bridge at a unix socket path. Rejects when
|
|
16
|
+
* nothing listens there.
|
|
17
|
+
*/
|
|
18
|
+
export async function openBridgeSocket(
|
|
19
|
+
path: string,
|
|
20
|
+
onLine: (line: Readonly<Record<string, unknown>>) => void,
|
|
21
|
+
): Promise<BridgeSocket> {
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
const decoder = new TextDecoder();
|
|
24
|
+
|
|
25
|
+
const closed = Promise.withResolvers<void>();
|
|
26
|
+
let pending = '';
|
|
27
|
+
|
|
28
|
+
let unsent = new Uint8Array(0);
|
|
29
|
+
|
|
30
|
+
const socket = await Bun.connect({
|
|
31
|
+
unix: path,
|
|
32
|
+
socket: {
|
|
33
|
+
data(_socket, buf) {
|
|
34
|
+
const lines = `${pending}${decoder.decode(buf, { stream: true })}`.split('\n');
|
|
35
|
+
|
|
36
|
+
pending = lines.pop() ?? '';
|
|
37
|
+
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const parsed = parseLine(line);
|
|
40
|
+
|
|
41
|
+
if (parsed !== null) {
|
|
42
|
+
onLine(parsed);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
drain(s) {
|
|
47
|
+
unsent = unsent.subarray(s.write(unsent));
|
|
48
|
+
},
|
|
49
|
+
close() {
|
|
50
|
+
closed.resolve();
|
|
51
|
+
},
|
|
52
|
+
error() {
|
|
53
|
+
closed.resolve();
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
writeLine: (value) => {
|
|
60
|
+
const bytes = encoder.encode(`${JSON.stringify(value)}\n`);
|
|
61
|
+
|
|
62
|
+
const joined = new Uint8Array(unsent.length + bytes.length);
|
|
63
|
+
|
|
64
|
+
joined.set(unsent, 0);
|
|
65
|
+
joined.set(bytes, unsent.length);
|
|
66
|
+
|
|
67
|
+
unsent = joined.subarray(socket.write(joined));
|
|
68
|
+
},
|
|
69
|
+
end: () => {
|
|
70
|
+
socket.end();
|
|
71
|
+
},
|
|
72
|
+
closed: closed.promise,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseLine(line: string): Readonly<Record<string, unknown>> | null {
|
|
77
|
+
try {
|
|
78
|
+
const parsed: unknown = JSON.parse(line);
|
|
79
|
+
|
|
80
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
81
|
+
? Object.fromEntries(Object.entries(parsed))
|
|
82
|
+
: null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { openBridgeSocket } from './open-bridge-socket';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sends one request to the session bridge at a unix socket path and
|
|
6
|
+
* returns its answer line, or null when nothing listens there, the
|
|
7
|
+
* connection closes first, or no answer arrives in time. Never throws.
|
|
8
|
+
*/
|
|
9
|
+
export async function sendBridgeRequest(
|
|
10
|
+
path: string,
|
|
11
|
+
op: string,
|
|
12
|
+
args: Readonly<Record<string, unknown>>,
|
|
13
|
+
timeoutMs: number,
|
|
14
|
+
): Promise<Readonly<Record<string, unknown>> | null> {
|
|
15
|
+
const id = randomUUID();
|
|
16
|
+
const answered = Promise.withResolvers<Readonly<Record<string, unknown>> | null>();
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const socket = await openBridgeSocket(path, (line) => {
|
|
20
|
+
if (line['id'] === id) {
|
|
21
|
+
answered.resolve(line);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
void (async () => {
|
|
26
|
+
await socket.closed;
|
|
27
|
+
|
|
28
|
+
answered.resolve(null);
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
answered.resolve(null);
|
|
33
|
+
}, timeoutMs);
|
|
34
|
+
|
|
35
|
+
socket.writeLine({ ...args, v: 1, id, op });
|
|
36
|
+
|
|
37
|
+
const answer = await answered.promise;
|
|
38
|
+
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
|
|
41
|
+
socket.end();
|
|
42
|
+
|
|
43
|
+
return answer;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a JSON value with every object's keys in sorted order, at every
|
|
3
|
+
* depth, so two values that differ only in key order serialize the same.
|
|
4
|
+
*/
|
|
5
|
+
export function sortJSONKeys(value: unknown): unknown {
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
return value.map((item) => sortJSONKeys(item));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof value === 'object' && value !== null) {
|
|
11
|
+
return Object.fromEntries(
|
|
12
|
+
Object.entries(value)
|
|
13
|
+
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
14
|
+
.map(([key, item]) => [key, sortJSONKeys(item)]),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DaemonID } from './daemon-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trusts a string as a daemon id. This is the one point in the codebase
|
|
5
|
+
* where a plain string becomes a `DaemonID`, and it adds no runtime check:
|
|
6
|
+
* the caller asserts the string is the id a daemon minted for itself.
|
|
7
|
+
*/
|
|
8
|
+
export function toDaemonID(id: string): DaemonID {
|
|
9
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- the one point where a string is trusted as a DaemonID
|
|
10
|
+
return id as DaemonID;
|
|
11
|
+
}
|
package/src/statusline.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { statusFile } from './shared/config';
|
|
5
5
|
import { isRecord, sendReport } from './shared/report';
|
|
6
|
+
import { sendBridgeRequest } from './shared/send-bridge-request';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Runs as the statusLine command injected into wrangled sessions. Chains the
|
|
@@ -60,6 +61,19 @@ export async function runStatusline(): Promise<void> {
|
|
|
60
61
|
}
|
|
61
62
|
} catch {}
|
|
62
63
|
|
|
64
|
+
const segment =
|
|
65
|
+
process.env['ATC_BRIDGE'] === '1' && sock !== undefined && sock !== ''
|
|
66
|
+
? await readOwnSegment(sock)
|
|
67
|
+
: readFleetSegment();
|
|
68
|
+
|
|
69
|
+
const line = [chained, segment].filter((part) => part !== '').join(' \u001B[90m▏\u001B[0m ');
|
|
70
|
+
|
|
71
|
+
console.log(line);
|
|
72
|
+
process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The fleet segment, from the status file the daemon writes beside it.
|
|
76
|
+
function readFleetSegment(): string {
|
|
63
77
|
let segment = '';
|
|
64
78
|
|
|
65
79
|
try {
|
|
@@ -90,10 +104,27 @@ export async function runStatusline(): Promise<void> {
|
|
|
90
104
|
}
|
|
91
105
|
} catch {}
|
|
92
106
|
|
|
93
|
-
|
|
107
|
+
return segment;
|
|
108
|
+
}
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
// Inside a remote host the status file is out of reach, and the session
|
|
111
|
+
// bridge returns the session's own state alone, never the rest of the fleet.
|
|
112
|
+
async function readOwnSegment(sock: string): Promise<string> {
|
|
113
|
+
const status = await sendBridgeRequest(sock, 'status.read', {}, 500);
|
|
114
|
+
|
|
115
|
+
if (status === null || status['ok'] !== true) {
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (status['state'] === 'needs_you') {
|
|
120
|
+
return '\u001B[1;31m● needs you\u001B[0m';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (status['state'] === 'done') {
|
|
124
|
+
return '\u001B[32m✓ done\u001B[0m';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return status['state'] === 'running' ? '\u001B[36m◐ running\u001B[0m' : '';
|
|
97
128
|
}
|
|
98
129
|
|
|
99
130
|
// A user statusline that is atc's own injected command would chain into
|