@zgeoff/atc 0.1.11 → 1.0.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 +54 -15
- package/package.json +7 -2
- package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
- package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
- package/src/agents/build-hook-settings.ts +59 -0
- package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
- package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
- package/src/agents/gateway-adapter.ts +106 -0
- package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
- package/src/agents/resolve-agent-home.ts +13 -0
- package/src/agents/truncate-detail.ts +7 -0
- package/src/agents/write-hook-settings.ts +37 -0
- package/src/cli.ts +25 -14
- package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
- package/src/client/build-client-machine.ts +119 -0
- package/src/client/collect-agent-picks.ts +31 -0
- package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
- package/src/{dirs.ts → client/dirs.ts} +8 -0
- package/src/client/format-overlay-agent-mark.ts +15 -0
- package/src/{index.ts → client/index.ts} +181 -550
- package/src/client/keys.ts +111 -0
- package/src/client/parse-daemon-event.ts +114 -0
- package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
- package/src/client/spawn-picker.ts +305 -0
- package/src/client/to-mirror-session.ts +65 -0
- package/src/{ui.ts → client/ui.ts} +13 -8
- package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
- package/src/daemon/build-session-event.ts +43 -0
- package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
- package/src/{daemon.ts → daemon/daemon.ts} +142 -301
- package/src/{hooks.ts → daemon/hooks.ts} +6 -4
- package/src/daemon/mint-session-id.ts +12 -0
- package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
- package/src/daemon/restore-fleet.ts +141 -0
- package/src/daemon/run-eject-handoff.ts +33 -0
- package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
- package/src/daemon/session-runtime.ts +64 -0
- package/src/{sessions.ts → daemon/sessions.ts} +98 -144
- package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
- package/src/daemon/start-headless-turn.ts +57 -0
- package/src/hook-report.ts +2 -2
- package/src/mcp-server.ts +33 -44
- package/src/protocol/parse-request-params.ts +31 -0
- package/src/{protocol.ts → protocol/protocol.ts} +1 -1
- package/src/protocol/request-param-schemas.ts +109 -0
- package/src/shared/agent-session-id.ts +8 -0
- package/src/shared/build-optional-boolean.ts +10 -0
- package/src/shared/build-optional-string-array.ts +14 -0
- package/src/shared/build-optional-string.ts +10 -0
- package/src/shared/collect-gateways.ts +113 -0
- package/src/{config.ts → shared/config.ts} +47 -31
- package/src/{get-build.ts → shared/get-build.ts} +21 -6
- package/src/shared/get-record.ts +20 -0
- package/src/shared/session-id.ts +8 -0
- package/src/shared/to-agent-session-id.ts +12 -0
- package/src/shared/to-session-id.ts +11 -0
- package/src/shared/to-shell-arg.ts +7 -0
- package/src/statusline.ts +2 -2
- package/src/store/bun-sqlite-driver.ts +96 -0
- package/src/store/fleet-entry.ts +72 -0
- package/src/store/run-migrations.ts +242 -0
- package/src/store/state-store.ts +226 -0
- package/src/format-overlay-agent-mark.ts +0 -17
- package/src/state-store.ts +0 -227
- /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
- /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
- /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
- /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
- /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
- /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
- /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
- /package/src/{report.ts → shared/report.ts} +0 -0
- /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
- /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
import { REQUEST_PARAM_SCHEMAS } from './request-param-schemas';
|
|
4
|
+
|
|
5
|
+
export type RequestMethod = keyof typeof REQUEST_PARAM_SCHEMAS;
|
|
6
|
+
|
|
7
|
+
type RequestParams<M extends RequestMethod> = z.infer<(typeof REQUEST_PARAM_SCHEMAS)[M]>;
|
|
8
|
+
|
|
9
|
+
export type ParsedRequestParams<M extends RequestMethod> =
|
|
10
|
+
| { readonly ok: true; readonly data: RequestParams<M> }
|
|
11
|
+
| { readonly ok: false; readonly message: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validates and defaults one request's params against its method's schema.
|
|
15
|
+
* A malformed shape folds into a single message the caller reports as
|
|
16
|
+
* bad_args, instead of a thrown ZodError.
|
|
17
|
+
*/
|
|
18
|
+
export function parseRequestParams<M extends RequestMethod>(
|
|
19
|
+
method: M,
|
|
20
|
+
rawParams: unknown,
|
|
21
|
+
): ParsedRequestParams<M> {
|
|
22
|
+
const input = isRecord(rawParams) ? rawParams : {};
|
|
23
|
+
const result = REQUEST_PARAM_SCHEMAS[method].safeParse(input);
|
|
24
|
+
|
|
25
|
+
if (!result.success) {
|
|
26
|
+
return { ok: false, message: result.error.issues[0]?.message ?? 'invalid params' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- method narrows M, but TS can't follow that through an indexed lookup into the schema map; a successful safeParse against that same lookup guarantees the shape at runtime
|
|
30
|
+
return { ok: true, data: result.data as RequestParams<M> };
|
|
31
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildOptionalBoolean } from '../shared/build-optional-boolean';
|
|
3
|
+
import { buildOptionalString } from '../shared/build-optional-string';
|
|
4
|
+
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
5
|
+
import { toSessionID } from '../shared/to-session-id';
|
|
6
|
+
|
|
7
|
+
const EJECT_DEFAULT_PROMPT =
|
|
8
|
+
'Continue the task autonomously. Verify your work as you go and stop when it is complete.';
|
|
9
|
+
|
|
10
|
+
// The `session` field every wire schema below carries: absent or
|
|
11
|
+
// wrong-typed falls back to an empty string instead of failing the parse.
|
|
12
|
+
// This is the one point where a session id arriving off the wire is typed
|
|
13
|
+
// as the branded atc session id every daemon-side session lookup expects.
|
|
14
|
+
const SESSION_DEFAULTED = z.object({
|
|
15
|
+
session: buildDefaultedString('').transform(toSessionID),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const REQUEST_PARAM_SCHEMAS = {
|
|
19
|
+
'daemon.hello': z.object({
|
|
20
|
+
client: buildDefaultedString('unknown client'),
|
|
21
|
+
}),
|
|
22
|
+
'daemon.ping': z.object({}),
|
|
23
|
+
'daemon.quit': z.object({}),
|
|
24
|
+
'session.list': z.object({}),
|
|
25
|
+
'dirs.list': z.object({}),
|
|
26
|
+
'fleet.list': z.object({}),
|
|
27
|
+
'fleet.restore': z.object({
|
|
28
|
+
cols: buildDefaultedNumber(80),
|
|
29
|
+
rows: buildDefaultedNumber(24),
|
|
30
|
+
}),
|
|
31
|
+
'session.spawn': z.object({
|
|
32
|
+
cwd: z.string({ error: 'session.spawn requires a cwd' }).min(1, 'session.spawn requires a cwd'),
|
|
33
|
+
name: buildDefaultedString(''),
|
|
34
|
+
prompt: buildDefaultedString(''),
|
|
35
|
+
cols: buildDefaultedNumber(80),
|
|
36
|
+
rows: buildDefaultedNumber(24),
|
|
37
|
+
|
|
38
|
+
resume: buildDefaultedBooleanOrString(false).transform((v) =>
|
|
39
|
+
typeof v === 'string' ? toAgentSessionID(v) : v,
|
|
40
|
+
),
|
|
41
|
+
agent: z
|
|
42
|
+
.string({ error: 'session.spawn agent must be a non-empty agent id' })
|
|
43
|
+
.min(1, 'session.spawn agent must be a non-empty agent id')
|
|
44
|
+
.optional(),
|
|
45
|
+
}),
|
|
46
|
+
'session.kill': SESSION_DEFAULTED,
|
|
47
|
+
'session.ack': SESSION_DEFAULTED,
|
|
48
|
+
'session.update': SESSION_DEFAULTED.extend({
|
|
49
|
+
name: buildOptionalString(),
|
|
50
|
+
pinned: buildOptionalBoolean(),
|
|
51
|
+
}),
|
|
52
|
+
'session.attach': SESSION_DEFAULTED.extend({
|
|
53
|
+
cols: buildDefaultedNumber(80),
|
|
54
|
+
rows: buildDefaultedNumber(24),
|
|
55
|
+
}),
|
|
56
|
+
'session.detach': SESSION_DEFAULTED,
|
|
57
|
+
'session.input': SESSION_DEFAULTED.extend({
|
|
58
|
+
d: buildDefaultedString(''),
|
|
59
|
+
}),
|
|
60
|
+
'session.resize': SESSION_DEFAULTED.extend({
|
|
61
|
+
cols: buildDefaultedNumber(0),
|
|
62
|
+
rows: buildDefaultedNumber(0),
|
|
63
|
+
}).refine((v) => v.cols >= 1 && v.rows >= 1, {
|
|
64
|
+
message: 'session.resize requires positive cols and rows',
|
|
65
|
+
}),
|
|
66
|
+
'session.resumeCommand': SESSION_DEFAULTED,
|
|
67
|
+
'session.eject': SESSION_DEFAULTED.extend({
|
|
68
|
+
prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
|
|
69
|
+
}),
|
|
70
|
+
'session.adopt': SESSION_DEFAULTED.extend({
|
|
71
|
+
cols: buildDefaultedNumber(80),
|
|
72
|
+
rows: buildDefaultedNumber(24),
|
|
73
|
+
}),
|
|
74
|
+
'permission.respond': z
|
|
75
|
+
.object({
|
|
76
|
+
request: buildDefaultedString(''),
|
|
77
|
+
decision: buildDefaultedString(''),
|
|
78
|
+
})
|
|
79
|
+
.refine((v) => v.request !== '' && v.decision !== '', {
|
|
80
|
+
message: 'permission.respond requires a request and a decision',
|
|
81
|
+
}),
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
function buildDefaultedString(fallback: string) {
|
|
85
|
+
return z.preprocess((v) => (typeof v === 'string' ? v : undefined), z.string().default(fallback));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildDefaultedNumber(fallback: number) {
|
|
89
|
+
return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildDefaultedBooleanOrString(fallback: boolean | string) {
|
|
93
|
+
return z.preprocess(
|
|
94
|
+
(v) => (typeof v === 'boolean' || typeof v === 'string' ? v : undefined),
|
|
95
|
+
z.union([z.boolean(), z.string()]).default(fallback),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Absent or wrong-typed stays undefined rather than falling back to a
|
|
100
|
+
// value, so the caller can tell "not given" from "given" and skip an update.
|
|
101
|
+
|
|
102
|
+
// An explicit empty string also falls back to the default instead of
|
|
103
|
+
// standing as a deliberate empty value.
|
|
104
|
+
function buildDefaultedNonEmptyString(fallback: string) {
|
|
105
|
+
return z.preprocess(
|
|
106
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
107
|
+
z.string().default(fallback),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Tagged } from 'type-fest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The session id an agent CLI issues for its own resume mechanism — what
|
|
5
|
+
* `claude --resume <id>` takes. Adapters mint it from a hook payload or a
|
|
6
|
+
* stored fleet row, and the fleet table keys on it.
|
|
7
|
+
*/
|
|
8
|
+
export type AgentSessionID = Tagged<string, 'AgentSessionID'>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A boolean that tolerates anything else in its place: a non-boolean parses
|
|
5
|
+
* to undefined so the caller's default stands, rather than failing the whole
|
|
6
|
+
* object it belongs to.
|
|
7
|
+
*/
|
|
8
|
+
export function buildOptionalBoolean() {
|
|
9
|
+
return z.preprocess((v) => (typeof v === 'boolean' ? v : undefined), z.boolean().optional());
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A list of strings that tolerates anything else in its place. A non-array
|
|
5
|
+
* parses to undefined so the caller's default stands, and a mixed array keeps
|
|
6
|
+
* the strings in it rather than being discarded whole — a config listing one
|
|
7
|
+
* bad argument still contributes its good ones.
|
|
8
|
+
*/
|
|
9
|
+
export function buildOptionalStringArray() {
|
|
10
|
+
return z.preprocess(
|
|
11
|
+
(v) => (Array.isArray(v) ? v.filter((a): a is string => typeof a === 'string') : undefined),
|
|
12
|
+
z.array(z.string()).optional(),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A string that tolerates anything else in its place: a non-string parses to
|
|
5
|
+
* undefined so the caller's default stands, rather than failing the whole
|
|
6
|
+
* object it belongs to.
|
|
7
|
+
*/
|
|
8
|
+
export function buildOptionalString() {
|
|
9
|
+
return z.preprocess((v) => (typeof v === 'string' ? v : undefined), z.string().optional());
|
|
10
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { AgentID } from '../agents/agent-adapter';
|
|
3
|
+
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
4
|
+
import { isRecord } from './report';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Claude-compatible backend the Claude CLI is pointed at: its own agent id,
|
|
8
|
+
* its own row in the spawn menu, and its own generated settings file. The
|
|
9
|
+
* credential is never held here — a helper command supplies it at run time,
|
|
10
|
+
* so it stays out of the file atc writes.
|
|
11
|
+
*/
|
|
12
|
+
export interface GatewayConfig {
|
|
13
|
+
readonly id: AgentID;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
readonly mark: string;
|
|
16
|
+
readonly bin: string;
|
|
17
|
+
readonly args: readonly string[];
|
|
18
|
+
readonly baseURL: string;
|
|
19
|
+
readonly apiKeyHelper?: string;
|
|
20
|
+
readonly env: Readonly<Record<string, string>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Ids the built-in adapters answer to; a gateway may not take one.
|
|
24
|
+
const BUILT_IN_IDS = new Set(['claude', 'grok', 'codex']);
|
|
25
|
+
|
|
26
|
+
// One gateway map entry's keys. An absent or wrong-typed field parses to
|
|
27
|
+
// undefined rather than failing the entry, so a gateway with one bad field
|
|
28
|
+
// still registers with the default for it.
|
|
29
|
+
const GATEWAY_ENTRY_SCHEMA = z.object({
|
|
30
|
+
label: buildOptionalNonEmptyString(),
|
|
31
|
+
mark: buildOptionalNonEmptyString(),
|
|
32
|
+
baseURL: buildOptionalNonEmptyString(),
|
|
33
|
+
bin: buildOptionalNonEmptyString(),
|
|
34
|
+
args: buildOptionalStringArray(),
|
|
35
|
+
apiKeyHelper: buildOptionalNonEmptyString(),
|
|
36
|
+
env: buildStringEnvRecord(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reads the gateway map into menu order. An entry without a base URL, or
|
|
41
|
+
* under an id another adapter already answers to, is left out rather than
|
|
42
|
+
* registered half-formed: every gateway in the result can be spawned.
|
|
43
|
+
*/
|
|
44
|
+
export function collectGateways(
|
|
45
|
+
raw: unknown,
|
|
46
|
+
claudeBin: string,
|
|
47
|
+
claudeArgs: readonly string[],
|
|
48
|
+
): GatewayConfig[] {
|
|
49
|
+
if (!isRecord(raw)) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const gateways: GatewayConfig[] = [];
|
|
54
|
+
|
|
55
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
56
|
+
if (id === '' || BUILT_IN_IDS.has(id)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const parsed = GATEWAY_ENTRY_SCHEMA.safeParse(entry);
|
|
61
|
+
|
|
62
|
+
if (!parsed.success || parsed.data.baseURL === undefined) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const firstOfMark = (parsed.data.mark ?? id).codePointAt(0);
|
|
67
|
+
|
|
68
|
+
gateways.push({
|
|
69
|
+
id,
|
|
70
|
+
label: parsed.data.label ?? id,
|
|
71
|
+
|
|
72
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- mark falls back to id, and both are checked non-empty above, so codePointAt(0) always returns a code point
|
|
73
|
+
mark: String.fromCodePoint(firstOfMark as number),
|
|
74
|
+
bin: parsed.data.bin ?? claudeBin,
|
|
75
|
+
args: parsed.data.args ?? claudeArgs,
|
|
76
|
+
baseURL: parsed.data.baseURL,
|
|
77
|
+
...(parsed.data.apiKeyHelper === undefined ? {} : { apiKeyHelper: parsed.data.apiKeyHelper }),
|
|
78
|
+
env: parsed.data.env,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return gateways;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildOptionalNonEmptyString() {
|
|
86
|
+
return z.preprocess(
|
|
87
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
88
|
+
z.string().optional(),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// String values only: everything here is handed to a child process as an
|
|
93
|
+
// environment variable.
|
|
94
|
+
function buildStringEnvRecord() {
|
|
95
|
+
return z.preprocess(
|
|
96
|
+
(v) => {
|
|
97
|
+
if (!isRecord(v)) {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const env: Record<string, string> = {};
|
|
102
|
+
|
|
103
|
+
for (const [key, value] of Object.entries(v)) {
|
|
104
|
+
if (typeof value === 'string') {
|
|
105
|
+
env[key] = value;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return env;
|
|
110
|
+
},
|
|
111
|
+
z.record(z.string(), z.string()),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { buildOptionalString } from './build-optional-string';
|
|
6
|
+
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
7
|
+
import { collectGateways } from './collect-gateways';
|
|
8
|
+
import type { GatewayConfig } from './collect-gateways';
|
|
5
9
|
|
|
6
10
|
export interface Config {
|
|
7
11
|
claudeBin: string;
|
|
@@ -10,6 +14,7 @@ export interface Config {
|
|
|
10
14
|
grokArgs: string[];
|
|
11
15
|
codexBin: string;
|
|
12
16
|
codexArgs: string[];
|
|
17
|
+
gateways: GatewayConfig[];
|
|
13
18
|
leader: LeaderKey;
|
|
14
19
|
}
|
|
15
20
|
|
|
@@ -25,6 +30,7 @@ const DEFAULTS: Config = {
|
|
|
25
30
|
grokArgs: [],
|
|
26
31
|
codexBin: 'codex',
|
|
27
32
|
codexArgs: [],
|
|
33
|
+
gateways: [],
|
|
28
34
|
leader: { code: 0, label: '^Space' },
|
|
29
35
|
};
|
|
30
36
|
|
|
@@ -38,6 +44,20 @@ export const dbFile = join(stateDir, 'atc.db');
|
|
|
38
44
|
export const legacyFleetFile = join(stateDir, 'fleet.json');
|
|
39
45
|
export const daemonPidFile = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.pid');
|
|
40
46
|
|
|
47
|
+
// A user-written config.json's top-level keys. An absent or wrong-typed
|
|
48
|
+
// field parses to undefined rather than failing the file, so a bad config
|
|
49
|
+
// falls back to a default instead of refusing to start atc.
|
|
50
|
+
const CONFIG_SCHEMA = z.object({
|
|
51
|
+
claudeBin: buildOptionalString(),
|
|
52
|
+
claudeArgs: buildOptionalStringArray(),
|
|
53
|
+
grokBin: buildOptionalString(),
|
|
54
|
+
grokArgs: buildOptionalStringArray(),
|
|
55
|
+
codexBin: buildOptionalString(),
|
|
56
|
+
codexArgs: buildOptionalStringArray(),
|
|
57
|
+
gateways: z.unknown().optional(),
|
|
58
|
+
leader: buildOptionalString(),
|
|
59
|
+
});
|
|
60
|
+
|
|
41
61
|
export function loadConfig(): Config {
|
|
42
62
|
mkdirSync(configDir, { recursive: true });
|
|
43
63
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -51,41 +71,37 @@ export function loadConfig(): Config {
|
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const claudeBin =
|
|
62
|
-
typeof parsed['claudeBin'] === 'string' ? parsed['claudeBin'] : DEFAULTS.claudeBin;
|
|
63
|
-
|
|
64
|
-
const claudeArgs = Array.isArray(parsed['claudeArgs'])
|
|
65
|
-
? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
|
|
66
|
-
: DEFAULTS.claudeArgs;
|
|
67
|
-
|
|
68
|
-
const grokBin = typeof parsed['grokBin'] === 'string' ? parsed['grokBin'] : DEFAULTS.grokBin;
|
|
74
|
+
return parseConfig(JSON.parse(readFileSync(file, 'utf8')));
|
|
75
|
+
} catch {
|
|
76
|
+
return { ...DEFAULTS };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Parses a user-written config.json's already-decoded JSON value into a
|
|
82
|
+
* Config, applying every default a malformed or absent field falls back to.
|
|
83
|
+
* Total: no shape of `raw` throws, so a hand-edited config never stops atc
|
|
84
|
+
* starting.
|
|
85
|
+
*/
|
|
86
|
+
export function parseConfig(raw: unknown): Config {
|
|
87
|
+
const parsed = CONFIG_SCHEMA.safeParse(raw);
|
|
73
88
|
|
|
74
|
-
|
|
75
|
-
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
return { ...DEFAULTS };
|
|
91
|
+
}
|
|
76
92
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
const claudeBin = parsed.data.claudeBin ?? DEFAULTS.claudeBin;
|
|
94
|
+
const claudeArgs = parsed.data.claudeArgs ?? DEFAULTS.claudeArgs;
|
|
95
|
+
const grokBin = parsed.data.grokBin ?? DEFAULTS.grokBin;
|
|
96
|
+
const grokArgs = parsed.data.grokArgs ?? DEFAULTS.grokArgs;
|
|
97
|
+
const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
|
|
98
|
+
const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
|
|
99
|
+
const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
|
|
80
100
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
DEFAULTS.leader;
|
|
101
|
+
const leader =
|
|
102
|
+
(parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
|
|
84
103
|
|
|
85
|
-
|
|
86
|
-
} catch {
|
|
87
|
-
return { ...DEFAULTS };
|
|
88
|
-
}
|
|
104
|
+
return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, gateways, leader };
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
// Control bytes the terminal needs for its own input: enter, tab, and esc
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import pkg from '
|
|
3
|
+
import pkg from '../../package.json';
|
|
4
|
+
|
|
5
|
+
// The src/ root, so the walk covers every module under it.
|
|
6
|
+
const SRC_ROOT = join(import.meta.dir, '..');
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* The build identity used in the protocol handshake. The version alone
|
|
@@ -15,11 +18,7 @@ export function getBuild(): string {
|
|
|
15
18
|
let stamp = 0;
|
|
16
19
|
|
|
17
20
|
try {
|
|
18
|
-
|
|
19
|
-
if (entry.endsWith('.ts')) {
|
|
20
|
-
stamp = Math.max(stamp, statSync(join(import.meta.dir, entry)).mtimeMs);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
21
|
+
stamp = getNewestTSMtime(SRC_ROOT);
|
|
23
22
|
} catch {}
|
|
24
23
|
|
|
25
24
|
if (stamp === 0) {
|
|
@@ -30,3 +29,19 @@ export function getBuild(): string {
|
|
|
30
29
|
|
|
31
30
|
return `atc/${pkg.version}+${Math.round(stamp).toString(36)}`;
|
|
32
31
|
}
|
|
32
|
+
|
|
33
|
+
function getNewestTSMtime(dir: string): number {
|
|
34
|
+
let stamp = 0;
|
|
35
|
+
|
|
36
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
37
|
+
const entryPath = join(dir, entry.name);
|
|
38
|
+
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
stamp = Math.max(stamp, getNewestTSMtime(entryPath));
|
|
41
|
+
} else if (entry.name.endsWith('.ts')) {
|
|
42
|
+
stamp = Math.max(stamp, statSync(entryPath).mtimeMs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return stamp;
|
|
47
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isRecord } from './report';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Narrows one field of a record to a record, for tests pulling a nested
|
|
5
|
+
* payload (a wire event's session descriptor, a response's session) out of
|
|
6
|
+
* loosely typed JSON. Throws rather than letting a malformed fixture pass a
|
|
7
|
+
* test vacuously.
|
|
8
|
+
*/
|
|
9
|
+
export function getRecord(
|
|
10
|
+
value: Readonly<Record<string, unknown>>,
|
|
11
|
+
key: string,
|
|
12
|
+
): Record<string, unknown> {
|
|
13
|
+
const inner = value[key];
|
|
14
|
+
|
|
15
|
+
if (!isRecord(inner)) {
|
|
16
|
+
throw new TypeError(`${key} is not an object`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return inner;
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Tagged } from 'type-fest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The atc-generated session id, minted by the session manager when it spawns
|
|
5
|
+
* or restores a session. It keys every protocol method, every daemon-side
|
|
6
|
+
* per-session map, and the hook envelope's `atcId` field.
|
|
7
|
+
*/
|
|
8
|
+
export type SessionID = Tagged<string, 'SessionID'>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AgentSessionID } from './agent-session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trusts a string as an agent-issued session id. This is the one point in
|
|
5
|
+
* the codebase where a plain string becomes an `AgentSessionID`, and it adds
|
|
6
|
+
* no runtime check: the caller asserts the string is the id an agent CLI
|
|
7
|
+
* issued for its own resume mechanism.
|
|
8
|
+
*/
|
|
9
|
+
export function toAgentSessionID(id: string): AgentSessionID {
|
|
10
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- the one point where a string is trusted as an AgentSessionID
|
|
11
|
+
return id as AgentSessionID;
|
|
12
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SessionID } from './session-id';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trusts a string as an atc session id. This is the one point in the
|
|
5
|
+
* codebase where a plain string becomes a `SessionID`, and it adds no
|
|
6
|
+
* runtime check: the caller asserts the string identifies an atc session.
|
|
7
|
+
*/
|
|
8
|
+
export function toSessionID(id: string): SessionID {
|
|
9
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- the one point where a string is trusted as a SessionID
|
|
10
|
+
return id as SessionID;
|
|
11
|
+
}
|
package/src/statusline.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { statusFile } from './config';
|
|
5
|
-
import { isRecord, sendReport } from './report';
|
|
4
|
+
import { statusFile } from './shared/config';
|
|
5
|
+
import { isRecord, sendReport } from './shared/report';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Runs as the statusLine command injected into wrangled sessions. Chains the
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Database, SQLQueryBindings } from 'bun:sqlite';
|
|
2
|
+
import { CompiledQuery } from 'kysely';
|
|
3
|
+
import type { DatabaseConnection, Driver, QueryResult } from 'kysely';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A kysely `Driver` over one bun:sqlite handle. SQLite has a single
|
|
7
|
+
* connection, so `acquireConnection` hands it out to one caller at a time
|
|
8
|
+
* through a promise-chain mutex and `releaseConnection` lets the next
|
|
9
|
+
* caller in — the ordering every transaction (and every plain query) relies
|
|
10
|
+
* on once the store runs asynchronously.
|
|
11
|
+
*/
|
|
12
|
+
export class BunSqliteDriver implements Driver {
|
|
13
|
+
private readonly connection: DatabaseConnection;
|
|
14
|
+
|
|
15
|
+
private turn: Promise<void> = Promise.resolve();
|
|
16
|
+
|
|
17
|
+
private releaseTurn: () => void = () => {};
|
|
18
|
+
|
|
19
|
+
constructor(sqlite: Database) {
|
|
20
|
+
this.connection = buildConnection(sqlite);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
init(): Promise<void> {
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async acquireConnection(): Promise<DatabaseConnection> {
|
|
28
|
+
const previous = this.turn;
|
|
29
|
+
let release: (() => void) | undefined;
|
|
30
|
+
|
|
31
|
+
this.turn = new Promise((resolve) => {
|
|
32
|
+
release = resolve;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await previous;
|
|
36
|
+
|
|
37
|
+
if (release !== undefined) {
|
|
38
|
+
this.releaseTurn = release;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return this.connection;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async beginTransaction(connection: DatabaseConnection): Promise<void> {
|
|
45
|
+
await connection.executeQuery(CompiledQuery.raw('begin'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async commitTransaction(connection: DatabaseConnection): Promise<void> {
|
|
49
|
+
await connection.executeQuery(CompiledQuery.raw('commit'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
|
|
53
|
+
await connection.executeQuery(CompiledQuery.raw('rollback'));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
releaseConnection(): Promise<void> {
|
|
57
|
+
this.releaseTurn();
|
|
58
|
+
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Waits out whatever turn is already queued before returning, so a caller
|
|
63
|
+
// that closes the underlying connection right after destroy() never does
|
|
64
|
+
// so out from under a write still in flight.
|
|
65
|
+
async destroy(): Promise<void> {
|
|
66
|
+
await this.acquireConnection();
|
|
67
|
+
await this.releaseConnection();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildConnection(sqlite: Database): DatabaseConnection {
|
|
72
|
+
return {
|
|
73
|
+
executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
|
|
74
|
+
const stmt = sqlite.query<R, SQLQueryBindings[]>(compiledQuery.sql);
|
|
75
|
+
|
|
76
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- kysely's sqlite compiler only ever binds bun:sqlite-legal values here; this is the one point where it is trusted
|
|
77
|
+
const bindings = compiledQuery.parameters as SQLQueryBindings[];
|
|
78
|
+
|
|
79
|
+
if (stmt.columnNames.length > 0) {
|
|
80
|
+
return Promise.resolve({ rows: stmt.all(...bindings) });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = stmt.run(...bindings);
|
|
84
|
+
|
|
85
|
+
return Promise.resolve({
|
|
86
|
+
rows: [],
|
|
87
|
+
numAffectedRows: BigInt(result.changes),
|
|
88
|
+
insertId: BigInt(result.lastInsertRowid),
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
|
|
93
|
+
throw new Error('BunSqliteDriver does not support streaming queries');
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|