@phnx-labs/agents-cli 1.22.105 → 1.22.107
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/CHANGELOG.md +32 -0
- package/README.md +28 -6
- package/dist/browser.js +0 -0
- package/dist/commands/exec.d.ts +78 -8
- package/dist/commands/exec.js +407 -284
- package/dist/commands/resume.d.ts +6 -21
- package/dist/commands/resume.js +18 -55
- package/dist/commands/run-account-picker.d.ts +11 -0
- package/dist/commands/run-account-picker.js +11 -1
- package/dist/commands/sessions-resume.d.ts +4 -0
- package/dist/commands/sessions-resume.js +132 -49
- package/dist/commands/sessions.js +32 -5
- package/dist/index.js +0 -0
- package/dist/lib/accounting/account-launch.d.ts +54 -0
- package/dist/lib/accounting/account-launch.js +117 -0
- package/dist/lib/accounting/account-pool-collect.js +2 -1
- package/dist/lib/accounting/account-pool.d.ts +2 -0
- package/dist/lib/accounting/account-pool.js +1 -0
- package/dist/lib/accounting/rotate.d.ts +32 -6
- package/dist/lib/accounting/rotate.js +70 -42
- package/dist/lib/accounting/usage.d.ts +71 -0
- package/dist/lib/accounting/usage.js +160 -11
- package/dist/lib/exec-account-home.d.ts +3 -1
- package/dist/lib/exec-account-home.js +2 -2
- package/dist/lib/exec.d.ts +27 -1
- package/dist/lib/exec.js +150 -27
- package/dist/lib/hosts/dispatch.d.ts +1 -1
- package/dist/lib/hosts/dispatch.js +1 -1
- package/dist/lib/models.d.ts +1 -1
- package/dist/lib/models.js +4 -4
- package/dist/lib/session/actor-sidecar.d.ts +3 -11
- package/dist/lib/session/actor-sidecar.js +3 -0
- package/dist/lib/session/claude-accounts.d.ts +12 -73
- package/dist/lib/session/claude-accounts.js +32 -70
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +18 -5
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +116 -15
- package/dist/lib/session/recovery.d.ts +30 -34
- package/dist/lib/session/recovery.js +212 -76
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/teams/placement-probe.js +1 -1
- package/dist/session-tracker/dist/adapters/claude.d.ts +10 -0
- package/dist/session-tracker/dist/adapters/claude.js +45 -0
- package/dist/session-tracker/dist/hook.sh +191 -0
- package/dist/session-tracker/dist/index.d.ts +19 -0
- package/dist/session-tracker/dist/index.js +67 -0
- package/dist/session-tracker/dist/install-hook.d.ts +19 -0
- package/dist/session-tracker/dist/install-hook.js +245 -0
- package/dist/session-tracker/dist/prune-state.d.ts +2 -0
- package/dist/session-tracker/dist/prune-state.js +7 -0
- package/dist/session-tracker/dist/reader.d.ts +7 -0
- package/dist/session-tracker/dist/reader.js +151 -0
- package/dist/session-tracker/dist/state-file.d.ts +10 -0
- package/dist/session-tracker/dist/state-file.js +119 -0
- package/dist/session-tracker/dist/types.d.ts +32 -0
- package/dist/session-tracker/dist/types.js +1 -0
- package/dist/session-tracker/dist/writer.d.ts +12 -0
- package/dist/session-tracker/dist/writer.js +27 -0
- 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,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>;
|