@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.
@@ -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
+ }
@@ -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