@zgeoff/atc 2.3.0 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -63,6 +63,8 @@
63
63
  },
64
64
  "overrides": {
65
65
  "fast-uri": "3.1.6",
66
+ "hono": ">=4.13.5",
67
+ "js-yaml": ">=4.3.2 <5",
66
68
  "qs": "6.16.0"
67
69
  },
68
70
  "packageManager": "bun@1.3.10"
@@ -1,15 +1,19 @@
1
+ import { z } from 'zod';
1
2
  import type { AgentID } from './agent-adapter';
2
3
  import { buildCLICommand } from './build-cli-command';
3
4
 
4
5
  /**
5
6
  * What one agent id needs on top of the shared instrumentation. The
6
7
  * environment block and the credential helper point the CLI at a backend
7
- * other than the default one; both are absent for the stock agent.
8
+ * other than the default one; both are absent for the stock agent. The
9
+ * settings are whatever else the config asked this agent's sessions to start
10
+ * with.
8
11
  */
9
12
  export interface HookSettingsProfile {
10
13
  readonly id: AgentID;
11
14
  readonly env?: Readonly<Record<string, string>>;
12
15
  readonly apiKeyHelper?: string;
16
+ readonly settings?: Readonly<Record<string, unknown>>;
13
17
  }
14
18
 
15
19
  /**
@@ -31,17 +35,20 @@ export function buildHookSettings(
31
35
  { hooks: [{ type: 'command', command: buildCLICommand('hook-report'), timeout: 5 }] },
32
36
  ];
33
37
 
34
- return {
35
- hooks: {
36
- // SessionStart carries the session id at spawn/resume time, before any
37
- // interaction — without it a session only enters the fleet file after
38
- // its first prompt/notification.
39
- SessionStart: entry,
40
- Notification: entry,
41
- Stop: entry,
42
- UserPromptSubmit: entry,
43
- SessionEnd: entry,
44
- },
38
+ const own = {
39
+ hooks: mergeSessionHooks(
40
+ {
41
+ // SessionStart carries the session id at spawn/resume time, before any
42
+ // interaction — without it a session only enters the fleet file after
43
+ // its first prompt/notification.
44
+ SessionStart: entry,
45
+ Notification: entry,
46
+ Stop: entry,
47
+ UserPromptSubmit: entry,
48
+ SessionEnd: entry,
49
+ },
50
+ profile.settings?.['hooks'],
51
+ ),
45
52
 
46
53
  // Fleet status renders inside Claude Code's own status line; the injected
47
54
  // command chains the user's configured statusline first, so mirror their
@@ -56,4 +63,36 @@ export function buildHookSettings(
56
63
  : { env: profile.env }),
57
64
  ...(profile.apiKeyHelper === undefined ? {} : { apiKeyHelper: profile.apiKeyHelper }),
58
65
  };
66
+
67
+ // The configured settings fill in around what atc writes and never replace
68
+ // it: a session atc did not instrument is a session the fleet cannot track.
69
+ return { ...profile.settings, ...own };
70
+ }
71
+
72
+ type SessionHooks = Readonly<Record<string, readonly unknown[]>>;
73
+
74
+ // One hook list per event name, which is the shape every harness that reads
75
+ // this file takes.
76
+ const CONFIGURED_HOOKS_SCHEMA = z.record(z.string(), z.array(z.unknown()));
77
+
78
+ /**
79
+ * atc's own reporter plus whatever the config added, per event. The reporter
80
+ * runs first and the configured entries follow it, so an added hook joins the
81
+ * fleet's rather than displacing it. A block that is not hook lists is left out
82
+ * whole, and costs its own entries rather than atc's.
83
+ */
84
+ function mergeSessionHooks(own: SessionHooks, configured: unknown): SessionHooks {
85
+ const parsed = CONFIGURED_HOOKS_SCHEMA.safeParse(configured);
86
+
87
+ if (!parsed.success) {
88
+ return own;
89
+ }
90
+
91
+ const merged: Record<string, readonly unknown[]> = { ...own };
92
+
93
+ for (const [event, entries] of Object.entries(parsed.data)) {
94
+ merged[event] = [...(merged[event] ?? []), ...entries];
95
+ }
96
+
97
+ return merged;
59
98
  }
@@ -99,6 +99,7 @@ export class GatewayAdapter implements AgentAdapter {
99
99
  ...(this.gateway.apiKeyHelper === undefined
100
100
  ? {}
101
101
  : { apiKeyHelper: this.gateway.apiKeyHelper }),
102
+ ...(this.gateway.settings === undefined ? {} : { settings: this.gateway.settings }),
102
103
  });
103
104
 
104
105
  return this.settingsFile;
@@ -18,6 +18,7 @@ export interface GatewayConfig {
18
18
  readonly baseURL: string;
19
19
  readonly apiKeyHelper?: string;
20
20
  readonly env: Readonly<Record<string, string>>;
21
+ readonly settings?: Readonly<Record<string, unknown>>;
21
22
  }
22
23
 
23
24
  // Ids the built-in adapters answer to; a gateway may not take one.
@@ -34,6 +35,7 @@ const GATEWAY_ENTRY_SCHEMA = z.object({
34
35
  args: buildOptionalStringArray(),
35
36
  apiKeyHelper: buildOptionalNonEmptyString(),
36
37
  env: buildStringEnvRecord(),
38
+ settings: buildOptionalSettings(),
37
39
  });
38
40
 
39
41
  /**
@@ -76,12 +78,22 @@ export function collectGateways(
76
78
  baseURL: parsed.data.baseURL,
77
79
  ...(parsed.data.apiKeyHelper === undefined ? {} : { apiKeyHelper: parsed.data.apiKeyHelper }),
78
80
  env: parsed.data.env,
81
+ ...(parsed.data.settings === undefined ? {} : { settings: parsed.data.settings }),
79
82
  });
80
83
  }
81
84
 
82
85
  return gateways;
83
86
  }
84
87
 
88
+ // Claude Code settings the sessions of this gateway are started with, on top of
89
+ // the ones atc writes itself. Anything but an object is no extra settings.
90
+ function buildOptionalSettings() {
91
+ return z.preprocess(
92
+ (v) => (isRecord(v) ? v : undefined),
93
+ z.record(z.string(), z.unknown()).optional(),
94
+ );
95
+ }
96
+
85
97
  function buildOptionalNonEmptyString() {
86
98
  return z.preprocess(
87
99
  (v) => (typeof v === 'string' && v !== '' ? v : undefined),