@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,106 @@
|
|
|
1
|
+
import type { HookEvent } from '../daemon/hooks';
|
|
2
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
|
+
import type { GatewayConfig } from '../shared/collect-gateways';
|
|
4
|
+
import type { Config } from '../shared/config';
|
|
5
|
+
import { toShellArg } from '../shared/to-shell-arg';
|
|
6
|
+
import type {
|
|
7
|
+
AdapterEvent,
|
|
8
|
+
AgentAdapter,
|
|
9
|
+
AgentID,
|
|
10
|
+
HeadlessRunner,
|
|
11
|
+
NameUpdate,
|
|
12
|
+
ResumeCheck,
|
|
13
|
+
SpawnOptions,
|
|
14
|
+
SpawnPlan,
|
|
15
|
+
} from './agent-adapter';
|
|
16
|
+
import { ClaudeAdapter } from './claude-adapter';
|
|
17
|
+
import { writeHookSettings } from './write-hook-settings';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Claude CLI pointed at a Claude-compatible backend other than the
|
|
21
|
+
* default one. Hooks, transcripts, and resume semantics are the CLI's, so
|
|
22
|
+
* they come straight from the Claude adapter; only the spawn plan and the
|
|
23
|
+
* resume command differ, because both have to name the settings file that
|
|
24
|
+
* carries the backend.
|
|
25
|
+
*/
|
|
26
|
+
export class GatewayAdapter implements AgentAdapter {
|
|
27
|
+
readonly id: AgentID;
|
|
28
|
+
|
|
29
|
+
// A headless turn carries the same settings file the terminal spawn does,
|
|
30
|
+
// so it reaches this backend rather than the default one.
|
|
31
|
+
readonly headlessRunner: HeadlessRunner | null;
|
|
32
|
+
|
|
33
|
+
// The CLI's hooks are authoritative; no screen heuristics needed.
|
|
34
|
+
readonly screenDetector = null;
|
|
35
|
+
|
|
36
|
+
private readonly gateway: GatewayConfig;
|
|
37
|
+
|
|
38
|
+
private readonly claude: ClaudeAdapter;
|
|
39
|
+
|
|
40
|
+
// Written on first spawn so constructing the adapter touches no state.
|
|
41
|
+
private settingsFile: string | undefined;
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
gateway: GatewayConfig,
|
|
45
|
+
config: Config,
|
|
46
|
+
headlessRunner: HeadlessRunner | null = null,
|
|
47
|
+
) {
|
|
48
|
+
this.gateway = gateway;
|
|
49
|
+
this.id = gateway.id;
|
|
50
|
+
|
|
51
|
+
this.claude = new ClaudeAdapter(config);
|
|
52
|
+
|
|
53
|
+
this.headlessRunner =
|
|
54
|
+
headlessRunner === null
|
|
55
|
+
? null
|
|
56
|
+
: (opts, hooks) => headlessRunner({ ...opts, settings: this.writeSettings() }, hooks);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
60
|
+
return {
|
|
61
|
+
bin: this.gateway.bin,
|
|
62
|
+
args: [
|
|
63
|
+
...this.gateway.args,
|
|
64
|
+
'--settings',
|
|
65
|
+
this.writeSettings(),
|
|
66
|
+
...(opts.resume === true ? ['--resume'] : []),
|
|
67
|
+
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
68
|
+
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
normalizeHook(e: HookEvent): AdapterEvent {
|
|
74
|
+
return this.claude.normalizeHook(e);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
|
|
78
|
+
return this.claude.loadName(source, namedBy);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
canResume(session: ResumeCheck): boolean {
|
|
82
|
+
return this.claude.canResume(session);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Shell command that re-opens this session outside atc. It names the
|
|
86
|
+
// generated settings file, because without it the CLI would resume the
|
|
87
|
+
// session against the default backend.
|
|
88
|
+
buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
|
|
89
|
+
const settings = toShellArg(this.writeSettings());
|
|
90
|
+
const resume = agentSessionID === undefined ? '' : ` ${agentSessionID}`;
|
|
91
|
+
|
|
92
|
+
return `cd ${toShellArg(cwd)} && ${this.gateway.bin} --settings ${settings} --resume${resume}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private writeSettings(): string {
|
|
96
|
+
this.settingsFile ??= writeHookSettings({
|
|
97
|
+
id: this.id,
|
|
98
|
+
env: { ANTHROPIC_BASE_URL: this.gateway.baseURL, ...this.gateway.env },
|
|
99
|
+
...(this.gateway.apiKeyHelper === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: { apiKeyHelper: this.gateway.apiKeyHelper }),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return this.settingsFile;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { homedir } from 'node:os';
|
|
3
2
|
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type { HookEvent } from '../daemon/hooks';
|
|
5
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
|
+
import { buildOptionalString } from '../shared/build-optional-string';
|
|
7
|
+
import type { Config } from '../shared/config';
|
|
8
|
+
import { isRecord } from '../shared/report';
|
|
9
|
+
import type { SessionID } from '../shared/session-id';
|
|
10
|
+
import { toAgentSessionID } from '../shared/to-agent-session-id';
|
|
11
|
+
import { toShellArg } from '../shared/to-shell-arg';
|
|
4
12
|
import type {
|
|
5
13
|
AdapterEvent,
|
|
6
14
|
AgentAdapter,
|
|
@@ -9,22 +17,42 @@ import type {
|
|
|
9
17
|
SpawnOptions,
|
|
10
18
|
SpawnPlan,
|
|
11
19
|
} from './agent-adapter';
|
|
12
|
-
import type { Config } from './config';
|
|
13
|
-
import type { HookEvent } from './hooks';
|
|
14
20
|
import { normalizeHookEventName } from './normalize-hook-event';
|
|
15
|
-
import {
|
|
21
|
+
import { resolveAgentHome } from './resolve-agent-home';
|
|
22
|
+
import { truncateDetail } from './truncate-detail';
|
|
16
23
|
|
|
17
24
|
interface GrokSessionHookState {
|
|
18
25
|
latestPromptID?: string;
|
|
19
26
|
lastKind: AdapterEvent['kind'];
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
// Bounds per-session hook state so a Grok session killed or crashed without
|
|
30
|
+
// a SessionEnd hook cannot grow it for the daemon's whole lifetime.
|
|
31
|
+
const MAX_HOOK_STATE_ENTRIES = 256;
|
|
32
|
+
|
|
33
|
+
// Grok's hook payload keys, camelCase. An absent or wrong-typed field parses
|
|
34
|
+
// to undefined rather than failing the payload, so a broken reporter never
|
|
35
|
+
// breaks the session it reports on.
|
|
36
|
+
const GROK_HOOK_PAYLOAD_SCHEMA = z.object({
|
|
37
|
+
sessionId: buildOptionalString(),
|
|
38
|
+
cwd: buildOptionalString(),
|
|
39
|
+
promptId: buildOptionalString(),
|
|
40
|
+
subagentType: buildOptionalString(),
|
|
41
|
+
prompt: buildOptionalString(),
|
|
42
|
+
notificationType: buildOptionalString(),
|
|
43
|
+
message: buildOptionalString(),
|
|
44
|
+
reason: buildOptionalString(),
|
|
45
|
+
lastAssistantMessage: buildOptionalString(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
type GrokHookPayload = z.infer<typeof GROK_HOOK_PAYLOAD_SCHEMA>;
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* The Grok Build adapter: spawn arguments, hook payload mapping,
|
|
24
52
|
* resume semantics, and summary.json name-pulling.
|
|
25
53
|
*/
|
|
26
54
|
export class GrokAdapter implements AgentAdapter {
|
|
27
|
-
readonly
|
|
55
|
+
readonly id = 'grok';
|
|
28
56
|
|
|
29
57
|
// Grok has no headless handoff.
|
|
30
58
|
readonly headlessRunner = null;
|
|
@@ -34,7 +62,7 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
34
62
|
|
|
35
63
|
private readonly config: Config;
|
|
36
64
|
|
|
37
|
-
private readonly hookState = new Map<
|
|
65
|
+
private readonly hookState = new Map<SessionID, GrokSessionHookState>();
|
|
38
66
|
|
|
39
67
|
constructor(config: Config) {
|
|
40
68
|
this.config = config;
|
|
@@ -53,32 +81,34 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
normalizeHook(e: HookEvent): AdapterEvent {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const
|
|
84
|
+
const parsed = GROK_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
|
|
85
|
+
const payload: GrokHookPayload = parsed.success ? parsed.data : {};
|
|
86
|
+
|
|
87
|
+
const agentSessionID =
|
|
88
|
+
payload.sessionId === undefined ? undefined : toAgentSessionID(payload.sessionId);
|
|
60
89
|
|
|
61
90
|
const base: AdapterEvent = {
|
|
62
91
|
kind: 'heartbeat',
|
|
63
|
-
...(
|
|
92
|
+
...(agentSessionID === undefined ? {} : { agentSessionID }),
|
|
64
93
|
};
|
|
65
94
|
|
|
66
95
|
const named: AdapterEvent = {
|
|
67
96
|
...base,
|
|
68
|
-
...(
|
|
69
|
-
? { nameSource: buildGrokNameSource(
|
|
97
|
+
...(agentSessionID !== undefined && payload.cwd !== undefined
|
|
98
|
+
? { nameSource: buildGrokNameSource(agentSessionID, payload.cwd) }
|
|
70
99
|
: {}),
|
|
71
100
|
};
|
|
72
101
|
|
|
73
|
-
if (
|
|
102
|
+
if (payload.subagentType !== undefined && payload.subagentType !== '') {
|
|
74
103
|
return this.emitHook(e.atcId, { ...base, kind: 'heartbeat' });
|
|
75
104
|
}
|
|
76
105
|
|
|
77
106
|
const event = normalizeHookEventName(e.event);
|
|
78
107
|
const prior = this.hookState.get(e.atcId);
|
|
108
|
+
const promptID = payload.promptId;
|
|
79
109
|
|
|
80
110
|
const stale =
|
|
81
|
-
|
|
111
|
+
promptID !== undefined &&
|
|
82
112
|
prior?.latestPromptID !== undefined &&
|
|
83
113
|
promptID !== prior.latestPromptID;
|
|
84
114
|
|
|
@@ -87,9 +117,7 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
87
117
|
return this.emitHook(e.atcId, { ...named, kind: 'started' });
|
|
88
118
|
}
|
|
89
119
|
case 'UserPromptSubmit': {
|
|
90
|
-
const
|
|
91
|
-
const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
|
|
92
|
-
const submittedID = typeof promptID === 'string' ? promptID : undefined;
|
|
120
|
+
const preview = payload.prompt === undefined ? '' : payload.prompt.slice(0, 80);
|
|
93
121
|
|
|
94
122
|
return this.emitHook(
|
|
95
123
|
e.atcId,
|
|
@@ -98,18 +126,18 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
98
126
|
kind: 'prompt-submitted',
|
|
99
127
|
...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
|
|
100
128
|
},
|
|
101
|
-
|
|
129
|
+
promptID,
|
|
102
130
|
);
|
|
103
131
|
}
|
|
104
132
|
case 'Notification': {
|
|
105
|
-
const notificationType =
|
|
106
|
-
const message =
|
|
133
|
+
const notificationType = payload.notificationType;
|
|
134
|
+
const message = payload.message;
|
|
107
135
|
|
|
108
136
|
if (notificationType === 'permission_prompt') {
|
|
109
137
|
return this.emitHook(e.atcId, {
|
|
110
138
|
...base,
|
|
111
139
|
kind: 'needs-input',
|
|
112
|
-
...(
|
|
140
|
+
...(message !== undefined && message !== ''
|
|
113
141
|
? { message, detail: truncateDetail(message) }
|
|
114
142
|
: {}),
|
|
115
143
|
});
|
|
@@ -124,11 +152,11 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
124
152
|
return this.emitHook(e.atcId, base);
|
|
125
153
|
}
|
|
126
154
|
case 'Stop': {
|
|
127
|
-
if (
|
|
155
|
+
if (payload.reason !== 'end_turn' || stale) {
|
|
128
156
|
return this.emitHook(e.atcId, base);
|
|
129
157
|
}
|
|
130
158
|
|
|
131
|
-
return this.emitHook(e.atcId, buildTurnDoneEvent(named,
|
|
159
|
+
return this.emitHook(e.atcId, buildTurnDoneEvent(named, payload));
|
|
132
160
|
}
|
|
133
161
|
case 'StopFailure':
|
|
134
162
|
case 'StopCancelled': {
|
|
@@ -136,7 +164,7 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
136
164
|
return this.emitHook(e.atcId, base);
|
|
137
165
|
}
|
|
138
166
|
|
|
139
|
-
return this.emitHook(e.atcId, buildTurnDoneEvent(named,
|
|
167
|
+
return this.emitHook(e.atcId, buildTurnDoneEvent(named, payload));
|
|
140
168
|
}
|
|
141
169
|
case 'SessionEnd': {
|
|
142
170
|
this.hookState.delete(e.atcId);
|
|
@@ -181,14 +209,17 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
181
209
|
}
|
|
182
210
|
|
|
183
211
|
// Shell command that re-opens this session outside atc (or anywhere).
|
|
184
|
-
buildResumeCommand(cwd: string, agentSessionID:
|
|
212
|
+
buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
|
|
185
213
|
const resume = agentSessionID === undefined ? 'grok' : `grok --resume ${agentSessionID}`;
|
|
186
|
-
const quoted = cwd.replaceAll("'", String.raw`'\''`);
|
|
187
214
|
|
|
188
|
-
return `cd
|
|
215
|
+
return `cd ${toShellArg(cwd)} && ${resume}`;
|
|
189
216
|
}
|
|
190
217
|
|
|
191
|
-
private emitHook(
|
|
218
|
+
private emitHook(
|
|
219
|
+
atcID: SessionID,
|
|
220
|
+
event: Readonly<AdapterEvent>,
|
|
221
|
+
promptID?: string,
|
|
222
|
+
): AdapterEvent {
|
|
192
223
|
const prior = this.hookState.get(atcID);
|
|
193
224
|
const latestPromptID = promptID ?? prior?.latestPromptID;
|
|
194
225
|
|
|
@@ -199,6 +230,10 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
199
230
|
? event.kind
|
|
200
231
|
: (prior?.lastKind ?? event.kind);
|
|
201
232
|
|
|
233
|
+
if (prior === undefined && this.hookState.size >= MAX_HOOK_STATE_ENTRIES) {
|
|
234
|
+
this.removeOldestHookState();
|
|
235
|
+
}
|
|
236
|
+
|
|
202
237
|
this.hookState.set(atcID, {
|
|
203
238
|
lastKind: tracked,
|
|
204
239
|
...(latestPromptID === undefined ? {} : { latestPromptID }),
|
|
@@ -206,28 +241,34 @@ export class GrokAdapter implements AgentAdapter {
|
|
|
206
241
|
|
|
207
242
|
return event;
|
|
208
243
|
}
|
|
209
|
-
}
|
|
210
244
|
|
|
211
|
-
|
|
212
|
-
|
|
245
|
+
// A Map preserves insertion order, so the first key is the oldest entry.
|
|
246
|
+
private removeOldestHookState(): void {
|
|
247
|
+
const oldest = this.hookState.keys().next().value;
|
|
213
248
|
|
|
214
|
-
|
|
249
|
+
if (oldest !== undefined) {
|
|
250
|
+
this.hookState.delete(oldest);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
215
253
|
}
|
|
216
254
|
|
|
217
|
-
function buildGrokNameSource(sessionID:
|
|
218
|
-
return join(
|
|
255
|
+
function buildGrokNameSource(sessionID: AgentSessionID, cwd: string): string {
|
|
256
|
+
return join(
|
|
257
|
+
resolveAgentHome('GROK_HOME', '.grok'),
|
|
258
|
+
'sessions',
|
|
259
|
+
encodeURIComponent(cwd),
|
|
260
|
+
sessionID,
|
|
261
|
+
'summary.json',
|
|
262
|
+
);
|
|
219
263
|
}
|
|
220
264
|
|
|
221
|
-
function buildTurnDoneEvent(
|
|
222
|
-
|
|
223
|
-
payload: Readonly<Record<string, unknown>>,
|
|
224
|
-
): AdapterEvent {
|
|
225
|
-
const lastMessage = payload['lastAssistantMessage'];
|
|
265
|
+
function buildTurnDoneEvent(named: Readonly<AdapterEvent>, payload: GrokHookPayload): AdapterEvent {
|
|
266
|
+
const lastMessage = payload.lastAssistantMessage;
|
|
226
267
|
|
|
227
268
|
return {
|
|
228
269
|
...named,
|
|
229
270
|
kind: 'turn-done',
|
|
230
|
-
...(
|
|
271
|
+
...(lastMessage !== undefined && lastMessage !== ''
|
|
231
272
|
? { detail: truncateDetail(lastMessage) }
|
|
232
273
|
: {}),
|
|
233
274
|
};
|
|
@@ -254,7 +295,7 @@ function findGrokSummary(source: string): string | null {
|
|
|
254
295
|
return null;
|
|
255
296
|
}
|
|
256
297
|
|
|
257
|
-
const root = join(
|
|
298
|
+
const root = join(resolveAgentHome('GROK_HOME', '.grok'), 'sessions');
|
|
258
299
|
|
|
259
300
|
try {
|
|
260
301
|
for (const group of readdirSync(root)) {
|
|
@@ -268,7 +309,3 @@ function findGrokSummary(source: string): string | null {
|
|
|
268
309
|
|
|
269
310
|
return null;
|
|
270
311
|
}
|
|
271
|
-
|
|
272
|
-
function truncateDetail(text: string): string {
|
|
273
|
-
return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
|
|
274
|
-
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An agent CLI's home directory: the named environment variable when it is
|
|
6
|
+
* set and non-empty, otherwise the given default directory under the user's
|
|
7
|
+
* home.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveAgentHome(envVar: string, defaultDirName: string): string {
|
|
10
|
+
const home = process.env[envVar];
|
|
11
|
+
|
|
12
|
+
return home !== undefined && home !== '' ? home : join(homedir(), defaultDirName);
|
|
13
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { stateDir } from '../shared/config';
|
|
5
|
+
import { isRecord } from '../shared/report';
|
|
6
|
+
import { buildHookSettings } from './build-hook-settings';
|
|
7
|
+
import type { HookSettingsProfile } from './build-hook-settings';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Writes the settings file passed to a wrangled session as
|
|
11
|
+
* `claude --settings`, one per agent id, and returns its path.
|
|
12
|
+
*/
|
|
13
|
+
export function writeHookSettings(profile: HookSettingsProfile): string {
|
|
14
|
+
const file = join(stateDir, `hook-settings-${profile.id}.json`);
|
|
15
|
+
const settings = buildHookSettings(profile, readStatuslinePadding());
|
|
16
|
+
|
|
17
|
+
writeFileSync(file, JSON.stringify(settings, null, 2));
|
|
18
|
+
|
|
19
|
+
return file;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The padding on the user's own statusline, so the chained one lines up with
|
|
24
|
+
* it. An unreadable or unconfigured setting is no padding.
|
|
25
|
+
*/
|
|
26
|
+
function readStatuslinePadding(): number {
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8');
|
|
29
|
+
const user: unknown = JSON.parse(raw);
|
|
30
|
+
const statusLine = isRecord(user) ? user['statusLine'] : undefined;
|
|
31
|
+
const padding = isRecord(statusLine) ? statusLine['padding'] : undefined;
|
|
32
|
+
|
|
33
|
+
return typeof padding === 'number' ? padding : 0;
|
|
34
|
+
} catch {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `statusline` are the commands injected into wrangled sessions.
|
|
3
3
|
import { defineCommand, runMain } from 'citty';
|
|
4
4
|
import pkg from '../package.json';
|
|
5
|
-
import { getBuild } from './get-build';
|
|
5
|
+
import { getBuild } from './shared/get-build';
|
|
6
6
|
|
|
7
7
|
const main = defineCommand({
|
|
8
8
|
meta: {
|
|
@@ -20,7 +20,7 @@ const main = defineCommand({
|
|
|
20
20
|
hidden: true,
|
|
21
21
|
},
|
|
22
22
|
async run() {
|
|
23
|
-
await import('./index');
|
|
23
|
+
await import('./client/index');
|
|
24
24
|
},
|
|
25
25
|
}),
|
|
26
26
|
mcp: () =>
|
|
@@ -42,12 +42,13 @@ const main = defineCommand({
|
|
|
42
42
|
description: 'Run the atc daemon in the foreground',
|
|
43
43
|
},
|
|
44
44
|
async run() {
|
|
45
|
-
const daemon = await import('./daemon');
|
|
46
|
-
const config = await import('./config');
|
|
47
|
-
const claude = await import('./claude-adapter');
|
|
48
|
-
const grok = await import('./grok-adapter');
|
|
49
|
-
const codex = await import('./codex-adapter');
|
|
50
|
-
const
|
|
45
|
+
const daemon = await import('./daemon/daemon');
|
|
46
|
+
const config = await import('./shared/config');
|
|
47
|
+
const claude = await import('./agents/claude-adapter');
|
|
48
|
+
const grok = await import('./agents/grok-adapter');
|
|
49
|
+
const codex = await import('./agents/codex-adapter');
|
|
50
|
+
const gateway = await import('./agents/gateway-adapter');
|
|
51
|
+
const headless = await import('./daemon/start-headless-run');
|
|
51
52
|
|
|
52
53
|
// Test harnesses shrink the outbound queue to force overflow
|
|
53
54
|
// deterministically; unset means the production default.
|
|
@@ -69,12 +70,19 @@ const main = defineCommand({
|
|
|
69
70
|
const grokAdapter = new grok.GrokAdapter(cfg);
|
|
70
71
|
const codexAdapter = new codex.CodexAdapter(cfg);
|
|
71
72
|
|
|
72
|
-
const
|
|
73
|
+
const gatewayAdapters = cfg.gateways.map(
|
|
74
|
+
(entry) =>
|
|
75
|
+
new gateway.GatewayAdapter(entry, cfg, (runOpts, hooks) =>
|
|
76
|
+
headless.startHeadlessRun(runOpts, hooks),
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const handle = await daemon.startDaemon({
|
|
73
81
|
socketPath: config.daemonSocketPath,
|
|
74
82
|
reporterSocketPath: config.socketPath,
|
|
75
83
|
build: getBuild(),
|
|
76
84
|
adapter: claudeAdapter,
|
|
77
|
-
adapters:
|
|
85
|
+
adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
|
|
78
86
|
dbPath: config.dbFile,
|
|
79
87
|
legacyFleetPath: config.legacyFleetFile,
|
|
80
88
|
pidPath: config.daemonPidFile,
|
|
@@ -84,8 +92,11 @@ const main = defineCommand({
|
|
|
84
92
|
});
|
|
85
93
|
|
|
86
94
|
process.on('SIGTERM', () => {
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
void (async () => {
|
|
96
|
+
await handle.stop();
|
|
97
|
+
|
|
98
|
+
process.exit(0);
|
|
99
|
+
})();
|
|
89
100
|
});
|
|
90
101
|
},
|
|
91
102
|
}),
|
|
@@ -97,7 +108,7 @@ const main = defineCommand({
|
|
|
97
108
|
'Print the Codex hook entries to merge into $CODEX_HOME/hooks.json (trust them once in the Codex TUI)',
|
|
98
109
|
},
|
|
99
110
|
async run() {
|
|
100
|
-
const hook = await import('./print-codex-hook-file');
|
|
111
|
+
const hook = await import('./agents/print-codex-hook-file');
|
|
101
112
|
|
|
102
113
|
hook.printCodexHookFile();
|
|
103
114
|
},
|
|
@@ -109,7 +120,7 @@ const main = defineCommand({
|
|
|
109
120
|
description: 'Print the Grok hook file to install at $GROK_HOME/hooks/atc-reporter.json',
|
|
110
121
|
},
|
|
111
122
|
async run() {
|
|
112
|
-
const hook = await import('./print-grok-hook-file');
|
|
123
|
+
const hook = await import('./agents/print-grok-hook-file');
|
|
113
124
|
|
|
114
125
|
hook.printGrokHookFile();
|
|
115
126
|
},
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { spawn as spawnChild } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import { toAgentID } from '../agents/agent-adapter';
|
|
5
|
+
import type { AgentID } from '../agents/agent-adapter';
|
|
6
|
+
import { daemonPidFile, daemonSocketPath } from '../shared/config';
|
|
7
|
+
import { getBuild } from '../shared/get-build';
|
|
8
|
+
import { isRecord } from '../shared/report';
|
|
6
9
|
import { DaemonClient } from './daemon-client';
|
|
7
|
-
import { getBuild } from './get-build';
|
|
8
|
-
import { isRecord } from './report';
|
|
9
10
|
|
|
10
11
|
export interface DaemonBoot {
|
|
11
12
|
readonly client: DaemonClient;
|
|
12
13
|
readonly stale: boolean;
|
|
13
|
-
readonly lastUsedAgent:
|
|
14
|
+
readonly lastUsedAgent: AgentID;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -35,7 +36,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
35
36
|
return {
|
|
36
37
|
client,
|
|
37
38
|
stale: hello['daemon'] !== build,
|
|
38
|
-
lastUsedAgent: hello['lastUsedAgent']
|
|
39
|
+
lastUsedAgent: toAgentID(hello['lastUsedAgent']),
|
|
39
40
|
};
|
|
40
41
|
} catch (error) {
|
|
41
42
|
client.stop();
|
|
@@ -120,7 +121,7 @@ async function stopStaleDaemon(): Promise<void> {
|
|
|
120
121
|
function spawnDaemonDetached() {
|
|
121
122
|
const exec = process.execPath;
|
|
122
123
|
const isBun = basename(exec) === 'bun' || basename(exec) === 'bun.exe';
|
|
123
|
-
const args = isBun ? [join(import.meta.dir, 'cli.ts'), 'daemon'] : ['daemon'];
|
|
124
|
+
const args = isBun ? [join(import.meta.dir, '..', 'cli.ts'), 'daemon'] : ['daemon'];
|
|
124
125
|
|
|
125
126
|
spawnChild(exec, args, { detached: true, stdio: 'ignore' }).unref();
|
|
126
127
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { setup } from 'xstate';
|
|
2
|
+
|
|
3
|
+
type ClientMachineEvent =
|
|
4
|
+
| { readonly type: 'ATTACH'; readonly sessionID: string }
|
|
5
|
+
| { readonly type: 'HOME' }
|
|
6
|
+
| { readonly type: 'OVERLAY' }
|
|
7
|
+
| { readonly type: 'HELP' }
|
|
8
|
+
| { readonly type: 'SPAWN'; readonly resume: boolean }
|
|
9
|
+
| { readonly type: 'EJECT'; readonly sessionID: string };
|
|
10
|
+
|
|
11
|
+
// What the machine borrows from the client that owns the screen, the fleet
|
|
12
|
+
// mirror, and the daemon connection. Every prop is a side effect the machine
|
|
13
|
+
// triggers but never performs itself.
|
|
14
|
+
export interface ClientMachineDeps {
|
|
15
|
+
readonly openHome: () => void;
|
|
16
|
+
readonly openAttached: (sessionID: string) => void;
|
|
17
|
+
readonly openOverlay: () => void;
|
|
18
|
+
readonly openHelp: () => void;
|
|
19
|
+
readonly openPicker: (resume: boolean) => void;
|
|
20
|
+
readonly openEject: (sessionID: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The client's mode as a statechart: which mode each event moves to, and
|
|
25
|
+
* what runs on entry. `ATTACH` and `OVERLAY` reach several of the modes
|
|
26
|
+
* below because several keys and outcomes lead to the same place —
|
|
27
|
+
* attaching a session, or falling back to the overlay — and each landing
|
|
28
|
+
* runs the same entry work regardless of where it was sent from.
|
|
29
|
+
*/
|
|
30
|
+
export function buildClientMachine(deps: ClientMachineDeps) {
|
|
31
|
+
return setup({
|
|
32
|
+
types: {
|
|
33
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- xstate's typed-setup contract: this value is never read, it only carries the event union past the type layer
|
|
34
|
+
events: {} as ClientMachineEvent,
|
|
35
|
+
},
|
|
36
|
+
actions: {
|
|
37
|
+
openHome: () => {
|
|
38
|
+
deps.openHome();
|
|
39
|
+
},
|
|
40
|
+
openAttached: (args) => {
|
|
41
|
+
if (args.event.type === 'ATTACH') {
|
|
42
|
+
deps.openAttached(args.event.sessionID);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
openOverlay: () => {
|
|
46
|
+
deps.openOverlay();
|
|
47
|
+
},
|
|
48
|
+
openHelp: () => {
|
|
49
|
+
deps.openHelp();
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}).createMachine({
|
|
53
|
+
id: 'client',
|
|
54
|
+
initial: 'home',
|
|
55
|
+
states: {
|
|
56
|
+
home: {
|
|
57
|
+
entry: 'openHome',
|
|
58
|
+
on: {
|
|
59
|
+
ATTACH: 'attached',
|
|
60
|
+
HOME: { target: 'home', reenter: true },
|
|
61
|
+
OVERLAY: 'overlay',
|
|
62
|
+
SPAWN: {
|
|
63
|
+
target: 'picker',
|
|
64
|
+
actions: (args) => {
|
|
65
|
+
deps.openPicker(args.event.resume);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
attached: {
|
|
71
|
+
entry: 'openAttached',
|
|
72
|
+
on: {
|
|
73
|
+
OVERLAY: 'overlay',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
overlay: {
|
|
77
|
+
entry: 'openOverlay',
|
|
78
|
+
on: {
|
|
79
|
+
ATTACH: 'attached',
|
|
80
|
+
HOME: 'home',
|
|
81
|
+
OVERLAY: { target: 'overlay', reenter: true },
|
|
82
|
+
HELP: 'help',
|
|
83
|
+
SPAWN: {
|
|
84
|
+
target: 'picker',
|
|
85
|
+
actions: (args) => {
|
|
86
|
+
deps.openPicker(args.event.resume);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
EJECT: {
|
|
90
|
+
target: 'picker-eject',
|
|
91
|
+
actions: (args) => {
|
|
92
|
+
deps.openEject(args.event.sessionID);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
help: {
|
|
98
|
+
entry: 'openHelp',
|
|
99
|
+
on: {
|
|
100
|
+
OVERLAY: 'overlay',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
picker: {
|
|
104
|
+
on: {
|
|
105
|
+
ATTACH: 'attached',
|
|
106
|
+
HOME: 'home',
|
|
107
|
+
OVERLAY: 'overlay',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
'picker-eject': {
|
|
111
|
+
on: {
|
|
112
|
+
ATTACH: 'attached',
|
|
113
|
+
HOME: 'home',
|
|
114
|
+
OVERLAY: 'overlay',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|