@zgeoff/atc 1.0.3 → 2.1.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 +77 -183
- package/package.json +5 -1
- package/src/cli.ts +14 -0
- package/src/client/index.ts +9 -9
- package/src/client/parse-daemon-event.ts +26 -26
- package/src/daemon/attach-registry.ts +4 -3
- package/src/daemon/build-session-event.ts +4 -4
- package/src/daemon/daemon-connection.ts +26 -1
- package/src/daemon/daemon.ts +103 -21
- package/src/daemon/make-hook-runner.ts +84 -0
- package/src/daemon/screen-model.ts +42 -11
- package/src/daemon/start-events-server.ts +97 -0
- package/src/events.ts +33 -0
- package/src/mcp-server.ts +11 -0
- package/src/protocol/protocol.ts +1 -1
- package/src/protocol/request-param-schemas.ts +1 -0
- package/src/shared/collect-hooks.ts +90 -0
- package/src/shared/config.ts +8 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isRecord } from './report';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One configured hook: a shell command the daemon runs when the named wire
|
|
7
|
+
* event broadcasts. `dir` narrows it to sessions whose repo root or working
|
|
8
|
+
* directory sits at or under that path; `timeout` caps the run in
|
|
9
|
+
* milliseconds before the process is killed.
|
|
10
|
+
*/
|
|
11
|
+
export interface HookEntry {
|
|
12
|
+
readonly command: string;
|
|
13
|
+
readonly dir?: string;
|
|
14
|
+
readonly timeout?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type HooksConfig = Readonly<Record<string, readonly HookEntry[]>>;
|
|
18
|
+
|
|
19
|
+
// One hook entry's keys. A wrong-typed optional field parses to undefined
|
|
20
|
+
// rather than failing the entry, so a hook with one bad field still runs
|
|
21
|
+
// with the default for it.
|
|
22
|
+
const HOOK_ENTRY_SCHEMA = z.object({
|
|
23
|
+
command: buildOptionalNonEmptyString(),
|
|
24
|
+
dir: buildOptionalNonEmptyString(),
|
|
25
|
+
timeout: buildOptionalPositiveNumber(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads the hooks map, keyed by wire-event name. An entry without a command
|
|
30
|
+
* is left out; an event name with no valid entries is dropped. Unknown event
|
|
31
|
+
* names are kept as written — they never fire, and a future daemon that
|
|
32
|
+
* emits them starts firing without a config change. A `dir` is stored with
|
|
33
|
+
* `~` expanded and trailing slashes trimmed, ready for path matching.
|
|
34
|
+
*/
|
|
35
|
+
export function collectHooks(raw: unknown): HooksConfig {
|
|
36
|
+
if (!isRecord(raw)) {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const hooks: Record<string, readonly HookEntry[]> = {};
|
|
41
|
+
|
|
42
|
+
for (const [event, value] of Object.entries(raw)) {
|
|
43
|
+
if (event === '' || !Array.isArray(value)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const entries: HookEntry[] = [];
|
|
48
|
+
|
|
49
|
+
for (const item of value) {
|
|
50
|
+
const parsed = HOOK_ENTRY_SCHEMA.safeParse(item);
|
|
51
|
+
|
|
52
|
+
if (!parsed.success || parsed.data.command === undefined) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
entries.push({
|
|
57
|
+
command: parsed.data.command,
|
|
58
|
+
...(parsed.data.dir === undefined ? {} : { dir: normalizeHookDir(parsed.data.dir) }),
|
|
59
|
+
...(parsed.data.timeout === undefined ? {} : { timeout: parsed.data.timeout }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (entries.length > 0) {
|
|
64
|
+
hooks[event] = entries;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return hooks;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeHookDir(dir: string): string {
|
|
72
|
+
const expanded = dir === '~' ? homedir() : dir.replace(/^~\//u, `${homedir()}/`);
|
|
73
|
+
const trimmed = expanded.replace(/\/+$/u, '');
|
|
74
|
+
|
|
75
|
+
return trimmed === '' ? '/' : trimmed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildOptionalNonEmptyString() {
|
|
79
|
+
return z.preprocess(
|
|
80
|
+
(v) => (typeof v === 'string' && v !== '' ? v : undefined),
|
|
81
|
+
z.string().optional(),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildOptionalPositiveNumber() {
|
|
86
|
+
return z.preprocess(
|
|
87
|
+
(v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined),
|
|
88
|
+
z.number().optional(),
|
|
89
|
+
);
|
|
90
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { buildOptionalString } from './build-optional-string';
|
|
|
6
6
|
import { buildOptionalStringArray } from './build-optional-string-array';
|
|
7
7
|
import { collectGateways } from './collect-gateways';
|
|
8
8
|
import type { GatewayConfig } from './collect-gateways';
|
|
9
|
+
import { collectHooks } from './collect-hooks';
|
|
10
|
+
import type { HooksConfig } from './collect-hooks';
|
|
9
11
|
|
|
10
12
|
export interface Config {
|
|
11
13
|
claudeBin: string;
|
|
@@ -15,6 +17,7 @@ export interface Config {
|
|
|
15
17
|
codexBin: string;
|
|
16
18
|
codexArgs: string[];
|
|
17
19
|
gateways: GatewayConfig[];
|
|
20
|
+
hooks: HooksConfig;
|
|
18
21
|
leader: LeaderKey;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -31,6 +34,7 @@ const DEFAULTS: Config = {
|
|
|
31
34
|
codexBin: 'codex',
|
|
32
35
|
codexArgs: [],
|
|
33
36
|
gateways: [],
|
|
37
|
+
hooks: {},
|
|
34
38
|
leader: { code: 0, label: '^Space' },
|
|
35
39
|
};
|
|
36
40
|
|
|
@@ -39,6 +43,7 @@ const configDir = join(homedir(), '.config', 'atc');
|
|
|
39
43
|
export const stateDir = join(homedir(), '.local', 'state', 'atc');
|
|
40
44
|
export const socketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc.sock');
|
|
41
45
|
export const daemonSocketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.sock');
|
|
46
|
+
export const eventsSocketPath = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-events.sock');
|
|
42
47
|
export const statusFile = join(stateDir, 'status.json');
|
|
43
48
|
export const dbFile = join(stateDir, 'atc.db');
|
|
44
49
|
export const legacyFleetFile = join(stateDir, 'fleet.json');
|
|
@@ -55,6 +60,7 @@ const CONFIG_SCHEMA = z.object({
|
|
|
55
60
|
codexBin: buildOptionalString(),
|
|
56
61
|
codexArgs: buildOptionalStringArray(),
|
|
57
62
|
gateways: z.unknown().optional(),
|
|
63
|
+
hooks: z.unknown().optional(),
|
|
58
64
|
leader: buildOptionalString(),
|
|
59
65
|
});
|
|
60
66
|
|
|
@@ -97,11 +103,12 @@ export function parseConfig(raw: unknown): Config {
|
|
|
97
103
|
const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
|
|
98
104
|
const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
|
|
99
105
|
const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
|
|
106
|
+
const hooks = collectHooks(parsed.data.hooks);
|
|
100
107
|
|
|
101
108
|
const leader =
|
|
102
109
|
(parsed.data.leader === undefined ? null : decodeLeader(parsed.data.leader)) ?? DEFAULTS.leader;
|
|
103
110
|
|
|
104
|
-
return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, gateways, leader };
|
|
111
|
+
return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, gateways, hooks, leader };
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
// Control bytes the terminal needs for its own input: enter, tab, and esc
|