@zgeoff/atc 0.1.9 → 0.1.10
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 +101 -57
- package/package.json +1 -1
- package/src/agent-adapter.ts +44 -0
- package/src/boot-daemon.ts +7 -1
- package/src/build-cli-command.ts +16 -0
- package/src/claude-adapter.ts +26 -18
- package/src/cli.ts +21 -2
- package/src/collect-clean-env.ts +30 -5
- package/src/config.ts +11 -1
- package/src/daemon-connection.ts +23 -1
- package/src/daemon.ts +58 -57
- package/src/format-overlay-agent-mark.ts +9 -0
- package/src/grok-adapter.ts +274 -0
- package/src/hook-report.ts +8 -4
- package/src/index.ts +79 -15
- package/src/mcp-server.ts +13 -1
- package/src/normalize-hook-event.ts +11 -0
- package/src/pick-tab-target.ts +1 -1
- package/src/print-grok-hook-file.ts +27 -0
- package/src/protocol.ts +1 -1
- package/src/sessions.ts +133 -41
- package/src/state-store.ts +62 -18
- package/src/ui.ts +22 -10
package/src/daemon.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { AgentAdapter } from './agent-adapter';
|
|
1
|
+
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type { AgentAdapter, AgentKind } from './agent-adapter';
|
|
3
3
|
import { AttachRegistry } from './attach-registry';
|
|
4
4
|
import type { Dims } from './attach-registry';
|
|
5
5
|
import { DaemonConnection } from './daemon-connection';
|
|
@@ -21,6 +21,10 @@ export interface DaemonOptions {
|
|
|
21
21
|
readonly build: string;
|
|
22
22
|
readonly adapter: AgentAdapter;
|
|
23
23
|
|
|
24
|
+
// Optional per-kind adapters. Lookup never falls back across kinds: a
|
|
25
|
+
// grok session with no grok adapter is unsupported, not a Claude spawn.
|
|
26
|
+
readonly adapters?: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
|
|
27
|
+
|
|
24
28
|
// SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
|
|
25
29
|
// fleet table once so upgrading keeps the restorable fleet.
|
|
26
30
|
readonly dbPath: string;
|
|
@@ -35,11 +39,6 @@ export interface DaemonOptions {
|
|
|
35
39
|
// Outbound queue capacity per client; small values force desync in tests.
|
|
36
40
|
readonly queueBytes?: number;
|
|
37
41
|
|
|
38
|
-
// Runs one headless turn over a session; the real one drives the Agent
|
|
39
|
-
// SDK, which spawns real claude — a boundary tests never cross. Absent
|
|
40
|
-
// means eject is unsupported.
|
|
41
|
-
readonly headlessRunner?: HeadlessRunner;
|
|
42
|
-
|
|
43
42
|
// How long an eject waits for the dying terminal to report SessionEnd
|
|
44
43
|
// before starting the headless run anyway.
|
|
45
44
|
readonly ejectSettleMs?: number;
|
|
@@ -56,24 +55,6 @@ export interface DaemonOptions {
|
|
|
56
55
|
readonly onQuit?: () => void;
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
interface HeadlessRunRequest {
|
|
60
|
-
readonly cwd: string;
|
|
61
|
-
readonly prompt: string;
|
|
62
|
-
readonly resume?: string;
|
|
63
|
-
readonly permissionMode?: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface HeadlessRunEvents {
|
|
67
|
-
readonly onOutput: (text: string) => void;
|
|
68
|
-
readonly onDone: (summary: string) => void;
|
|
69
|
-
readonly onNeedsYou: (msg: string) => void;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export type HeadlessRunner = (
|
|
73
|
-
opts: HeadlessRunRequest,
|
|
74
|
-
hooks: HeadlessRunEvents,
|
|
75
|
-
) => { readonly stop: () => void };
|
|
76
|
-
|
|
77
58
|
export interface DaemonHandle {
|
|
78
59
|
readonly stop: () => void;
|
|
79
60
|
}
|
|
@@ -95,12 +76,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
95
76
|
}
|
|
96
77
|
|
|
97
78
|
const store = new StateStore(opts.dbPath, opts.legacyFleetPath);
|
|
98
|
-
|
|
99
|
-
const mgr =
|
|
100
|
-
opts.statusPath === undefined
|
|
101
|
-
? new SessionManager(opts.adapter, store)
|
|
102
|
-
: new SessionManager(opts.adapter, store, opts.statusPath);
|
|
103
|
-
|
|
79
|
+
const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? {});
|
|
104
80
|
const clients = new Set<DaemonConnection>();
|
|
105
81
|
|
|
106
82
|
const emitEvent = (event: EventMsg) => {
|
|
@@ -165,10 +141,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
165
141
|
};
|
|
166
142
|
|
|
167
143
|
const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
|
|
168
|
-
const runner = opts.headlessRunner;
|
|
169
144
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
145
|
+
const runner = s === undefined ? null : (mgr.findAdapter(s.agent)?.headlessRunner ?? null);
|
|
170
146
|
|
|
171
|
-
if (
|
|
147
|
+
if (s === undefined || runner === null || headlessRuns.has(sessionID)) {
|
|
172
148
|
return false;
|
|
173
149
|
}
|
|
174
150
|
|
|
@@ -178,7 +154,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
178
154
|
{
|
|
179
155
|
cwd: s.cwd,
|
|
180
156
|
prompt,
|
|
181
|
-
...(s.
|
|
157
|
+
...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
|
|
182
158
|
permissionMode: 'auto',
|
|
183
159
|
},
|
|
184
160
|
{
|
|
@@ -211,11 +187,13 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
211
187
|
const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
212
188
|
const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
213
189
|
const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
190
|
+
const pendingLastUsed = new Set<string>();
|
|
214
191
|
|
|
215
192
|
// The screen tier of the detector stack: once a session's output has
|
|
216
193
|
// quiesced, judge the serialized screen and flip running/needs_you.
|
|
217
194
|
const scheduleDetect = (sessionID: string) => {
|
|
218
|
-
const
|
|
195
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
196
|
+
const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
|
|
219
197
|
|
|
220
198
|
if (detector === null) {
|
|
221
199
|
return;
|
|
@@ -306,7 +284,8 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
306
284
|
};
|
|
307
285
|
|
|
308
286
|
const applyScreenJudgment = async (sessionID: string) => {
|
|
309
|
-
const
|
|
287
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
288
|
+
const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
|
|
310
289
|
const model = screens.get(sessionID);
|
|
311
290
|
|
|
312
291
|
if (detector === null || model === undefined) {
|
|
@@ -416,8 +395,9 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
416
395
|
unread: s.unread,
|
|
417
396
|
lastMsg: s.lastMsg,
|
|
418
397
|
kind: s.kind,
|
|
419
|
-
alive: s.pty !== null || (s.kind === '
|
|
420
|
-
...(s.
|
|
398
|
+
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
399
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
400
|
+
agent: s.agent,
|
|
421
401
|
pinned: s.pinned,
|
|
422
402
|
lastAttachedAt: s.lastAttachedAt,
|
|
423
403
|
}),
|
|
@@ -439,17 +419,22 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
439
419
|
store.recordEvent(e);
|
|
440
420
|
}
|
|
441
421
|
|
|
442
|
-
|
|
422
|
+
const kind = mgr.applyHook(e);
|
|
423
|
+
|
|
424
|
+
if (kind === 'ended') {
|
|
443
425
|
pendingEjects.get(e.atcId)?.();
|
|
444
426
|
}
|
|
445
427
|
|
|
446
428
|
// A revived session announcing itself is the cue a staggered restore
|
|
447
429
|
// waits on before booting the next one.
|
|
448
|
-
if (
|
|
430
|
+
if (kind === 'started') {
|
|
449
431
|
bootWaiters.get(e.atcId)?.();
|
|
450
|
-
|
|
432
|
+
const started = mgr.sessions.find((s) => s.id === e.atcId);
|
|
451
433
|
|
|
452
|
-
|
|
434
|
+
if (started !== undefined && pendingLastUsed.delete(started.id)) {
|
|
435
|
+
store.writeLastUsedAgent(started.agent);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
453
438
|
}, opts.reporterSocketPath);
|
|
454
439
|
|
|
455
440
|
const ctx: DaemonContext = {
|
|
@@ -457,9 +442,12 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
457
442
|
collectSessions: () => mgr.collectDescriptors(),
|
|
458
443
|
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
459
444
|
collectFleet: () => store.loadFleet(),
|
|
445
|
+
loadLastUsedAgent: () => store.loadLastUsedAgent(),
|
|
446
|
+
findAdapter: (kind) => mgr.findAdapter(kind),
|
|
460
447
|
spawnSession: (p) => {
|
|
461
|
-
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
|
|
448
|
+
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.agent);
|
|
462
449
|
|
|
450
|
+
pendingLastUsed.add(s.id);
|
|
463
451
|
ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
|
|
464
452
|
screens.set(s.id, new ScreenModel(p.cols, p.rows));
|
|
465
453
|
store.recordSpawnDir(p.cwd);
|
|
@@ -487,7 +475,15 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
487
475
|
return true;
|
|
488
476
|
},
|
|
489
477
|
ejectSession: (id, prompt) => {
|
|
490
|
-
|
|
478
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
479
|
+
|
|
480
|
+
if (s === undefined) {
|
|
481
|
+
return 'missing';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const adapter = mgr.findAdapter(s.agent);
|
|
485
|
+
|
|
486
|
+
if (adapter === null || adapter.headlessRunner === null) {
|
|
491
487
|
return 'unsupported';
|
|
492
488
|
}
|
|
493
489
|
|
|
@@ -589,7 +585,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
589
585
|
return 'missing';
|
|
590
586
|
}
|
|
591
587
|
|
|
592
|
-
if (s.kind === '
|
|
588
|
+
if (s.kind === 'headless') {
|
|
593
589
|
if (headlessRuns.has(sessionID)) {
|
|
594
590
|
return 'busy';
|
|
595
591
|
}
|
|
@@ -619,11 +615,11 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
619
615
|
getEffectiveDims: (sessionID) =>
|
|
620
616
|
attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
|
|
621
617
|
restoreFleet: (cols, rows) => {
|
|
622
|
-
const hasSession = (
|
|
618
|
+
const hasSession = (agentSessionID: string) =>
|
|
623
619
|
mgr.sessions.some(
|
|
624
620
|
(s) =>
|
|
625
|
-
s.
|
|
626
|
-
(s.pty !== null || (s.kind === '
|
|
621
|
+
s.agentSessionID === agentSessionID &&
|
|
622
|
+
(s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
|
|
627
623
|
);
|
|
628
624
|
|
|
629
625
|
const recency = store.collectFleetRecency();
|
|
@@ -633,9 +629,9 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
633
629
|
// reported an event keep their stored order at the end.
|
|
634
630
|
const entries = store
|
|
635
631
|
.loadFleet()
|
|
636
|
-
.filter((entry) => !hasSession(entry.
|
|
632
|
+
.filter((entry) => !hasSession(entry.agentSessionID))
|
|
637
633
|
.toSorted((a, b) =>
|
|
638
|
-
(recency.get(b.
|
|
634
|
+
(recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
|
|
639
635
|
);
|
|
640
636
|
|
|
641
637
|
// The whole fleet registers as terminal-less sessions up front, so the
|
|
@@ -749,18 +745,23 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
|
749
745
|
return { stop: stopDaemon };
|
|
750
746
|
}
|
|
751
747
|
|
|
752
|
-
// A session reporting a transcript path that does not exist on disk yet has
|
|
753
|
-
// nothing to resume — claude exits immediately on such a resume, which
|
|
754
|
-
// reads as the revive silently failing. A session that never reported a
|
|
755
|
-
// path (foreign adapters) passes; the spawn itself will tell the truth.
|
|
756
748
|
function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
|
|
757
749
|
const s = mgr.sessions.find((x) => x.id === id);
|
|
758
750
|
|
|
759
|
-
if (s === undefined
|
|
760
|
-
return
|
|
751
|
+
if (s === undefined) {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const adapter = mgr.findAdapter(s.agent);
|
|
756
|
+
|
|
757
|
+
if (adapter === null) {
|
|
758
|
+
return false;
|
|
761
759
|
}
|
|
762
760
|
|
|
763
|
-
return
|
|
761
|
+
return adapter.canResume({
|
|
762
|
+
...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
|
|
763
|
+
...(s.transcriptSource === undefined ? {} : { transcriptSource: s.transcriptSource }),
|
|
764
|
+
});
|
|
764
765
|
}
|
|
765
766
|
|
|
766
767
|
function getDescriptor(mgr: SessionManager, id: string): SessionDescriptor {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AgentKind } from './agent-adapter';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The reserved overlay column after unread: a `g` for Grok, a space for
|
|
5
|
+
* Claude, so session names stay aligned across a mixed fleet.
|
|
6
|
+
*/
|
|
7
|
+
export function formatOverlayAgentMark(agent: AgentKind): string {
|
|
8
|
+
return agent === 'grok' ? 'g' : ' ';
|
|
9
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
AdapterEvent,
|
|
6
|
+
AgentAdapter,
|
|
7
|
+
NameUpdate,
|
|
8
|
+
ResumeCheck,
|
|
9
|
+
SpawnOptions,
|
|
10
|
+
SpawnPlan,
|
|
11
|
+
} from './agent-adapter';
|
|
12
|
+
import type { Config } from './config';
|
|
13
|
+
import type { HookEvent } from './hooks';
|
|
14
|
+
import { normalizeHookEventName } from './normalize-hook-event';
|
|
15
|
+
import { isRecord } from './report';
|
|
16
|
+
|
|
17
|
+
interface GrokSessionHookState {
|
|
18
|
+
latestPromptID?: string;
|
|
19
|
+
lastKind: AdapterEvent['kind'];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The Grok Build adapter: spawn arguments, hook payload mapping,
|
|
24
|
+
* resume semantics, and summary.json name-pulling.
|
|
25
|
+
*/
|
|
26
|
+
export class GrokAdapter implements AgentAdapter {
|
|
27
|
+
readonly kind = 'grok';
|
|
28
|
+
|
|
29
|
+
// Grok has no headless handoff.
|
|
30
|
+
readonly headlessRunner = null;
|
|
31
|
+
|
|
32
|
+
// Grok's hooks are authoritative; no screen heuristics needed.
|
|
33
|
+
readonly screenDetector = null;
|
|
34
|
+
|
|
35
|
+
private readonly config: Config;
|
|
36
|
+
|
|
37
|
+
private readonly hookState = new Map<string, GrokSessionHookState>();
|
|
38
|
+
|
|
39
|
+
constructor(config: Config) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
44
|
+
return {
|
|
45
|
+
bin: this.config.grokBin,
|
|
46
|
+
args: [
|
|
47
|
+
...this.config.grokArgs.filter((arg) => arg !== '--leader' && arg !== '--no-leader'),
|
|
48
|
+
'--no-leader',
|
|
49
|
+
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
50
|
+
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
normalizeHook(e: HookEvent): AdapterEvent {
|
|
56
|
+
const sessionID = e.payload['sessionId'];
|
|
57
|
+
const cwd = e.payload['cwd'];
|
|
58
|
+
const promptID = e.payload['promptId'];
|
|
59
|
+
const subagentType = e.payload['subagentType'];
|
|
60
|
+
|
|
61
|
+
const base: AdapterEvent = {
|
|
62
|
+
kind: 'heartbeat',
|
|
63
|
+
...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const named: AdapterEvent = {
|
|
67
|
+
...base,
|
|
68
|
+
...(typeof sessionID === 'string' && typeof cwd === 'string'
|
|
69
|
+
? { nameSource: buildGrokNameSource(sessionID, cwd) }
|
|
70
|
+
: {}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (typeof subagentType === 'string' && subagentType !== '') {
|
|
74
|
+
return this.emitHook(e.atcId, { ...base, kind: 'heartbeat' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const event = normalizeHookEventName(e.event);
|
|
78
|
+
const prior = this.hookState.get(e.atcId);
|
|
79
|
+
|
|
80
|
+
const stale =
|
|
81
|
+
typeof promptID === 'string' &&
|
|
82
|
+
prior?.latestPromptID !== undefined &&
|
|
83
|
+
promptID !== prior.latestPromptID;
|
|
84
|
+
|
|
85
|
+
switch (event) {
|
|
86
|
+
case 'SessionStart': {
|
|
87
|
+
return this.emitHook(e.atcId, { ...named, kind: 'started' });
|
|
88
|
+
}
|
|
89
|
+
case 'UserPromptSubmit': {
|
|
90
|
+
const prompt = e.payload['prompt'];
|
|
91
|
+
const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
|
|
92
|
+
const submittedID = typeof promptID === 'string' ? promptID : undefined;
|
|
93
|
+
|
|
94
|
+
return this.emitHook(
|
|
95
|
+
e.atcId,
|
|
96
|
+
{
|
|
97
|
+
...named,
|
|
98
|
+
kind: 'prompt-submitted',
|
|
99
|
+
...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
|
|
100
|
+
},
|
|
101
|
+
submittedID,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
case 'Notification': {
|
|
105
|
+
const notificationType = e.payload['notificationType'];
|
|
106
|
+
const message = e.payload['message'];
|
|
107
|
+
|
|
108
|
+
if (notificationType === 'permission_prompt') {
|
|
109
|
+
return this.emitHook(e.atcId, {
|
|
110
|
+
...base,
|
|
111
|
+
kind: 'needs-input',
|
|
112
|
+
...(typeof message === 'string' && message !== ''
|
|
113
|
+
? { message, detail: truncateDetail(message) }
|
|
114
|
+
: {}),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (notificationType === 'idle_prompt') {
|
|
119
|
+
const kind = prior?.lastKind === 'needs-input' ? 'heartbeat' : 'turn-done';
|
|
120
|
+
|
|
121
|
+
return this.emitHook(e.atcId, { ...named, kind });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return this.emitHook(e.atcId, base);
|
|
125
|
+
}
|
|
126
|
+
case 'Stop': {
|
|
127
|
+
if (e.payload['reason'] !== 'end_turn' || stale) {
|
|
128
|
+
return this.emitHook(e.atcId, base);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
|
|
132
|
+
}
|
|
133
|
+
case 'StopFailure':
|
|
134
|
+
case 'StopCancelled': {
|
|
135
|
+
if (stale) {
|
|
136
|
+
return this.emitHook(e.atcId, base);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
|
|
140
|
+
}
|
|
141
|
+
case 'SessionEnd': {
|
|
142
|
+
this.hookState.delete(e.atcId);
|
|
143
|
+
|
|
144
|
+
return { ...base, kind: 'ended' };
|
|
145
|
+
}
|
|
146
|
+
default: {
|
|
147
|
+
return this.emitHook(e.atcId, base);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
|
|
153
|
+
const parsed = readSummary(source) ?? readSummary(findGrokSummary(source));
|
|
154
|
+
|
|
155
|
+
if (parsed === null) {
|
|
156
|
+
return Promise.resolve(null);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const generated = parsed['generated_title'];
|
|
160
|
+
const summary = parsed['session_summary'];
|
|
161
|
+
const title = typeof generated === 'string' ? generated : '';
|
|
162
|
+
const sessionSummary = typeof summary === 'string' ? summary : '';
|
|
163
|
+
|
|
164
|
+
if (parsed['title_is_manual'] === true && title !== '') {
|
|
165
|
+
return Promise.resolve({ name: title, namedBy: 'agent' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (namedBy !== 'user' && title !== '') {
|
|
169
|
+
return Promise.resolve({ name: title });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (namedBy !== 'user' && sessionSummary !== '') {
|
|
173
|
+
return Promise.resolve({ name: sessionSummary });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return Promise.resolve(null);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
canResume(session: ResumeCheck): boolean {
|
|
180
|
+
return session.agentSessionID !== undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Shell command that re-opens this session outside atc (or anywhere).
|
|
184
|
+
buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
|
|
185
|
+
const resume = agentSessionID === undefined ? 'grok' : `grok --resume ${agentSessionID}`;
|
|
186
|
+
const quoted = cwd.replaceAll("'", String.raw`'\''`);
|
|
187
|
+
|
|
188
|
+
return `cd '${quoted}' && ${resume}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private emitHook(atcID: string, event: Readonly<AdapterEvent>, promptID?: string): AdapterEvent {
|
|
192
|
+
const prior = this.hookState.get(atcID);
|
|
193
|
+
const latestPromptID = promptID ?? prior?.latestPromptID;
|
|
194
|
+
|
|
195
|
+
const tracked =
|
|
196
|
+
event.kind === 'needs-input' ||
|
|
197
|
+
event.kind === 'prompt-submitted' ||
|
|
198
|
+
event.kind === 'turn-done'
|
|
199
|
+
? event.kind
|
|
200
|
+
: (prior?.lastKind ?? event.kind);
|
|
201
|
+
|
|
202
|
+
this.hookState.set(atcID, {
|
|
203
|
+
lastKind: tracked,
|
|
204
|
+
...(latestPromptID === undefined ? {} : { latestPromptID }),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return event;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveGrokHome(): string {
|
|
212
|
+
const home = process.env['GROK_HOME'];
|
|
213
|
+
|
|
214
|
+
return home !== undefined && home !== '' ? home : join(homedir(), '.grok');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildGrokNameSource(sessionID: string, cwd: string): string {
|
|
218
|
+
return join(resolveGrokHome(), 'sessions', encodeURIComponent(cwd), sessionID, 'summary.json');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildTurnDoneEvent(
|
|
222
|
+
named: Readonly<AdapterEvent>,
|
|
223
|
+
payload: Readonly<Record<string, unknown>>,
|
|
224
|
+
): AdapterEvent {
|
|
225
|
+
const lastMessage = payload['lastAssistantMessage'];
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
...named,
|
|
229
|
+
kind: 'turn-done',
|
|
230
|
+
...(typeof lastMessage === 'string' && lastMessage !== ''
|
|
231
|
+
? { detail: truncateDetail(lastMessage) }
|
|
232
|
+
: {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readSummary(source: string | null): Record<string, unknown> | null {
|
|
237
|
+
if (source === null) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const parsed: unknown = JSON.parse(readFileSync(source, 'utf8'));
|
|
243
|
+
|
|
244
|
+
return isRecord(parsed) ? parsed : null;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function findGrokSummary(source: string): string | null {
|
|
251
|
+
const sessionID = source.split('/').at(-2);
|
|
252
|
+
|
|
253
|
+
if (sessionID === undefined || sessionID === '') {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const root = join(resolveGrokHome(), 'sessions');
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
for (const group of readdirSync(root)) {
|
|
261
|
+
const candidate = join(root, group, sessionID, 'summary.json');
|
|
262
|
+
|
|
263
|
+
if (existsSync(candidate)) {
|
|
264
|
+
return candidate;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function truncateDetail(text: string): string {
|
|
273
|
+
return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
|
|
274
|
+
}
|
package/src/hook-report.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { normalizeHookEventName } from './normalize-hook-event';
|
|
1
2
|
import { isRecord, sendReport } from './report';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Runs as a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Runs as a hook inside wrangled sessions. Reads the hook event from stdin
|
|
6
|
+
* (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
|
|
7
|
+
* event name to the atc unix socket. Always exits 0 so it never blocks the
|
|
8
|
+
* session it reports on.
|
|
7
9
|
*/
|
|
8
10
|
export async function runHookReport(): Promise<void> {
|
|
9
11
|
const sock = process.env['ATC_SOCKET'];
|
|
@@ -22,7 +24,9 @@ export async function runHookReport(): Promise<void> {
|
|
|
22
24
|
}
|
|
23
25
|
} catch {}
|
|
24
26
|
|
|
25
|
-
const
|
|
27
|
+
const rawName = payload['hook_event_name'] ?? payload['hookEventName'];
|
|
28
|
+
const event = typeof rawName === 'string' ? normalizeHookEventName(rawName) : rawName;
|
|
29
|
+
const line = `${JSON.stringify({ atcId, event, payload })}\n`;
|
|
26
30
|
|
|
27
31
|
await sendReport(sock, line, 2000);
|
|
28
32
|
}
|