@zgeoff/atc 0.1.12 → 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.
Files changed (74) hide show
  1. package/README.md +50 -12
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/spawn-picker.ts +305 -0
  25. package/src/client/to-mirror-session.ts +65 -0
  26. package/src/{ui.ts → client/ui.ts} +13 -8
  27. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  28. package/src/daemon/build-session-event.ts +43 -0
  29. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  30. package/src/{daemon.ts → daemon/daemon.ts} +142 -312
  31. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  32. package/src/daemon/mint-session-id.ts +12 -0
  33. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  34. package/src/daemon/restore-fleet.ts +141 -0
  35. package/src/daemon/run-eject-handoff.ts +33 -0
  36. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  37. package/src/daemon/session-runtime.ts +64 -0
  38. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  39. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  40. package/src/daemon/start-headless-turn.ts +57 -0
  41. package/src/hook-report.ts +2 -2
  42. package/src/mcp-server.ts +33 -44
  43. package/src/protocol/parse-request-params.ts +31 -0
  44. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  45. package/src/protocol/request-param-schemas.ts +109 -0
  46. package/src/shared/agent-session-id.ts +8 -0
  47. package/src/shared/build-optional-boolean.ts +10 -0
  48. package/src/shared/build-optional-string-array.ts +14 -0
  49. package/src/shared/build-optional-string.ts +10 -0
  50. package/src/shared/collect-gateways.ts +113 -0
  51. package/src/{config.ts → shared/config.ts} +47 -31
  52. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  53. package/src/shared/get-record.ts +20 -0
  54. package/src/shared/session-id.ts +8 -0
  55. package/src/shared/to-agent-session-id.ts +12 -0
  56. package/src/shared/to-session-id.ts +11 -0
  57. package/src/shared/to-shell-arg.ts +7 -0
  58. package/src/statusline.ts +2 -2
  59. package/src/store/bun-sqlite-driver.ts +96 -0
  60. package/src/store/fleet-entry.ts +72 -0
  61. package/src/store/run-migrations.ts +242 -0
  62. package/src/store/state-store.ts +226 -0
  63. package/src/format-overlay-agent-mark.ts +0 -17
  64. package/src/state-store.ts +0 -236
  65. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  66. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  67. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  68. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  69. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  70. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  71. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  72. /package/src/{report.ts → shared/report.ts} +0 -0
  73. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  74. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
@@ -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 { isRecord } from './report';
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
- const raw = readFileSync(file, 'utf8');
55
- const parsed: unknown = JSON.parse(raw);
56
-
57
- if (!isRecord(parsed)) {
58
- return { ...DEFAULTS };
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
- const grokArgs = Array.isArray(parsed['grokArgs'])
71
- ? parsed['grokArgs'].filter((a): a is string => typeof a === 'string')
72
- : DEFAULTS.grokArgs;
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
- const codexBin =
75
- typeof parsed['codexBin'] === 'string' ? parsed['codexBin'] : DEFAULTS.codexBin;
89
+ if (!parsed.success) {
90
+ return { ...DEFAULTS };
91
+ }
76
92
 
77
- const codexArgs = Array.isArray(parsed['codexArgs'])
78
- ? parsed['codexArgs'].filter((a): a is string => typeof a === 'string')
79
- : DEFAULTS.codexArgs;
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
- const leader =
82
- (typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
83
- DEFAULTS.leader;
101
+ const leader =
102
+ (parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
84
103
 
85
- return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, leader };
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 '../package.json';
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
- for (const entry of readdirSync(import.meta.dir)) {
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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Wraps a value in single quotes so it stands as one shell argument,
3
+ * escaping any single quotes the value itself contains.
4
+ */
5
+ export function toShellArg(value: string): string {
6
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
7
+ }
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
+ }
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import { toAgentID } from '../agents/agent-adapter';
3
+ import type { AgentID } from '../agents/agent-adapter';
4
+ import type { AgentSessionID } from '../shared/agent-session-id';
5
+ import { buildOptionalBoolean } from '../shared/build-optional-boolean';
6
+ import { isRecord } from '../shared/report';
7
+ import { toAgentSessionID } from '../shared/to-agent-session-id';
8
+
9
+ export interface FleetEntry {
10
+ readonly name: string;
11
+ readonly cwd: string;
12
+ readonly agentSessionID: AgentSessionID;
13
+ readonly agent: AgentID;
14
+ readonly pinned?: boolean;
15
+ readonly lastAttachedAt?: number;
16
+ readonly exited?: boolean;
17
+ }
18
+
19
+ export interface FleetStore {
20
+ readonly loadFleet: () => Promise<FleetEntry[]>;
21
+ readonly writeFleet: (entries: readonly FleetEntry[]) => Promise<void>;
22
+ }
23
+
24
+ // A stored fleet row's keys. name, cwd, and the resolved agentSessionID are
25
+ // required: a row missing any of them cannot restore a session, so the whole
26
+ // row parses to undefined rather than a half-built entry. Fleet files
27
+ // written before the id key was agent-neutral carry it under its Claude-era
28
+ // name, so agentSessionID is read off whichever key the row actually has
29
+ // before validation.
30
+ const FLEET_ENTRY_SCHEMA = z.preprocess(
31
+ (value) => {
32
+ if (!isRecord(value)) {
33
+ return value;
34
+ }
35
+
36
+ return { ...value, agentSessionID: value['agentSessionID'] ?? value['claudeId'] };
37
+ },
38
+ z.object({
39
+ name: z.string(),
40
+ cwd: z.string(),
41
+ agentSessionID: z.string(),
42
+ agent: z.unknown().optional(),
43
+ pinned: buildOptionalBoolean(),
44
+ lastAttachedAt: buildOptionalNumber(),
45
+ exited: buildOptionalBoolean(),
46
+ }),
47
+ );
48
+
49
+ export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
50
+ const parsed = FLEET_ENTRY_SCHEMA.safeParse(raw);
51
+
52
+ if (!parsed.success) {
53
+ return undefined;
54
+ }
55
+
56
+ return {
57
+ name: parsed.data.name,
58
+ cwd: parsed.data.cwd,
59
+
60
+ agentSessionID: toAgentSessionID(parsed.data.agentSessionID),
61
+ agent: toAgentID(parsed.data.agent),
62
+ ...(parsed.data.pinned === true ? { pinned: true } : {}),
63
+ ...(parsed.data.lastAttachedAt === undefined
64
+ ? {}
65
+ : { lastAttachedAt: parsed.data.lastAttachedAt }),
66
+ ...(parsed.data.exited === true ? { exited: true } : {}),
67
+ };
68
+ }
69
+
70
+ function buildOptionalNumber() {
71
+ return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().optional());
72
+ }