@phnx-labs/agents-cli 1.22.105 → 1.22.106

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +19 -1
  3. package/dist/browser.js +0 -0
  4. package/dist/commands/exec.js +135 -233
  5. package/dist/commands/resume.d.ts +6 -21
  6. package/dist/commands/resume.js +18 -55
  7. package/dist/commands/sessions-resume.d.ts +4 -0
  8. package/dist/commands/sessions-resume.js +132 -49
  9. package/dist/commands/sessions.js +32 -5
  10. package/dist/index.js +0 -0
  11. package/dist/lib/accounting/account-launch.d.ts +54 -0
  12. package/dist/lib/accounting/account-launch.js +117 -0
  13. package/dist/lib/accounting/account-pool-collect.js +2 -1
  14. package/dist/lib/accounting/account-pool.d.ts +2 -0
  15. package/dist/lib/accounting/account-pool.js +1 -0
  16. package/dist/lib/accounting/rotate.d.ts +32 -6
  17. package/dist/lib/accounting/rotate.js +70 -42
  18. package/dist/lib/accounting/usage.d.ts +71 -0
  19. package/dist/lib/accounting/usage.js +160 -11
  20. package/dist/lib/exec-account-home.d.ts +3 -1
  21. package/dist/lib/exec-account-home.js +2 -2
  22. package/dist/lib/exec.d.ts +27 -1
  23. package/dist/lib/exec.js +150 -27
  24. package/dist/lib/models.d.ts +1 -1
  25. package/dist/lib/models.js +4 -4
  26. package/dist/lib/session/actor-sidecar.d.ts +3 -11
  27. package/dist/lib/session/actor-sidecar.js +3 -0
  28. package/dist/lib/session/claude-accounts.d.ts +12 -73
  29. package/dist/lib/session/claude-accounts.js +32 -70
  30. package/dist/lib/session/db.d.ts +1 -1
  31. package/dist/lib/session/db.js +18 -5
  32. package/dist/lib/session/discover.d.ts +4 -0
  33. package/dist/lib/session/discover.js +116 -15
  34. package/dist/lib/session/recovery.d.ts +30 -34
  35. package/dist/lib/session/recovery.js +212 -76
  36. package/dist/lib/session/types.d.ts +2 -0
  37. package/dist/lib/teams/placement-probe.js +1 -1
  38. package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
  39. package/dist/session-tracker/dist/adapters/claude.js +45 -0
  40. package/dist/session-tracker/dist/hook.sh +191 -0
  41. package/dist/session-tracker/dist/index.d.ts +19 -0
  42. package/dist/session-tracker/dist/index.js +67 -0
  43. package/dist/session-tracker/dist/install-hook.d.ts +19 -0
  44. package/dist/session-tracker/dist/install-hook.js +245 -0
  45. package/dist/session-tracker/dist/prune-state.d.ts +2 -0
  46. package/dist/session-tracker/dist/prune-state.js +7 -0
  47. package/dist/session-tracker/dist/reader.d.ts +7 -0
  48. package/dist/session-tracker/dist/reader.js +151 -0
  49. package/dist/session-tracker/dist/state-file.d.ts +10 -0
  50. package/dist/session-tracker/dist/state-file.js +119 -0
  51. package/dist/session-tracker/dist/types.d.ts +32 -0
  52. package/dist/session-tracker/dist/types.js +1 -0
  53. package/dist/session-tracker/dist/writer.d.ts +12 -0
  54. package/dist/session-tracker/dist/writer.js +27 -0
  55. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ # Polyglot SessionStart hook.
3
+ #
4
+ # Registered as a SessionStart hook in each agent's native config file.
5
+ # Each agent passes the hook payload differently:
6
+ # - claude/codex/cursor: JSON on stdin with session_id (+conversation_id for cursor)
7
+ # - grok: GROK_SESSION_ID and GROK_WORKSPACE_ROOT env vars
8
+ # - hermes: JSON on stdin (on_session_start payload); best-effort field probe
9
+ # - gemini/antigravity: best-effort stdin-JSON probe
10
+ #
11
+ # Writes ~/.agents/.cache/terminals/sessions/<PPID>.json with the canonical
12
+ # SessionState schema from src/types.ts. Atomic via mktemp + mv.
13
+ #
14
+ # Invocation:
15
+ # hook.sh <agent> # required; selects which payload format to parse
16
+ #
17
+ # Silent on success (SessionStart stdout leaks into the model context).
18
+
19
+ set -euo pipefail
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+
23
+ TMP=""
24
+ SID_TMP=""
25
+ cleanup() {
26
+ [ -n "$TMP" ] && rm -f "$TMP"
27
+ [ -n "$SID_TMP" ] && rm -f "$SID_TMP"
28
+ return 0
29
+ }
30
+ trap cleanup EXIT
31
+
32
+ AGENT="${1:-${AGENT_HINT:-}}"
33
+ if [ -z "$AGENT" ]; then
34
+ exit 0
35
+ fi
36
+
37
+ # Read stdin if any (don't block forever).
38
+ # Use `cat` only when stdin is not a TTY — and rely on hosts (claude, codex,
39
+ # cursor) closing stdin promptly. macOS has no `timeout` in PATH by default,
40
+ # so we don't use it.
41
+ STDIN_JSON=""
42
+ if [ ! -t 0 ]; then
43
+ STDIN_JSON="$(cat || true)"
44
+ fi
45
+
46
+ SID=""
47
+ CWD=""
48
+ METHOD="hook-stdin"
49
+
50
+ extract_stdin_json() {
51
+ local field_priority="$1" # space-separated list of JSON keys to try in order
52
+ python3 -c "
53
+ import json, sys
54
+ try:
55
+ d = json.load(sys.stdin)
56
+ for k in '''$field_priority'''.split():
57
+ v = d.get(k)
58
+ if isinstance(v, str) and v:
59
+ print(v); sys.exit(0)
60
+ if isinstance(v, list) and v and isinstance(v[0], str):
61
+ print(v[0]); sys.exit(0)
62
+ except Exception:
63
+ pass
64
+ " 2>/dev/null || true
65
+ }
66
+
67
+ case "$AGENT" in
68
+ claude|codex|droid|kimi)
69
+ SID="$(printf '%s' "$STDIN_JSON" | extract_stdin_json 'session_id')"
70
+ CWD="$(printf '%s' "$STDIN_JSON" | extract_stdin_json 'cwd')"
71
+ ;;
72
+ cursor)
73
+ SID="$(printf '%s' "$STDIN_JSON" | extract_stdin_json 'session_id conversation_id')"
74
+ CWD="$(printf '%s' "$STDIN_JSON" | extract_stdin_json 'cwd workspace_roots')"
75
+ ;;
76
+ grok)
77
+ SID="${GROK_SESSION_ID:-}"
78
+ CWD="${GROK_WORKSPACE_ROOT:-$PWD}"
79
+ METHOD="hook-env"
80
+ ;;
81
+ hermes|gemini|antigravity)
82
+ # Best-effort stdin-JSON probe across the field names these harnesses use.
83
+ # A miss exits 0 below (no state file) — never a wrong write.
84
+ SID="$(printf '%s' "$STDIN_JSON" | extract_stdin_json 'session_id conversation_id sessionId')"
85
+ CWD="$(printf '%s' "$STDIN_JSON" | extract_stdin_json 'cwd workspace_roots')"
86
+ ;;
87
+ *)
88
+ exit 0
89
+ ;;
90
+ esac
91
+
92
+ if [ -z "$SID" ]; then
93
+ exit 0
94
+ fi
95
+
96
+ # The session id becomes two filenames below. Reject path components before
97
+ # either mktemp or mv sees the harness-provided value.
98
+ case "$SID" in
99
+ *'/'*|*'\'*|'.'|'..') exit 0 ;;
100
+ esac
101
+
102
+ [ -z "$CWD" ] && CWD="$PWD"
103
+
104
+ STATE_DIR="$HOME/.agents/.cache/terminals/sessions"
105
+ mkdir -p "$STATE_DIR"
106
+
107
+ TID="${AGENT_TERMINAL_ID:-}"
108
+ LID="${AGENT_LAUNCH_ID:-}"
109
+
110
+ TMP="$(mktemp "$STATE_DIR/.${PPID}.XXXXXX")"
111
+ python3 - "$SID" "$CWD" "$PPID" "$AGENT" "$TID" "$LID" "$METHOD" > "$TMP" <<'PY'
112
+ import json, sys, time
113
+ sid, cwd, pid, agent, tid, lid, method = sys.argv[1:8]
114
+ out = {
115
+ "session_id": sid,
116
+ "agent": agent,
117
+ "cwd": cwd,
118
+ "pid": int(pid),
119
+ "ts": int(time.time() * 1000),
120
+ "method": method,
121
+ }
122
+ if tid:
123
+ out["terminal_id"] = tid
124
+ if lid:
125
+ out["launch_id"] = lid
126
+ json.dump(out, sys.stdout)
127
+ PY
128
+
129
+ mv -f "$TMP" "$STATE_DIR/$PPID.json"
130
+
131
+ # Prune dead-pid records, zero-byte files, and orphaned temp files left by
132
+ # crashed atomic writes. Keep stdout/stderr silent — it leaks into the model.
133
+ PRUNE_SCRIPT="$SCRIPT_DIR/../dist/prune-state.js"
134
+ if [ -f "$PRUNE_SCRIPT" ]; then
135
+ node "$PRUNE_SCRIPT" >/dev/null 2>&1 || true
136
+ fi
137
+
138
+ # Persist launch metadata under the harness's real session id. `agents run`
139
+ # exports the EFFECTIVE mode after capability/headless resolution, plus the
140
+ # shared (non-version-home) history directory. Atomic replacement lets a native
141
+ # resume with an explicit --mode become the new mode for the next resume.
142
+ HISTORY_DIR="${AGENTS_HISTORY_DIR:-}"
143
+ RUN_MODE="${AGENTS_RUN_MODE:-}"
144
+ # The agents-cli version-home id this run launched under. Recorded here (not at
145
+ # spawn) for the same reason as RUN_MODE: the harness coins its real session id
146
+ # only after launch, so it must be joined to that id by this hook. A later native
147
+ # resume pins the exact origin version off it, so a session whose transcript
148
+ # carries no derivable version (codex's `.codex-homes/<version>/` home) still
149
+ # resumes natively instead of degrading to `/continue` (PHNX-3626).
150
+ RUN_VERSION="${AGENTS_RUN_VERSION:-}"
151
+ RUN_ACCOUNT_ID="${AGENTS_RUN_ACCOUNT_ID:-}"
152
+ TMUX_SESSION_NAME="${AGENT_TMUX_SESSION_NAME:-}"
153
+ if [ -n "$HISTORY_DIR" ] && { [ -n "$RUN_MODE" ] || [ -n "$RUN_VERSION" ] || [ -n "$RUN_ACCOUNT_ID" ] || [ -n "$TMUX_SESSION_NAME" ]; }; then
154
+ BY_SESSION_DIR="$HISTORY_DIR/by-session"
155
+ mkdir -p "$BY_SESSION_DIR"
156
+ SID_TMP="$(mktemp "$BY_SESSION_DIR/.${SID}.XXXXXX")"
157
+ python3 - "$SID" "$RUN_MODE" "${AGENTS_ACTOR:-}" "${AGENTS_ACTOR_KIND:-}" "$TMUX_SESSION_NAME" "$BY_SESSION_DIR/$SID.json" "$RUN_VERSION" "$RUN_ACCOUNT_ID" > "$SID_TMP" <<'PY'
158
+ import json, re, sys, time
159
+ sid, mode, actor, initiated_by, tmux_name, existing_path, version, account_id = sys.argv[1:9]
160
+ out = {}
161
+ try:
162
+ with open(existing_path) as existing:
163
+ value = json.load(existing)
164
+ if isinstance(value, dict):
165
+ out = value
166
+ except (OSError, ValueError):
167
+ pass
168
+ out['sessionId'] = sid
169
+ if mode in ('plan', 'edit', 'auto', 'skip'):
170
+ out['mode'] = mode
171
+ if version:
172
+ out['version'] = version
173
+ if account_id:
174
+ out.setdefault('accountId', account_id)
175
+ out['startedAtMs'] = int(time.time() * 1000)
176
+ if actor:
177
+ out['actor'] = actor
178
+ if initiated_by in ('human', 'agent'):
179
+ out['initiatedBy'] = initiated_by
180
+ if re.fullmatch(r'ag-[a-z][a-z0-9-]*-[0-9a-f]{8}', tmux_name, re.I):
181
+ aliases = out.get('aliases')
182
+ if not isinstance(aliases, list):
183
+ aliases = []
184
+ aliases = [alias.lower() for alias in aliases if isinstance(alias, str) and re.fullmatch(r'ag-[a-z][a-z0-9-]*-[0-9a-f]{8}', alias, re.I)]
185
+ aliases.append(tmux_name.lower())
186
+ out['aliases'] = list(dict.fromkeys(aliases))
187
+ json.dump(out, sys.stdout)
188
+ PY
189
+ mv -f "$SID_TMP" "$BY_SESSION_DIR/$SID.json"
190
+ fi
191
+ exit 0
@@ -0,0 +1,19 @@
1
+ import type { DetectionResult, LookupInput, SessionState, TrackSpawnInput } from './types.js';
2
+ export * from './types.js';
3
+ export * from './state-file.js';
4
+ export * from './writer.js';
5
+ export * from './install-hook.js';
6
+ export { descendantPids, findStateByPid, findStateInTree, findStateByTerminalId, findStateByLaunchId, pruneStaleSessionState, } from './reader.js';
7
+ export interface TrackSpawnOptions {
8
+ /** Max time to wait for the SessionStart hook to land the state file. Default 5000ms. */
9
+ timeoutMs?: number;
10
+ /** Poll interval. Default 50ms. */
11
+ pollIntervalMs?: number;
12
+ }
13
+ /**
14
+ * Wait for the polyglot SessionStart hook (src/hook.sh) to drop a state file
15
+ * at stateFilePath(input.agentPid). Resolves as soon as it appears.
16
+ * Returns confidence='low' / sessionId=null on timeout.
17
+ */
18
+ export declare function trackSpawn(input: TrackSpawnInput, opts?: TrackSpawnOptions): Promise<DetectionResult>;
19
+ export declare function getLiveSession(input: LookupInput): Promise<SessionState | null>;
@@ -0,0 +1,67 @@
1
+ import * as fs from 'fs';
2
+ import { parseState, stateFilePath } from './state-file.js';
3
+ import { findStateByLaunchId, findStateByTerminalId, findStateInTree, } from './reader.js';
4
+ export * from './types.js';
5
+ export * from './state-file.js';
6
+ export * from './writer.js';
7
+ export * from './install-hook.js';
8
+ export { descendantPids, findStateByPid, findStateInTree, findStateByTerminalId, findStateByLaunchId, pruneStaleSessionState, } from './reader.js';
9
+ /**
10
+ * Wait for the polyglot SessionStart hook (src/hook.sh) to drop a state file
11
+ * at stateFilePath(input.agentPid). Resolves as soon as it appears.
12
+ * Returns confidence='low' / sessionId=null on timeout.
13
+ */
14
+ export async function trackSpawn(input, opts = {}) {
15
+ const timeoutMs = opts.timeoutMs ?? 5000;
16
+ const pollIntervalMs = opts.pollIntervalMs ?? 50;
17
+ const start = Date.now();
18
+ const targetPath = stateFilePath(input.agentPid);
19
+ while (Date.now() - start < timeoutMs) {
20
+ const state = await readStateIfPresent(targetPath);
21
+ if (state) {
22
+ return {
23
+ sessionId: state.session_id,
24
+ method: state.method,
25
+ latencyMs: Date.now() - start,
26
+ confidence: 'high',
27
+ };
28
+ }
29
+ await sleep(pollIntervalMs);
30
+ }
31
+ return {
32
+ sessionId: null,
33
+ method: null,
34
+ latencyMs: Date.now() - start,
35
+ confidence: 'low',
36
+ };
37
+ }
38
+ export async function getLiveSession(input) {
39
+ if (input.launchId) {
40
+ const byLaunch = await findStateByLaunchId(input.launchId);
41
+ if (byLaunch)
42
+ return byLaunch;
43
+ }
44
+ if (input.terminalId) {
45
+ const byTerm = await findStateByTerminalId(input.terminalId);
46
+ if (byTerm)
47
+ return byTerm;
48
+ }
49
+ if (input.shellPid) {
50
+ return findStateInTree(input.shellPid);
51
+ }
52
+ return null;
53
+ }
54
+ async function readStateIfPresent(p) {
55
+ try {
56
+ const raw = await fs.promises.readFile(p, 'utf8');
57
+ return parseState(raw);
58
+ }
59
+ catch (err) {
60
+ if (err.code === 'ENOENT')
61
+ return null;
62
+ return null;
63
+ }
64
+ }
65
+ function sleep(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
@@ -0,0 +1,19 @@
1
+ import type { AgentId } from './types.js';
2
+ export interface InstallResult {
3
+ agent: AgentId;
4
+ installed: boolean;
5
+ configPath: string;
6
+ error?: string;
7
+ }
8
+ export interface InstallOptions {
9
+ dryRun?: boolean;
10
+ hookPathOverride?: string;
11
+ /** Home directory whose harness-native config should be written. Defaults to
12
+ * `os.homedir()` so the live config (usually a symlink into the active
13
+ * version home) is updated. */
14
+ home?: string;
15
+ }
16
+ /** Every agent id the hook installer knows — the keys of the compile-time-complete
17
+ * {@link HOOK_SUPPORT} table (Record<AgentId, …>), so this can never drift from AgentId. */
18
+ export declare const HOOK_AGENTS: AgentId[];
19
+ export declare function installHookFor(agent: AgentId, opts?: InstallOptions): Promise<InstallResult>;
@@ -0,0 +1,245 @@
1
+ // Installs the polyglot src/hook.sh as a SessionStart hook in each agent's
2
+ // native config file. Idempotent — running twice does not double-register.
3
+ //
4
+ // CLI usage:
5
+ // tsx src/install-hook.ts claude
6
+ // tsx src/install-hook.ts claude codex cursor
7
+ import * as fs from 'fs';
8
+ import * as os from 'os';
9
+ import * as path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import * as TOML from 'smol-toml';
12
+ import * as YAML from 'yaml';
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+ const HOOK_PATH = path.resolve(__dirname, 'hook.sh');
16
+ function hookCommand(agent, opts) {
17
+ const hook = opts.hookPathOverride ?? HOOK_PATH;
18
+ return `${hook} ${agent}`;
19
+ }
20
+ /** True when a stored command string is a prior registration of this package's
21
+ * `hook.sh` (src or dist). Used for idempotency when stripping old entries. */
22
+ function isOwnHookCommand(command) {
23
+ const first = command.trim().split(/\s+/)[0];
24
+ return first.endsWith('hook.sh') && first.includes('session-tracker');
25
+ }
26
+ async function readJson(p) {
27
+ try {
28
+ const raw = await fs.promises.readFile(p, 'utf8');
29
+ return JSON.parse(raw);
30
+ }
31
+ catch (err) {
32
+ if (err.code === 'ENOENT')
33
+ return {};
34
+ throw err;
35
+ }
36
+ }
37
+ async function writeJsonAtomic(p, data) {
38
+ await fs.promises.mkdir(path.dirname(p), { recursive: true });
39
+ const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
40
+ try {
41
+ await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
42
+ await fs.promises.rename(tmp, p);
43
+ }
44
+ catch (err) {
45
+ try {
46
+ await fs.promises.unlink(tmp);
47
+ }
48
+ catch { /* best-effort */ }
49
+ throw err;
50
+ }
51
+ }
52
+ async function installClaude(opts) {
53
+ const configPath = path.join(opts.home ?? os.homedir(), '.claude', 'settings.json');
54
+ const command = hookCommand('claude', opts);
55
+ if (opts.dryRun) {
56
+ return { agent: 'claude', installed: false, configPath };
57
+ }
58
+ const cfg = await readJson(configPath);
59
+ cfg.hooks = cfg.hooks ?? {};
60
+ cfg.hooks.SessionStart = cfg.hooks.SessionStart ?? [];
61
+ // Remove any prior registration of THIS hook path (idempotency).
62
+ for (const entry of cfg.hooks.SessionStart) {
63
+ if (!entry || !Array.isArray(entry.hooks))
64
+ continue;
65
+ entry.hooks = entry.hooks.filter((h) => !(h && h.command && isOwnHookCommand(h.command)));
66
+ }
67
+ // Find or create the empty-matcher group and add our hook.
68
+ let group = cfg.hooks.SessionStart.find((e) => e && e.matcher === '');
69
+ if (!group) {
70
+ group = { matcher: '', hooks: [] };
71
+ cfg.hooks.SessionStart.push(group);
72
+ }
73
+ group.hooks.push({ type: 'command', command, timeout: 5 });
74
+ await writeJsonAtomic(configPath, cfg);
75
+ return { agent: 'claude', installed: true, configPath };
76
+ }
77
+ async function installCodex(opts) {
78
+ const configPath = path.join(opts.home ?? os.homedir(), '.codex', 'hooks.json');
79
+ const command = hookCommand('codex', opts);
80
+ if (opts.dryRun) {
81
+ return { agent: 'codex', installed: false, configPath };
82
+ }
83
+ const cfg = await readJson(configPath);
84
+ cfg.hooks = cfg.hooks ?? {};
85
+ cfg.hooks.SessionStart = cfg.hooks.SessionStart ?? [];
86
+ for (const entry of cfg.hooks.SessionStart) {
87
+ if (!entry || !Array.isArray(entry.hooks))
88
+ continue;
89
+ entry.hooks = entry.hooks.filter((h) => !(h && h.command && isOwnHookCommand(h.command)));
90
+ }
91
+ let group = cfg.hooks.SessionStart.find((e) => e && (e.matcher === '' || e.matcher === 'startup|resume'));
92
+ if (!group) {
93
+ group = { matcher: 'startup|resume', hooks: [] };
94
+ cfg.hooks.SessionStart.push(group);
95
+ }
96
+ group.hooks.push({ type: 'command', command, timeout: 5 });
97
+ await writeJsonAtomic(configPath, cfg);
98
+ return { agent: 'codex', installed: true, configPath };
99
+ }
100
+ async function installCursor(opts) {
101
+ const configPath = path.join(opts.home ?? os.homedir(), '.cursor', 'hooks.json');
102
+ const command = hookCommand('cursor', opts);
103
+ if (opts.dryRun) {
104
+ return { agent: 'cursor', installed: false, configPath };
105
+ }
106
+ const cfg = await readJson(configPath);
107
+ cfg.hooks = cfg.hooks ?? {};
108
+ cfg.hooks.sessionStart = cfg.hooks.sessionStart ?? [];
109
+ cfg.hooks.sessionStart = cfg.hooks.sessionStart.filter((h) => !(h && h.command && isOwnHookCommand(h.command)));
110
+ cfg.hooks.sessionStart.push({ type: 'command', command, timeout: 5 });
111
+ await writeJsonAtomic(configPath, cfg);
112
+ return { agent: 'cursor', installed: true, configPath };
113
+ }
114
+ async function installGrok(opts) {
115
+ const configPath = path.join(opts.home ?? os.homedir(), '.grok', 'hooks', 'session-start.json');
116
+ const command = hookCommand('grok', opts);
117
+ if (opts.dryRun) {
118
+ return { agent: 'grok', installed: false, configPath };
119
+ }
120
+ await writeJsonAtomic(configPath, { command, timeout: 5 });
121
+ return { agent: 'grok', installed: true, configPath };
122
+ }
123
+ async function installDroid(opts) {
124
+ const configPath = path.join(opts.home ?? os.homedir(), '.factory', 'settings.json');
125
+ const command = hookCommand('droid', opts);
126
+ if (opts.dryRun)
127
+ return { agent: 'droid', installed: false, configPath };
128
+ const cfg = await readJson(configPath);
129
+ cfg.hooks = cfg.hooks ?? {};
130
+ cfg.hooks.SessionStart = cfg.hooks.SessionStart ?? [];
131
+ for (const entry of cfg.hooks.SessionStart) {
132
+ if (!entry || !Array.isArray(entry.hooks))
133
+ continue;
134
+ entry.hooks = entry.hooks.filter((hook) => !(hook?.command && String(hook.command).includes('packages/session-tracker/src/hook.sh')));
135
+ }
136
+ let group = cfg.hooks.SessionStart.find((entry) => entry?.matcher === '');
137
+ if (!group) {
138
+ group = { matcher: '', hooks: [] };
139
+ cfg.hooks.SessionStart.push(group);
140
+ }
141
+ group.hooks.push({ type: 'command', command, timeout: 5 });
142
+ await writeJsonAtomic(configPath, cfg);
143
+ return { agent: 'droid', installed: true, configPath };
144
+ }
145
+ async function installKimi(opts) {
146
+ const configPath = path.join(opts.home ?? os.homedir(), '.kimi-code', 'config.toml');
147
+ const command = hookCommand('kimi', opts);
148
+ if (opts.dryRun)
149
+ return { agent: 'kimi', installed: false, configPath };
150
+ let cfg = {};
151
+ try {
152
+ cfg = TOML.parse(await fs.promises.readFile(configPath, 'utf8'));
153
+ }
154
+ catch (error) {
155
+ if (error.code !== 'ENOENT')
156
+ throw error;
157
+ }
158
+ const hooks = (Array.isArray(cfg.hooks) ? cfg.hooks : []);
159
+ cfg.hooks = [
160
+ ...hooks.filter((hook) => !(typeof hook.command === 'string' && isOwnHookCommand(hook.command))),
161
+ { event: 'SessionStart', command, timeout: 5 },
162
+ ];
163
+ await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
164
+ await fs.promises.writeFile(configPath, TOML.stringify(cfg), 'utf8');
165
+ return { agent: 'kimi', installed: true, configPath };
166
+ }
167
+ async function installHermes(opts) {
168
+ const configPath = path.join(opts.home ?? os.homedir(), '.hermes', 'config.yaml');
169
+ const command = hookCommand('hermes', opts);
170
+ if (opts.dryRun)
171
+ return { agent: 'hermes', installed: false, configPath };
172
+ // Read-modify-write the YAML, preserving every sibling key (mcp_servers, …) —
173
+ // mirrors the CLI's registerHooksForHermes. Hermes maps SessionStart to the
174
+ // `on_session_start` event (HERMES_EVENT_MAP in cli/src/lib/hooks/install.ts).
175
+ let cfg = {};
176
+ try {
177
+ const parsed = YAML.parse(await fs.promises.readFile(configPath, 'utf8'));
178
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
179
+ cfg = parsed;
180
+ }
181
+ catch (error) {
182
+ if (error.code !== 'ENOENT')
183
+ throw error;
184
+ }
185
+ const hooks = cfg.hooks && typeof cfg.hooks === 'object' && !Array.isArray(cfg.hooks)
186
+ ? cfg.hooks
187
+ : {};
188
+ const existing = Array.isArray(hooks.on_session_start) ? hooks.on_session_start : [];
189
+ hooks.on_session_start = [
190
+ ...existing.filter((h) => !(typeof h?.command === 'string' && isOwnHookCommand(h.command))),
191
+ { command, timeout: 5 },
192
+ ];
193
+ cfg.hooks = hooks;
194
+ await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
195
+ await fs.promises.writeFile(configPath, YAML.stringify(cfg), 'utf8');
196
+ return { agent: 'hermes', installed: true, configPath };
197
+ }
198
+ const HOOK_SUPPORT = {
199
+ claude: { install: installClaude },
200
+ codex: { install: installCodex },
201
+ cursor: { install: installCursor },
202
+ grok: { install: installGrok },
203
+ droid: { install: installDroid },
204
+ kimi: { install: installKimi },
205
+ hermes: { install: installHermes },
206
+ gemini: { unsupported: 'gemini is hard-deprecated (kept only for parsing old sessions)' },
207
+ antigravity: { unsupported: 'antigravity has no SessionStart hook event' },
208
+ opencode: { unsupported: 'opencode SessionStart is a generated plugin, not a shell-command hook' },
209
+ };
210
+ /** Every agent id the hook installer knows — the keys of the compile-time-complete
211
+ * {@link HOOK_SUPPORT} table (Record<AgentId, …>), so this can never drift from AgentId. */
212
+ export const HOOK_AGENTS = Object.keys(HOOK_SUPPORT);
213
+ export async function installHookFor(agent, opts = {}) {
214
+ const support = HOOK_SUPPORT[agent];
215
+ if (!support) {
216
+ return { agent, installed: false, configPath: '', error: `unknown agent '${agent}'` };
217
+ }
218
+ if ('unsupported' in support) {
219
+ return { agent, installed: false, configPath: '', error: support.unsupported };
220
+ }
221
+ try {
222
+ return await support.install(opts);
223
+ }
224
+ catch (err) {
225
+ return {
226
+ agent,
227
+ installed: false,
228
+ configPath: '',
229
+ error: err.message,
230
+ };
231
+ }
232
+ }
233
+ if (import.meta.url === `file://${process.argv[1]}`) {
234
+ const agents = process.argv.slice(2);
235
+ if (agents.length === 0) {
236
+ console.error('usage: tsx src/install-hook.ts <agent> [<agent>...]');
237
+ process.exit(2);
238
+ }
239
+ for (const a of agents) {
240
+ const r = await installHookFor(a);
241
+ console.log(JSON.stringify(r));
242
+ if (r.error)
243
+ process.exitCode = 1;
244
+ }
245
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // Prune stale entries from the SessionStart hook state directory.
3
+ // Called by hook.sh after a successful write so the directory never accumulates
4
+ // dead-pid records, zero-byte files, or orphaned temp files.
5
+ import { cleanupOrphanedStateFiles } from './state-file.js';
6
+ await cleanupOrphanedStateFiles();
7
+ // Stay silent — stdout from SessionStart hooks leaks into the model context.
@@ -0,0 +1,7 @@
1
+ import type { SessionState } from './types.js';
2
+ export declare function descendantPids(rootPid: number): Promise<number[]>;
3
+ export declare function findStateByPid(pid: number): Promise<SessionState | null>;
4
+ export declare function findStateInTree(shellPid: number): Promise<SessionState | null>;
5
+ export declare function findStateByTerminalId(terminalId: string): Promise<SessionState | null>;
6
+ export declare function findStateByLaunchId(launchId: string): Promise<SessionState | null>;
7
+ export declare function pruneStaleSessionState(): Promise<number>;