@made-by-moonlight/athene-plugin-agent-claude-code 0.9.1
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/LICENSE +22 -0
- package/dist/activity-detection.d.ts +92 -0
- package/dist/activity-detection.d.ts.map +1 -0
- package/dist/activity-detection.js +409 -0
- package/dist/activity-detection.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1127 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
import { shellEscape, normalizeAgentPermissionMode, isWindows, } from "@made-by-moonlight/athene-core";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { readFile, stat, open, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { classifyTerminalOutput, findLatestSessionFile, getClaudeActivityState, isClaudeProcessAlive, resolveWorkspaceForClaude, toClaudeProjectPath, } from "./activity-detection.js";
|
|
8
|
+
export { resetPsCache, resolveWorkspaceForClaude, toClaudeProjectPath } from "./activity-detection.js";
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Metadata Updater Hook Script
|
|
11
|
+
// =============================================================================
|
|
12
|
+
/** Hook script content that updates session metadata on git/gh commands.
|
|
13
|
+
* Exported for integration testing. */
|
|
14
|
+
export const METADATA_UPDATER_SCRIPT = `#!/usr/bin/env bash
|
|
15
|
+
# Metadata Updater Hook for Athene
|
|
16
|
+
#
|
|
17
|
+
# This PostToolUse hook automatically updates session metadata when:
|
|
18
|
+
# - gh pr create: extracts PR URL and writes to metadata
|
|
19
|
+
# - git checkout -b / git switch -c: extracts branch name and writes to metadata
|
|
20
|
+
# - gh pr merge: updates status to "merged"
|
|
21
|
+
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
# Configuration
|
|
25
|
+
AO_DATA_DIR="\${AO_DATA_DIR:-$HOME/.ao-sessions}"
|
|
26
|
+
|
|
27
|
+
# Read hook input from stdin
|
|
28
|
+
input=$(cat)
|
|
29
|
+
|
|
30
|
+
# Extract fields from JSON (using jq if available, otherwise basic parsing)
|
|
31
|
+
if command -v jq &>/dev/null; then
|
|
32
|
+
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
|
|
33
|
+
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
|
34
|
+
output=$(echo "$input" | jq -r '.tool_response // empty')
|
|
35
|
+
exit_code=$(echo "$input" | jq -r '.exit_code // 0')
|
|
36
|
+
else
|
|
37
|
+
# Fallback: basic JSON parsing without jq
|
|
38
|
+
tool_name=$(echo "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4 || echo "")
|
|
39
|
+
command=$(echo "$input" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4 || echo "")
|
|
40
|
+
output=$(echo "$input" | grep -o '"tool_response"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4 || echo "")
|
|
41
|
+
exit_code=$(echo "$input" | grep -o '"exit_code"[[:space:]]*:[[:space:]]*[0-9]*' | grep -o '[0-9]*$' || echo "0")
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Only process successful commands (exit code 0)
|
|
45
|
+
if [[ "$exit_code" -ne 0 ]]; then
|
|
46
|
+
echo '{}'
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Only process Bash tool calls
|
|
51
|
+
if [[ "$tool_name" != "Bash" ]]; then
|
|
52
|
+
echo '{}' # Empty JSON output
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# Validate AO_SESSION is set
|
|
57
|
+
if [[ -z "\${AO_SESSION:-}" ]]; then
|
|
58
|
+
echo '{"systemMessage": "AO_SESSION environment variable not set, skipping metadata update"}'
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# Construct metadata file path
|
|
63
|
+
# AO_DATA_DIR is already set to the project-specific sessions directory
|
|
64
|
+
# V2 storage uses .json extension
|
|
65
|
+
metadata_file="$AO_DATA_DIR/\${AO_SESSION}.json"
|
|
66
|
+
|
|
67
|
+
# Fallback to bare filename for pre-migration layouts
|
|
68
|
+
if [[ ! -f "$metadata_file" ]]; then
|
|
69
|
+
metadata_file="$AO_DATA_DIR/$AO_SESSION"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
# Ensure metadata file exists
|
|
73
|
+
if [[ ! -f "$metadata_file" ]]; then
|
|
74
|
+
echo '{"systemMessage": "Metadata file not found: '"$AO_DATA_DIR/\${AO_SESSION}"'"}'
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
# Detect if metadata file is JSON format
|
|
79
|
+
is_json_metadata() {
|
|
80
|
+
local first_char
|
|
81
|
+
first_char=$(head -c1 "$metadata_file" 2>/dev/null)
|
|
82
|
+
[[ "$first_char" == "{" ]]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Update a single key in metadata (handles both JSON and key=value formats)
|
|
86
|
+
update_metadata_key() {
|
|
87
|
+
local key="$1"
|
|
88
|
+
local value="$2"
|
|
89
|
+
local temp_file="\${metadata_file}.tmp"
|
|
90
|
+
|
|
91
|
+
if is_json_metadata; then
|
|
92
|
+
# JSON format
|
|
93
|
+
if command -v jq &>/dev/null; then
|
|
94
|
+
jq --arg k "$key" --arg v "$value" '.[$k] = $v' "$metadata_file" > "$temp_file"
|
|
95
|
+
mv "$temp_file" "$metadata_file"
|
|
96
|
+
else
|
|
97
|
+
# jq unavailable — use node (hard dep) for safe nested JSON update
|
|
98
|
+
node -e "
|
|
99
|
+
const fs = require('fs');
|
|
100
|
+
const d = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
|
|
101
|
+
d[process.argv[2]] = process.argv[3];
|
|
102
|
+
fs.writeFileSync(process.argv[4], JSON.stringify(d, null, 2));
|
|
103
|
+
" "$metadata_file" "$key" "$value" "$temp_file"
|
|
104
|
+
mv "$temp_file" "$metadata_file"
|
|
105
|
+
fi
|
|
106
|
+
else
|
|
107
|
+
# Key=value format (legacy)
|
|
108
|
+
local escaped_value=$(echo "$value" | sed 's/[&|\\/]/\\\\&/g')
|
|
109
|
+
if grep -q "^$key=" "$metadata_file" 2>/dev/null; then
|
|
110
|
+
sed "s|^$key=.*|$key=$escaped_value|" "$metadata_file" > "$temp_file"
|
|
111
|
+
else
|
|
112
|
+
cp "$metadata_file" "$temp_file"
|
|
113
|
+
echo "$key=$value" >> "$temp_file"
|
|
114
|
+
fi
|
|
115
|
+
mv "$temp_file" "$metadata_file"
|
|
116
|
+
fi
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# ============================================================================
|
|
120
|
+
# Command Detection and Parsing
|
|
121
|
+
# ============================================================================
|
|
122
|
+
|
|
123
|
+
# Strip leading directory-change prefixes so that commands like
|
|
124
|
+
# cd ~/.worktrees/project && gh pr create ...
|
|
125
|
+
# are correctly detected. Agents frequently cd into a worktree first.
|
|
126
|
+
# Store the regex pattern in a variable for clarity (avoids shell quoting confusion).
|
|
127
|
+
# Uses space-padded (&&|;) to avoid breaking on paths containing & or ; chars.
|
|
128
|
+
cd_prefix_pattern='^[[:space:]]*cd[[:space:]]+.*[[:space:]]+(&&|;)[[:space:]]+(.*)'
|
|
129
|
+
clean_command="$command"
|
|
130
|
+
while [[ "$clean_command" =~ ^[[:space:]]*cd[[:space:]] ]]; do
|
|
131
|
+
if [[ "$clean_command" =~ $cd_prefix_pattern ]]; then
|
|
132
|
+
clean_command="\${BASH_REMATCH[2]}"
|
|
133
|
+
else
|
|
134
|
+
break
|
|
135
|
+
fi
|
|
136
|
+
done
|
|
137
|
+
|
|
138
|
+
# Detect: gh pr create
|
|
139
|
+
if [[ "$clean_command" =~ ^gh[[:space:]]+pr[[:space:]]+create ]]; then
|
|
140
|
+
sanitized_output=$(printf '%s' "$output" | sed -E $'s/\x1B\\[[0-9;]*[A-Za-z]//g')
|
|
141
|
+
# Extract PR URL from output
|
|
142
|
+
pr_url=""
|
|
143
|
+
# GitHub PR URLs are whitespace-delimited in gh output after ANSI stripping.
|
|
144
|
+
if [[ "$sanitized_output" =~ (https://github[.]com/[^[:space:]]+/[^[:space:]]+/pull/[0-9]+) ]]; then
|
|
145
|
+
pr_url="\${BASH_REMATCH[1]}"
|
|
146
|
+
fi
|
|
147
|
+
|
|
148
|
+
if [[ -n "$pr_url" ]]; then
|
|
149
|
+
update_metadata_key "pr" "$pr_url"
|
|
150
|
+
# Append to prs field (comma-separated list of all PR URLs for this session).
|
|
151
|
+
# Supports multiple PRs per session — same repo or different repos.
|
|
152
|
+
existing_prs=""
|
|
153
|
+
if is_json_metadata; then
|
|
154
|
+
if command -v jq &>/dev/null; then
|
|
155
|
+
existing_prs=$(jq -r '.prs // empty' "$metadata_file" 2>/dev/null || echo "")
|
|
156
|
+
else
|
|
157
|
+
existing_prs=$(node -e "
|
|
158
|
+
const fs = require('fs');
|
|
159
|
+
const d = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
|
|
160
|
+
process.stdout.write(d.prs || '');
|
|
161
|
+
" "$metadata_file" 2>/dev/null || echo "")
|
|
162
|
+
fi
|
|
163
|
+
else
|
|
164
|
+
existing_prs=$(grep '^prs=' "$metadata_file" 2>/dev/null | cut -d'=' -f2- || echo "")
|
|
165
|
+
fi
|
|
166
|
+
if [[ -z "$existing_prs" ]]; then
|
|
167
|
+
new_prs="$pr_url"
|
|
168
|
+
else
|
|
169
|
+
# Only append if not already present (exact comma-delimited match to avoid /pull/1 matching /pull/10)
|
|
170
|
+
if ! echo ",$existing_prs," | grep -qF ",$pr_url,"; then
|
|
171
|
+
new_prs="$existing_prs,$pr_url"
|
|
172
|
+
else
|
|
173
|
+
new_prs="$existing_prs"
|
|
174
|
+
fi
|
|
175
|
+
fi
|
|
176
|
+
update_metadata_key "prs" "$new_prs"
|
|
177
|
+
update_metadata_key "status" "pr_open"
|
|
178
|
+
echo '{"systemMessage": "Updated metadata: PR created at '"$pr_url"'"}'
|
|
179
|
+
exit 0
|
|
180
|
+
fi
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
# Detect: git checkout -b <branch> or git switch -c <branch>
|
|
184
|
+
if [[ "$clean_command" =~ ^git[[:space:]]+checkout[[:space:]]+-b[[:space:]]+([^[:space:]]+) ]] || \\
|
|
185
|
+
[[ "$clean_command" =~ ^git[[:space:]]+switch[[:space:]]+-c[[:space:]]+([^[:space:]]+) ]]; then
|
|
186
|
+
branch="\${BASH_REMATCH[1]}"
|
|
187
|
+
|
|
188
|
+
if [[ -n "$branch" ]]; then
|
|
189
|
+
update_metadata_key "branch" "$branch"
|
|
190
|
+
echo '{"systemMessage": "Updated metadata: branch = '"$branch"'"}'
|
|
191
|
+
exit 0
|
|
192
|
+
fi
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
# Detect: git checkout <branch> (without -b) or git switch <branch> (without -c)
|
|
196
|
+
# Only update if the branch name looks like a feature branch (contains / or -)
|
|
197
|
+
if [[ "$clean_command" =~ ^git[[:space:]]+checkout[[:space:]]+([^[:space:]-]+[/-][^[:space:]]+) ]] || \\
|
|
198
|
+
[[ "$clean_command" =~ ^git[[:space:]]+switch[[:space:]]+([^[:space:]-]+[/-][^[:space:]]+) ]]; then
|
|
199
|
+
branch="\${BASH_REMATCH[1]}"
|
|
200
|
+
|
|
201
|
+
# Avoid updating for checkout of commits/tags
|
|
202
|
+
if [[ -n "$branch" && "$branch" != "HEAD" ]]; then
|
|
203
|
+
update_metadata_key "branch" "$branch"
|
|
204
|
+
echo '{"systemMessage": "Updated metadata: branch = '"$branch"'"}'
|
|
205
|
+
exit 0
|
|
206
|
+
fi
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
# Detect: gh pr merge
|
|
210
|
+
if [[ "$clean_command" =~ ^gh[[:space:]]+pr[[:space:]]+merge ]]; then
|
|
211
|
+
update_metadata_key "status" "merged"
|
|
212
|
+
echo '{"systemMessage": "Updated metadata: status = merged"}'
|
|
213
|
+
exit 0
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
# No matching command, exit silently
|
|
217
|
+
echo '{}'
|
|
218
|
+
exit 0
|
|
219
|
+
`;
|
|
220
|
+
// =============================================================================
|
|
221
|
+
// Metadata Updater Hook Script — Node.js (Windows)
|
|
222
|
+
// =============================================================================
|
|
223
|
+
/**
|
|
224
|
+
* Node.js equivalent of METADATA_UPDATER_SCRIPT for Windows.
|
|
225
|
+
* Reads JSON from stdin, parses it with Node built-ins, and updates the
|
|
226
|
+
* key=value metadata file. No bash, jq, grep, sed, or chmod needed.
|
|
227
|
+
* Exported for testing.
|
|
228
|
+
*/
|
|
229
|
+
export const METADATA_UPDATER_SCRIPT_NODE = `#!/usr/bin/env node
|
|
230
|
+
// Metadata Updater Hook for Athene (Node.js — Windows)
|
|
231
|
+
//
|
|
232
|
+
// This PostToolUse hook automatically updates session metadata when:
|
|
233
|
+
// - gh pr create: extracts PR URL and writes to metadata
|
|
234
|
+
// - git checkout -b / git switch -c: extracts branch name and writes to metadata
|
|
235
|
+
// - gh pr merge: updates status to "merged"
|
|
236
|
+
|
|
237
|
+
const { readFileSync, writeFileSync, renameSync, existsSync, realpathSync } = require("node:fs");
|
|
238
|
+
const { join, sep, resolve: resolvePath } = require("node:path");
|
|
239
|
+
const os = require("node:os");
|
|
240
|
+
|
|
241
|
+
const AO_DATA_DIR = process.env.AO_DATA_DIR || join(process.env.HOME || process.env.USERPROFILE || "", ".ao-sessions");
|
|
242
|
+
const AO_SESSION = process.env.AO_SESSION || "";
|
|
243
|
+
|
|
244
|
+
// Read hook input from stdin (fd 0 is cross-platform, no /dev/stdin needed)
|
|
245
|
+
let inputRaw = "";
|
|
246
|
+
try {
|
|
247
|
+
inputRaw = readFileSync(0, "utf-8");
|
|
248
|
+
} catch {
|
|
249
|
+
inputRaw = "";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let input;
|
|
253
|
+
try {
|
|
254
|
+
input = JSON.parse(inputRaw || "{}");
|
|
255
|
+
} catch {
|
|
256
|
+
process.stdout.write("{}\\n");
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const toolName = input.tool_name || "";
|
|
261
|
+
const command = (input.tool_input && input.tool_input.command) || "";
|
|
262
|
+
const output = input.tool_response || "";
|
|
263
|
+
const exitCode = typeof input.exit_code === "number" ? input.exit_code : 0;
|
|
264
|
+
|
|
265
|
+
// Only process successful commands
|
|
266
|
+
if (exitCode !== 0) {
|
|
267
|
+
process.stdout.write("{}\\n");
|
|
268
|
+
process.exit(0);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Only process Bash tool calls
|
|
272
|
+
if (toolName !== "Bash") {
|
|
273
|
+
process.stdout.write("{}\\n");
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Validate AO_SESSION is set
|
|
278
|
+
if (!AO_SESSION) {
|
|
279
|
+
process.stdout.write(JSON.stringify({ systemMessage: "AO_SESSION environment variable not set, skipping metadata update" }) + "\\n");
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Validate AO_SESSION contains no path traversal components
|
|
284
|
+
if (AO_SESSION.includes("/") || AO_SESSION.includes("\\\\") || AO_SESSION.includes("..")) {
|
|
285
|
+
process.stdout.write(JSON.stringify({ systemMessage: "AO_SESSION contains invalid path characters, skipping metadata update" }) + "\\n");
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Validate AO_DATA_DIR is within an allowed base directory (mirrors ao-metadata-helper.sh)
|
|
290
|
+
const home = os.homedir();
|
|
291
|
+
let resolvedAoDir;
|
|
292
|
+
try { resolvedAoDir = realpathSync(AO_DATA_DIR); } catch { resolvedAoDir = resolvePath(AO_DATA_DIR); }
|
|
293
|
+
const allowedBases = [join(home, ".ao"), join(home, ".agent-orchestrator"), os.tmpdir()];
|
|
294
|
+
if (!allowedBases.some((a) => resolvedAoDir === a || resolvedAoDir.startsWith(a + sep))) {
|
|
295
|
+
process.stdout.write(JSON.stringify({ systemMessage: "AO_DATA_DIR is outside allowed directories, skipping metadata update" }) + "\\n");
|
|
296
|
+
process.exit(0);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const metadataFile = join(AO_DATA_DIR, AO_SESSION);
|
|
300
|
+
|
|
301
|
+
if (!existsSync(metadataFile)) {
|
|
302
|
+
process.stdout.write(JSON.stringify({ systemMessage: "Metadata file not found: " + metadataFile }) + "\\n");
|
|
303
|
+
process.exit(0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Update or append a key=value line in the metadata file (atomic via temp file).
|
|
308
|
+
*/
|
|
309
|
+
function updateMetadataKey(key, value) {
|
|
310
|
+
const lines = readFileSync(metadataFile, "utf-8").split("\\n");
|
|
311
|
+
let found = false;
|
|
312
|
+
const updated = lines.map((line) => {
|
|
313
|
+
if (line.startsWith(key + "=")) {
|
|
314
|
+
found = true;
|
|
315
|
+
return key + "=" + value;
|
|
316
|
+
}
|
|
317
|
+
return line;
|
|
318
|
+
});
|
|
319
|
+
if (!found) {
|
|
320
|
+
// Insert before the trailing empty line (if any) so the file ends cleanly
|
|
321
|
+
updated.push(key + "=" + value);
|
|
322
|
+
}
|
|
323
|
+
const tmpFile = metadataFile + ".tmp." + process.pid;
|
|
324
|
+
writeFileSync(tmpFile, updated.join("\\n"), "utf-8");
|
|
325
|
+
renameSync(tmpFile, metadataFile);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Strip leading cd ... && / cd ... ; prefixes (agents frequently cd into a
|
|
329
|
+
// worktree before running the real command)
|
|
330
|
+
let cleanCommand = command;
|
|
331
|
+
const cdPrefixRe = /^\\s*cd\\s+\\S.*?\\s+(?:&&|;)\\s+(.*)/;
|
|
332
|
+
let m;
|
|
333
|
+
while ((m = cdPrefixRe.exec(cleanCommand)) !== null && /^\\s*cd\\s/.test(cleanCommand)) {
|
|
334
|
+
cleanCommand = m[1];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Detect: gh pr create
|
|
338
|
+
if (/^gh\\s+pr\\s+create/.test(cleanCommand)) {
|
|
339
|
+
const prMatch = output.match(/https:\\/\\/github[.]com\\/[^/]+\\/[^/]+\\/pull\\/\\d+/);
|
|
340
|
+
if (prMatch) {
|
|
341
|
+
const prUrl = prMatch[0];
|
|
342
|
+
let existingPrs = "";
|
|
343
|
+
try {
|
|
344
|
+
const raw = readFileSync(metadataFile, "utf-8");
|
|
345
|
+
if (metadataFile.endsWith(".json")) {
|
|
346
|
+
existingPrs = JSON.parse(raw).prs || "";
|
|
347
|
+
} else {
|
|
348
|
+
const prsLine = raw.split("\\n").find((l) => l.startsWith("prs="));
|
|
349
|
+
existingPrs = prsLine ? prsLine.slice(4) : "";
|
|
350
|
+
}
|
|
351
|
+
} catch {}
|
|
352
|
+
const newPrs = !existingPrs
|
|
353
|
+
? prUrl
|
|
354
|
+
: existingPrs.split(",").map((u) => u.trim()).includes(prUrl)
|
|
355
|
+
? existingPrs
|
|
356
|
+
: existingPrs + "," + prUrl;
|
|
357
|
+
updateMetadataKey("pr", prUrl);
|
|
358
|
+
updateMetadataKey("prs", newPrs);
|
|
359
|
+
updateMetadataKey("status", "pr_open");
|
|
360
|
+
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: PR created at " + prUrl }) + "\\n");
|
|
361
|
+
process.exit(0);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Detect: git checkout -b <branch> or git switch -c <branch>
|
|
366
|
+
const checkoutNewBranch = cleanCommand.match(/^git\\s+checkout\\s+-b\\s+(\\S+)/) ||
|
|
367
|
+
cleanCommand.match(/^git\\s+switch\\s+-c\\s+(\\S+)/);
|
|
368
|
+
if (checkoutNewBranch) {
|
|
369
|
+
const branch = checkoutNewBranch[1];
|
|
370
|
+
if (branch) {
|
|
371
|
+
updateMetadataKey("branch", branch);
|
|
372
|
+
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: branch = " + branch }) + "\\n");
|
|
373
|
+
process.exit(0);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Detect: git checkout <branch> or git switch <branch> (without -b/-c)
|
|
378
|
+
// Only update if branch looks like a feature branch (contains / or -)
|
|
379
|
+
const checkoutBranch = cleanCommand.match(/^git\\s+checkout\\s+([^\\s-]+[/-][^\\s]+)/) ||
|
|
380
|
+
cleanCommand.match(/^git\\s+switch\\s+([^\\s-]+[/-][^\\s]+)/);
|
|
381
|
+
if (checkoutBranch) {
|
|
382
|
+
const branch = checkoutBranch[1];
|
|
383
|
+
if (branch && branch !== "HEAD") {
|
|
384
|
+
updateMetadataKey("branch", branch);
|
|
385
|
+
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: branch = " + branch }) + "\\n");
|
|
386
|
+
process.exit(0);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Detect: gh pr merge
|
|
391
|
+
if (/^gh\\s+pr\\s+merge/.test(cleanCommand)) {
|
|
392
|
+
updateMetadataKey("status", "merged");
|
|
393
|
+
process.stdout.write(JSON.stringify({ systemMessage: "Updated metadata: status = merged" }) + "\\n");
|
|
394
|
+
process.exit(0);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// No matching command
|
|
398
|
+
process.stdout.write("{}\\n");
|
|
399
|
+
process.exit(0);
|
|
400
|
+
`;
|
|
401
|
+
// =============================================================================
|
|
402
|
+
// Activity Updater Hook Script
|
|
403
|
+
// =============================================================================
|
|
404
|
+
/**
|
|
405
|
+
* Bash hook script that translates Claude Code lifecycle hooks into AO activity
|
|
406
|
+
* JSONL entries. Registered on every event whose firing carries activity
|
|
407
|
+
* information (SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
|
|
408
|
+
* PermissionRequest, Notification, Stop, SubagentStop, StopFailure, PreCompact,
|
|
409
|
+
* PostCompact, SubagentStart, PostToolBatch).
|
|
410
|
+
*
|
|
411
|
+
* Reads the JSON payload from stdin, parses `hook_event_name`, maps it to an
|
|
412
|
+
* activity state, and appends a single JSONL entry to
|
|
413
|
+
* `$CLAUDE_PROJECT_DIR/.ao/activity.jsonl` with `source: "hook"`.
|
|
414
|
+
*
|
|
415
|
+
* Notification is filtered by `notification_type` — only `permission_prompt`
|
|
416
|
+
* and `idle_prompt` map to `waiting_input`; `auth_success`/`elicitation_*` etc.
|
|
417
|
+
* are skipped because they don't represent a stuck-on-the-user transition.
|
|
418
|
+
*
|
|
419
|
+
* The script always exits 0 (never blocks Claude). Unknown events exit
|
|
420
|
+
* silently. Exported for integration testing.
|
|
421
|
+
*/
|
|
422
|
+
export const ACTIVITY_UPDATER_SCRIPT = `#!/usr/bin/env bash
|
|
423
|
+
# Activity Updater Hook for Athene
|
|
424
|
+
#
|
|
425
|
+
# Records Claude Code lifecycle events to {workspace}/.ao/activity.jsonl so
|
|
426
|
+
# the dashboard / lifecycle reducer derives activity state from authoritative
|
|
427
|
+
# platform events instead of regex over rendered terminal output. (#1941)
|
|
428
|
+
|
|
429
|
+
set -uo pipefail
|
|
430
|
+
|
|
431
|
+
input=$(cat)
|
|
432
|
+
|
|
433
|
+
if command -v jq &>/dev/null; then
|
|
434
|
+
event=$(printf '%s' "$input" | jq -r '.hook_event_name // empty')
|
|
435
|
+
notif_type=$(printf '%s' "$input" | jq -r '.notification_type // empty')
|
|
436
|
+
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty')
|
|
437
|
+
error_type=$(printf '%s' "$input" | jq -r '.error_type // empty')
|
|
438
|
+
else
|
|
439
|
+
event=$(printf '%s' "$input" | grep -o '"hook_event_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
|
440
|
+
notif_type=$(printf '%s' "$input" | grep -o '"notification_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
|
441
|
+
tool_name=$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
|
442
|
+
error_type=$(printf '%s' "$input" | grep -o '"error_type"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4)
|
|
443
|
+
fi
|
|
444
|
+
|
|
445
|
+
state=""
|
|
446
|
+
trigger=""
|
|
447
|
+
case "$event" in
|
|
448
|
+
SessionStart|Stop|SubagentStop)
|
|
449
|
+
state="ready"
|
|
450
|
+
trigger="$event"
|
|
451
|
+
;;
|
|
452
|
+
UserPromptSubmit|PreToolUse|PostToolUse|PostToolUseFailure|PreCompact|PostCompact|SubagentStart|PostToolBatch)
|
|
453
|
+
state="active"
|
|
454
|
+
trigger="$event"
|
|
455
|
+
;;
|
|
456
|
+
PermissionRequest)
|
|
457
|
+
state="waiting_input"
|
|
458
|
+
if [[ -n "$tool_name" ]]; then
|
|
459
|
+
trigger="PermissionRequest ($tool_name)"
|
|
460
|
+
else
|
|
461
|
+
trigger="PermissionRequest"
|
|
462
|
+
fi
|
|
463
|
+
;;
|
|
464
|
+
Notification)
|
|
465
|
+
if [[ "$notif_type" == "permission_prompt" || "$notif_type" == "idle_prompt" ]]; then
|
|
466
|
+
state="waiting_input"
|
|
467
|
+
trigger="Notification ($notif_type)"
|
|
468
|
+
else
|
|
469
|
+
# auth_success / elicitation_* / unrecognized — not an activity transition
|
|
470
|
+
echo '{}'
|
|
471
|
+
exit 0
|
|
472
|
+
fi
|
|
473
|
+
;;
|
|
474
|
+
StopFailure)
|
|
475
|
+
state="blocked"
|
|
476
|
+
if [[ -n "$error_type" ]]; then
|
|
477
|
+
trigger="StopFailure ($error_type)"
|
|
478
|
+
else
|
|
479
|
+
trigger="StopFailure"
|
|
480
|
+
fi
|
|
481
|
+
;;
|
|
482
|
+
*)
|
|
483
|
+
echo '{}'
|
|
484
|
+
exit 0
|
|
485
|
+
;;
|
|
486
|
+
esac
|
|
487
|
+
|
|
488
|
+
workspace="\${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
489
|
+
log_dir="$workspace/.ao"
|
|
490
|
+
log_file="$log_dir/activity.jsonl"
|
|
491
|
+
|
|
492
|
+
mkdir -p "$log_dir" 2>/dev/null || { echo '{}'; exit 0; }
|
|
493
|
+
|
|
494
|
+
# Node is a hard runtime dep of Claude Code, so node -p is always available
|
|
495
|
+
# and gives millisecond-precision ISO timestamps matching the rest of the
|
|
496
|
+
# activity-JSONL log. Fall back to seconds-precision date for the unlikely
|
|
497
|
+
# case where node is unavailable (still valid ISO 8601).
|
|
498
|
+
ts=$(node -p 'new Date().toISOString()' 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
499
|
+
|
|
500
|
+
# Escape JSON-special characters in the trigger value. Triggers are bounded
|
|
501
|
+
# today to event/tool/error names (no control chars in practice) but escape
|
|
502
|
+
# defensively — \\ and " for content, plus the five common control chars
|
|
503
|
+
# (\\n \\r \\t \\b \\f) so the JSONL line stays parseable for any future
|
|
504
|
+
# trigger source. Matches what Node's JSON.stringify produces in the .cjs
|
|
505
|
+
# variant so both implementations stay in lockstep.
|
|
506
|
+
escape_json() {
|
|
507
|
+
local s="$1"
|
|
508
|
+
s="\${s//\\\\/\\\\\\\\}"
|
|
509
|
+
s="\${s//\\"/\\\\\\"}"
|
|
510
|
+
s="\${s//$'\\n'/\\\\n}"
|
|
511
|
+
s="\${s//$'\\r'/\\\\r}"
|
|
512
|
+
s="\${s//$'\\t'/\\\\t}"
|
|
513
|
+
s="\${s//$'\\b'/\\\\b}"
|
|
514
|
+
s="\${s//$'\\f'/\\\\f}"
|
|
515
|
+
printf '%s' "$s"
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if [[ "$state" == "waiting_input" || "$state" == "blocked" ]]; then
|
|
519
|
+
esc_trigger=$(escape_json "$trigger")
|
|
520
|
+
printf '{"ts":"%s","state":"%s","source":"hook","trigger":"%s"}\\n' "$ts" "$state" "$esc_trigger" >> "$log_file"
|
|
521
|
+
else
|
|
522
|
+
printf '{"ts":"%s","state":"%s","source":"hook"}\\n' "$ts" "$state" >> "$log_file"
|
|
523
|
+
fi
|
|
524
|
+
|
|
525
|
+
echo '{}'
|
|
526
|
+
exit 0
|
|
527
|
+
`;
|
|
528
|
+
/**
|
|
529
|
+
* Node.js equivalent of ACTIVITY_UPDATER_SCRIPT for Windows. No bash, no jq,
|
|
530
|
+
* no shebang interpretation; relies only on Node built-ins. Exported for
|
|
531
|
+
* testing.
|
|
532
|
+
*/
|
|
533
|
+
export const ACTIVITY_UPDATER_SCRIPT_NODE = `#!/usr/bin/env node
|
|
534
|
+
// Activity Updater Hook for Athene (Node.js — Windows). See
|
|
535
|
+
// ACTIVITY_UPDATER_SCRIPT for the canonical bash version. (#1941)
|
|
536
|
+
|
|
537
|
+
const { appendFileSync, mkdirSync, readFileSync } = require("node:fs");
|
|
538
|
+
const { join } = require("node:path");
|
|
539
|
+
|
|
540
|
+
let inputRaw = "";
|
|
541
|
+
try {
|
|
542
|
+
inputRaw = readFileSync(0, "utf-8");
|
|
543
|
+
} catch {
|
|
544
|
+
process.stdout.write("{}\\n");
|
|
545
|
+
process.exit(0);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let payload;
|
|
549
|
+
try {
|
|
550
|
+
payload = JSON.parse(inputRaw || "{}");
|
|
551
|
+
} catch {
|
|
552
|
+
process.stdout.write("{}\\n");
|
|
553
|
+
process.exit(0);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const event = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
|
557
|
+
const notifType = typeof payload.notification_type === "string" ? payload.notification_type : "";
|
|
558
|
+
const toolName = typeof payload.tool_name === "string" ? payload.tool_name : "";
|
|
559
|
+
const errorType = typeof payload.error_type === "string" ? payload.error_type : "";
|
|
560
|
+
|
|
561
|
+
let state = "";
|
|
562
|
+
let trigger = "";
|
|
563
|
+
switch (event) {
|
|
564
|
+
case "SessionStart":
|
|
565
|
+
case "Stop":
|
|
566
|
+
case "SubagentStop":
|
|
567
|
+
state = "ready";
|
|
568
|
+
trigger = event;
|
|
569
|
+
break;
|
|
570
|
+
case "UserPromptSubmit":
|
|
571
|
+
case "PreToolUse":
|
|
572
|
+
case "PostToolUse":
|
|
573
|
+
case "PostToolUseFailure":
|
|
574
|
+
case "PreCompact":
|
|
575
|
+
case "PostCompact":
|
|
576
|
+
case "SubagentStart":
|
|
577
|
+
case "PostToolBatch":
|
|
578
|
+
state = "active";
|
|
579
|
+
trigger = event;
|
|
580
|
+
break;
|
|
581
|
+
case "PermissionRequest":
|
|
582
|
+
state = "waiting_input";
|
|
583
|
+
trigger = toolName ? \`PermissionRequest (\${toolName})\` : "PermissionRequest";
|
|
584
|
+
break;
|
|
585
|
+
case "Notification":
|
|
586
|
+
if (notifType === "permission_prompt" || notifType === "idle_prompt") {
|
|
587
|
+
state = "waiting_input";
|
|
588
|
+
trigger = \`Notification (\${notifType})\`;
|
|
589
|
+
} else {
|
|
590
|
+
process.stdout.write("{}\\n");
|
|
591
|
+
process.exit(0);
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
594
|
+
case "StopFailure":
|
|
595
|
+
state = "blocked";
|
|
596
|
+
trigger = errorType ? \`StopFailure (\${errorType})\` : "StopFailure";
|
|
597
|
+
break;
|
|
598
|
+
default:
|
|
599
|
+
process.stdout.write("{}\\n");
|
|
600
|
+
process.exit(0);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const workspace = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
604
|
+
const logDir = join(workspace, ".ao");
|
|
605
|
+
const logFile = join(logDir, "activity.jsonl");
|
|
606
|
+
|
|
607
|
+
try {
|
|
608
|
+
mkdirSync(logDir, { recursive: true });
|
|
609
|
+
} catch {
|
|
610
|
+
process.stdout.write("{}\\n");
|
|
611
|
+
process.exit(0);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const ts = new Date().toISOString();
|
|
615
|
+
const entry =
|
|
616
|
+
state === "waiting_input" || state === "blocked"
|
|
617
|
+
? { ts, state, source: "hook", trigger }
|
|
618
|
+
: { ts, state, source: "hook" };
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
appendFileSync(logFile, JSON.stringify(entry) + "\\n", "utf-8");
|
|
622
|
+
} catch {
|
|
623
|
+
// Best-effort — never block Claude on log append failure
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
process.stdout.write("{}\\n");
|
|
627
|
+
process.exit(0);
|
|
628
|
+
`;
|
|
629
|
+
// =============================================================================
|
|
630
|
+
// Plugin Manifest
|
|
631
|
+
// =============================================================================
|
|
632
|
+
export const manifest = {
|
|
633
|
+
name: "claude-code",
|
|
634
|
+
slot: "agent",
|
|
635
|
+
description: "Agent plugin: Claude Code CLI",
|
|
636
|
+
version: "0.1.0",
|
|
637
|
+
displayName: "Claude Code",
|
|
638
|
+
};
|
|
639
|
+
/**
|
|
640
|
+
* Read only the last chunk of a JSONL file to extract the last entry's type
|
|
641
|
+
* and the file's modification time. This is optimized for polling — it avoids
|
|
642
|
+
* reading the entire file (which `getSessionInfo()` does for full cost/summary).
|
|
643
|
+
* Now uses the shared readLastJsonlEntry utility from @made-by-moonlight/athene-core.
|
|
644
|
+
*/
|
|
645
|
+
/**
|
|
646
|
+
* Parse only the last `maxBytes` of a JSONL file.
|
|
647
|
+
* Summaries and recent activity are always near the end, so reading the whole
|
|
648
|
+
* file (which can be 100MB+) is wasteful. For files smaller than maxBytes,
|
|
649
|
+
* readFile is used directly. For large files, only the tail is read via a
|
|
650
|
+
* file handle to avoid loading the entire file into memory.
|
|
651
|
+
*/
|
|
652
|
+
async function parseJsonlFileTail(filePath, maxBytes = 131_072) {
|
|
653
|
+
let content;
|
|
654
|
+
let offset;
|
|
655
|
+
try {
|
|
656
|
+
const { size = 0 } = await stat(filePath);
|
|
657
|
+
offset = Math.max(0, size - maxBytes);
|
|
658
|
+
if (offset === 0) {
|
|
659
|
+
// Small file (or unknown size) — read it whole
|
|
660
|
+
content = await readFile(filePath, "utf-8");
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
// Large file — read only the tail via a file handle
|
|
664
|
+
const handle = await open(filePath, "r");
|
|
665
|
+
try {
|
|
666
|
+
const length = size - offset;
|
|
667
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
668
|
+
await handle.read(buffer, 0, length, offset);
|
|
669
|
+
content = buffer.toString("utf-8");
|
|
670
|
+
}
|
|
671
|
+
finally {
|
|
672
|
+
await handle.close();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
return [];
|
|
678
|
+
}
|
|
679
|
+
// Skip potentially truncated first line only when we started mid-file.
|
|
680
|
+
// If offset === 0 we read from the start so the first line is complete.
|
|
681
|
+
const firstNewline = content.indexOf("\n");
|
|
682
|
+
const safeContent = offset > 0 && firstNewline >= 0 ? content.slice(firstNewline + 1) : content;
|
|
683
|
+
const lines = [];
|
|
684
|
+
for (const line of safeContent.split("\n")) {
|
|
685
|
+
const trimmed = line.trim();
|
|
686
|
+
if (!trimmed)
|
|
687
|
+
continue;
|
|
688
|
+
try {
|
|
689
|
+
const parsed = JSON.parse(trimmed);
|
|
690
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
691
|
+
lines.push(parsed);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
// Skip malformed lines
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return lines;
|
|
699
|
+
}
|
|
700
|
+
/** Extract auto-generated summary from JSONL (last "summary" type entry) */
|
|
701
|
+
function extractSummary(lines) {
|
|
702
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
703
|
+
const line = lines[i];
|
|
704
|
+
if (line?.type === "summary" && line.summary) {
|
|
705
|
+
return { summary: line.summary, isFallback: false };
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
// Fallback: first user message truncated to 120 chars
|
|
709
|
+
for (const line of lines) {
|
|
710
|
+
if (line?.type === "user" &&
|
|
711
|
+
line.message?.content &&
|
|
712
|
+
typeof line.message.content === "string") {
|
|
713
|
+
const msg = line.message.content.trim();
|
|
714
|
+
if (msg.length > 0) {
|
|
715
|
+
return {
|
|
716
|
+
summary: msg.length > 120 ? msg.substring(0, 120) + "..." : msg,
|
|
717
|
+
isFallback: true,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
/** Aggregate cost estimate from JSONL usage events */
|
|
725
|
+
function extractCost(lines) {
|
|
726
|
+
let inputTokens = 0;
|
|
727
|
+
let outputTokens = 0;
|
|
728
|
+
let cachedReadTokens = 0;
|
|
729
|
+
let cacheCreationTokens = 0;
|
|
730
|
+
let totalCost = 0;
|
|
731
|
+
for (const line of lines) {
|
|
732
|
+
// Handle direct cost fields — prefer costUSD; only use estimatedCostUsd
|
|
733
|
+
// as fallback to avoid double-counting when both are present.
|
|
734
|
+
if (typeof line.costUSD === "number") {
|
|
735
|
+
totalCost += line.costUSD;
|
|
736
|
+
}
|
|
737
|
+
else if (typeof line.estimatedCostUsd === "number") {
|
|
738
|
+
totalCost += line.estimatedCostUsd;
|
|
739
|
+
}
|
|
740
|
+
// Handle token counts — prefer the structured `usage` object when present;
|
|
741
|
+
// only fall back to flat `inputTokens`/`outputTokens` fields to avoid
|
|
742
|
+
// double-counting if a line contains both.
|
|
743
|
+
if (line.usage) {
|
|
744
|
+
inputTokens += line.usage.input_tokens ?? 0;
|
|
745
|
+
cachedReadTokens += line.usage.cache_read_input_tokens ?? 0;
|
|
746
|
+
cacheCreationTokens += line.usage.cache_creation_input_tokens ?? 0;
|
|
747
|
+
outputTokens += line.usage.output_tokens ?? 0;
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
if (typeof line.inputTokens === "number") {
|
|
751
|
+
inputTokens += line.inputTokens;
|
|
752
|
+
}
|
|
753
|
+
if (typeof line.outputTokens === "number") {
|
|
754
|
+
outputTokens += line.outputTokens;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (inputTokens === 0 &&
|
|
759
|
+
outputTokens === 0 &&
|
|
760
|
+
totalCost === 0 &&
|
|
761
|
+
cachedReadTokens === 0 &&
|
|
762
|
+
cacheCreationTokens === 0) {
|
|
763
|
+
return undefined;
|
|
764
|
+
}
|
|
765
|
+
if (totalCost === 0) {
|
|
766
|
+
totalCost =
|
|
767
|
+
(inputTokens / 1_000_000) * 3.0 +
|
|
768
|
+
(outputTokens / 1_000_000) * 15.0 +
|
|
769
|
+
(cachedReadTokens / 1_000_000) * 0.3 +
|
|
770
|
+
(cacheCreationTokens / 1_000_000) * 3.75;
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
inputTokens: inputTokens + cachedReadTokens + cacheCreationTokens,
|
|
774
|
+
outputTokens,
|
|
775
|
+
estimatedCostUsd: totalCost,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Set the registration's hook in the `event`'s hook array, updating any
|
|
780
|
+
* existing entry whose command contains one of `identifiers` (idempotent).
|
|
781
|
+
*
|
|
782
|
+
* Tolerates malformed pre-existing settings: if `hooks[event]` is not an
|
|
783
|
+
* array (object, string, missing) we start a fresh array rather than
|
|
784
|
+
* throwing on `.push`.
|
|
785
|
+
*
|
|
786
|
+
* Only refreshes the entry-level `matcher` when the entry contains a single
|
|
787
|
+
* hook def (ours). When a user has co-located their own hook def in the
|
|
788
|
+
* same `{ matcher, hooks: [...] }` object, we leave their matcher alone and
|
|
789
|
+
* only update our def's `command`/`timeout` so their hook keeps firing on
|
|
790
|
+
* the matchers they chose.
|
|
791
|
+
*/
|
|
792
|
+
function upsertHookEntry(hooks, reg) {
|
|
793
|
+
const existing = hooks[reg.event];
|
|
794
|
+
const entries = Array.isArray(existing) ? existing : [];
|
|
795
|
+
let foundEntryIdx = -1;
|
|
796
|
+
let foundDefIdx = -1;
|
|
797
|
+
for (let i = 0; i < entries.length; i++) {
|
|
798
|
+
const entry = entries[i];
|
|
799
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
800
|
+
continue;
|
|
801
|
+
const hooksList = entry["hooks"];
|
|
802
|
+
if (!Array.isArray(hooksList))
|
|
803
|
+
continue;
|
|
804
|
+
for (let j = 0; j < hooksList.length; j++) {
|
|
805
|
+
const def = hooksList[j];
|
|
806
|
+
if (typeof def !== "object" || def === null || Array.isArray(def))
|
|
807
|
+
continue;
|
|
808
|
+
const cmd = def["command"];
|
|
809
|
+
if (typeof cmd === "string" && reg.identifiers.some((id) => cmd.includes(id))) {
|
|
810
|
+
foundEntryIdx = i;
|
|
811
|
+
foundDefIdx = j;
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (foundEntryIdx >= 0)
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
if (foundEntryIdx === -1) {
|
|
819
|
+
entries.push({
|
|
820
|
+
matcher: reg.matcher,
|
|
821
|
+
hooks: [{ type: "command", command: reg.command, timeout: reg.timeout }],
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
const entry = entries[foundEntryIdx];
|
|
826
|
+
const hooksList = entry["hooks"];
|
|
827
|
+
hooksList[foundDefIdx]["command"] = reg.command;
|
|
828
|
+
hooksList[foundDefIdx]["timeout"] = reg.timeout;
|
|
829
|
+
// Only refresh the matcher when the entry is clearly owned by AO
|
|
830
|
+
// (single hook def == ours). With multiple defs the entry is shared
|
|
831
|
+
// with a user hook; changing the matcher would change when their hook
|
|
832
|
+
// fires.
|
|
833
|
+
if (hooksList.length === 1) {
|
|
834
|
+
entry["matcher"] = reg.matcher;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
hooks[reg.event] = entries;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Build the list of hooks to register for this workspace. Two scripts are
|
|
841
|
+
* installed:
|
|
842
|
+
* - metadata-updater: PostToolUse(Bash) only — extracts gh/git side-effects.
|
|
843
|
+
* - activity-updater: every event that carries activity information, so
|
|
844
|
+
* dashboard / lifecycle reducer state derives from platform events
|
|
845
|
+
* instead of regex over rendered terminal output (#1941).
|
|
846
|
+
*
|
|
847
|
+
* Activity events use matcher "" — match every variant. PermissionRequest's
|
|
848
|
+
* tool-name and Notification's notification_type are filtered inside the
|
|
849
|
+
* script itself so the registered set stays small.
|
|
850
|
+
*/
|
|
851
|
+
function buildHookRegistrations(metadataCommand, activityCommand) {
|
|
852
|
+
const METADATA_IDS = [
|
|
853
|
+
"metadata-updater.sh",
|
|
854
|
+
"metadata-updater.cjs",
|
|
855
|
+
"metadata-updater.js",
|
|
856
|
+
];
|
|
857
|
+
const ACTIVITY_IDS = ["activity-updater.sh", "activity-updater.cjs"];
|
|
858
|
+
const regs = [
|
|
859
|
+
{
|
|
860
|
+
event: "PostToolUse",
|
|
861
|
+
matcher: "Bash",
|
|
862
|
+
command: metadataCommand,
|
|
863
|
+
timeout: 5000,
|
|
864
|
+
identifiers: METADATA_IDS,
|
|
865
|
+
},
|
|
866
|
+
];
|
|
867
|
+
// Activity-updater events. Every event that the activity-updater script
|
|
868
|
+
// knows how to map (see ACTIVITY_UPDATER_SCRIPT) must be registered here;
|
|
869
|
+
// unregistered events fire no hook, so unrecognized hooks waste no time.
|
|
870
|
+
const activityEvents = [
|
|
871
|
+
"SessionStart",
|
|
872
|
+
"UserPromptSubmit",
|
|
873
|
+
"PreToolUse",
|
|
874
|
+
"PostToolUse",
|
|
875
|
+
"PostToolUseFailure",
|
|
876
|
+
"PostToolBatch",
|
|
877
|
+
"Notification",
|
|
878
|
+
"PermissionRequest",
|
|
879
|
+
"Stop",
|
|
880
|
+
"StopFailure",
|
|
881
|
+
"SubagentStart",
|
|
882
|
+
"SubagentStop",
|
|
883
|
+
"PreCompact",
|
|
884
|
+
"PostCompact",
|
|
885
|
+
];
|
|
886
|
+
for (const event of activityEvents) {
|
|
887
|
+
regs.push({
|
|
888
|
+
event,
|
|
889
|
+
matcher: "",
|
|
890
|
+
command: activityCommand,
|
|
891
|
+
// Hook execution is best-effort and the activity-updater is intentionally
|
|
892
|
+
// O(few ms): JSON parse, one append, exit. A short timeout keeps a stuck
|
|
893
|
+
// hook from slowing a turn down.
|
|
894
|
+
timeout: 2000,
|
|
895
|
+
identifiers: ACTIVITY_IDS,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return regs;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Install Claude Code workspace hooks. Writes both helper scripts
|
|
902
|
+
* (metadata-updater + activity-updater) and merges hook registrations into
|
|
903
|
+
* `.claude/settings.json` — preserving any user-installed hooks, updating our
|
|
904
|
+
* own in place on repeated calls.
|
|
905
|
+
*/
|
|
906
|
+
async function setupHookInWorkspace(workspacePath) {
|
|
907
|
+
const claudeDir = join(workspacePath, ".claude");
|
|
908
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
909
|
+
try {
|
|
910
|
+
await mkdir(claudeDir, { recursive: true });
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
// Directory may already exist; ignore
|
|
914
|
+
}
|
|
915
|
+
let metadataCommand;
|
|
916
|
+
let activityCommand;
|
|
917
|
+
if (isWindows()) {
|
|
918
|
+
const metadataPath = join(claudeDir, "metadata-updater.cjs");
|
|
919
|
+
const activityPath = join(claudeDir, "activity-updater.cjs");
|
|
920
|
+
await writeFile(metadataPath, METADATA_UPDATER_SCRIPT_NODE, "utf-8");
|
|
921
|
+
await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT_NODE, "utf-8");
|
|
922
|
+
// .cjs forces CJS regardless of workspace package.json "type"; node
|
|
923
|
+
// invocation is required on Windows because shebangs aren't honoured.
|
|
924
|
+
metadataCommand = "node .claude/metadata-updater.cjs";
|
|
925
|
+
activityCommand = "node .claude/activity-updater.cjs";
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
const metadataPath = join(claudeDir, "metadata-updater.sh");
|
|
929
|
+
const activityPath = join(claudeDir, "activity-updater.sh");
|
|
930
|
+
await writeFile(metadataPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
|
931
|
+
await writeFile(activityPath, ACTIVITY_UPDATER_SCRIPT, "utf-8");
|
|
932
|
+
await chmod(metadataPath, 0o755);
|
|
933
|
+
await chmod(activityPath, 0o755);
|
|
934
|
+
metadataCommand = ".claude/metadata-updater.sh";
|
|
935
|
+
activityCommand = ".claude/activity-updater.sh";
|
|
936
|
+
}
|
|
937
|
+
let existingSettings = {};
|
|
938
|
+
if (existsSync(settingsPath)) {
|
|
939
|
+
try {
|
|
940
|
+
const content = await readFile(settingsPath, "utf-8");
|
|
941
|
+
existingSettings = JSON.parse(content);
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
// Invalid JSON or read error — start fresh
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
const hooks = existingSettings["hooks"] ?? {};
|
|
948
|
+
for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) {
|
|
949
|
+
upsertHookEntry(hooks, reg);
|
|
950
|
+
}
|
|
951
|
+
existingSettings["hooks"] = hooks;
|
|
952
|
+
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
|
|
953
|
+
}
|
|
954
|
+
// =============================================================================
|
|
955
|
+
// Agent Implementation
|
|
956
|
+
// =============================================================================
|
|
957
|
+
function createClaudeCodeAgent() {
|
|
958
|
+
return {
|
|
959
|
+
name: "claude-code",
|
|
960
|
+
processName: "claude",
|
|
961
|
+
getLaunchCommand(config) {
|
|
962
|
+
// Note: CLAUDECODE is unset via getEnvironment() (set to ""), not here.
|
|
963
|
+
// This command must be safe for both shell and execFile contexts.
|
|
964
|
+
const parts = ["claude"];
|
|
965
|
+
const permissionMode = normalizeAgentPermissionMode(config.permissions);
|
|
966
|
+
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
|
|
967
|
+
parts.push("--dangerously-skip-permissions");
|
|
968
|
+
}
|
|
969
|
+
if (config.model) {
|
|
970
|
+
parts.push("--model", shellEscape(config.model));
|
|
971
|
+
}
|
|
972
|
+
if (config.systemPromptFile) {
|
|
973
|
+
if (isWindows()) {
|
|
974
|
+
// Windows: $(cat ...) is bash syntax, not understood by PowerShell/cmd.exe.
|
|
975
|
+
// Read the file synchronously and inline the content instead.
|
|
976
|
+
const content = readFileSync(config.systemPromptFile, "utf-8");
|
|
977
|
+
parts.push("--append-system-prompt", shellEscape(content));
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
// Unix: use shell command substitution to read from file at launch time.
|
|
981
|
+
// This avoids tmux truncation when inlining 2000+ char prompts.
|
|
982
|
+
// The double quotes allow $() expansion; inner path is single-quoted for safety.
|
|
983
|
+
parts.push("--append-system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
else if (config.systemPrompt) {
|
|
987
|
+
parts.push("--append-system-prompt", shellEscape(config.systemPrompt));
|
|
988
|
+
}
|
|
989
|
+
// The positional [prompt] argument auto-submits as the first user turn
|
|
990
|
+
// and keeps Claude in interactive mode. -p / --print is what triggers
|
|
991
|
+
// headless one-shot exit, not the presence of a prompt.
|
|
992
|
+
if (config.prompt) {
|
|
993
|
+
parts.push("--", shellEscape(config.prompt));
|
|
994
|
+
}
|
|
995
|
+
return parts.join(" ");
|
|
996
|
+
},
|
|
997
|
+
getEnvironment(config) {
|
|
998
|
+
const env = {};
|
|
999
|
+
// Unset CLAUDECODE to avoid nested agent conflicts
|
|
1000
|
+
env["CLAUDECODE"] = "";
|
|
1001
|
+
// Set session info for introspection
|
|
1002
|
+
env["AO_SESSION_ID"] = config.sessionId;
|
|
1003
|
+
// NOTE: AO_PROJECT_ID is NOT set here - it's the caller's responsibility
|
|
1004
|
+
// to set it based on their metadata path scheme:
|
|
1005
|
+
// - spawn.ts sets it to projectId for project-specific directories
|
|
1006
|
+
// - start.ts omits it for orchestrator (flat directories)
|
|
1007
|
+
// - session manager omits it (flat directories)
|
|
1008
|
+
if (config.issueId) {
|
|
1009
|
+
env["AO_ISSUE_ID"] = config.issueId;
|
|
1010
|
+
}
|
|
1011
|
+
return env;
|
|
1012
|
+
},
|
|
1013
|
+
detectActivity(terminalOutput) {
|
|
1014
|
+
// #1941: Claude activity is derived from platform-event hooks
|
|
1015
|
+
// (PermissionRequest / StopFailure / Notification / Stop / ...) which
|
|
1016
|
+
// write directly to {workspace}/.ao/activity.jsonl. The terminal-regex
|
|
1017
|
+
// layer was structurally fragile (every UI tweak in Claude regressed
|
|
1018
|
+
// it; see the 15-commit churn in #1932) so it has been retired in
|
|
1019
|
+
// favour of those authoritative events.
|
|
1020
|
+
//
|
|
1021
|
+
// detectActivity is kept on the Agent interface for other plugins
|
|
1022
|
+
// (Aider, OpenCode, Codex fallback) that still rely on terminal output.
|
|
1023
|
+
// For Claude, classifyTerminalOutput is a stable "idle" stub — the
|
|
1024
|
+
// lifecycle manager only consults this method when getActivityState
|
|
1025
|
+
// returned null (no Claude process / no JSONL / no hook entry yet),
|
|
1026
|
+
// and in that no-signal case "idle" is the correct conservative
|
|
1027
|
+
// answer (we don't write it back to JSONL — recordActivity is also
|
|
1028
|
+
// intentionally omitted for Claude).
|
|
1029
|
+
return classifyTerminalOutput(terminalOutput);
|
|
1030
|
+
},
|
|
1031
|
+
// recordActivity is intentionally NOT implemented for the Claude agent
|
|
1032
|
+
// (#1941). Hooks write activity entries directly via the activity-updater
|
|
1033
|
+
// script, so polling-driven terminal-output classification would only add
|
|
1034
|
+
// stale duplicates to .ao/activity.jsonl.
|
|
1035
|
+
async isProcessRunning(handle) {
|
|
1036
|
+
return isClaudeProcessAlive(handle);
|
|
1037
|
+
},
|
|
1038
|
+
async getActivityState(session, readyThresholdMs) {
|
|
1039
|
+
return getClaudeActivityState(session, readyThresholdMs, (handle) => this.isProcessRunning(handle));
|
|
1040
|
+
},
|
|
1041
|
+
async getSessionInfo(session) {
|
|
1042
|
+
if (!session.workspacePath)
|
|
1043
|
+
return null;
|
|
1044
|
+
// Build the Claude project directory path
|
|
1045
|
+
const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath));
|
|
1046
|
+
const projectDir = join(homedir(), ".claude", "projects", projectPath);
|
|
1047
|
+
// Find the latest session JSONL file
|
|
1048
|
+
const sessionFile = await findLatestSessionFile(projectDir);
|
|
1049
|
+
if (!sessionFile)
|
|
1050
|
+
return null;
|
|
1051
|
+
// Parse only the tail — summaries are always near the end, files can be 100MB+
|
|
1052
|
+
const lines = await parseJsonlFileTail(sessionFile);
|
|
1053
|
+
if (lines.length === 0)
|
|
1054
|
+
return null;
|
|
1055
|
+
// Extract session ID from filename
|
|
1056
|
+
const agentSessionId = basename(sessionFile, ".jsonl");
|
|
1057
|
+
const summaryResult = extractSummary(lines);
|
|
1058
|
+
return {
|
|
1059
|
+
summary: summaryResult?.summary ?? null,
|
|
1060
|
+
summaryIsFallback: summaryResult?.isFallback,
|
|
1061
|
+
agentSessionId,
|
|
1062
|
+
metadata: { claudeSessionUuid: agentSessionId },
|
|
1063
|
+
cost: extractCost(lines),
|
|
1064
|
+
};
|
|
1065
|
+
},
|
|
1066
|
+
async getRestoreCommand(session, project) {
|
|
1067
|
+
let sessionUuid = session.metadata?.["claudeSessionUuid"]?.trim();
|
|
1068
|
+
if (!sessionUuid) {
|
|
1069
|
+
if (!session.workspacePath)
|
|
1070
|
+
return null;
|
|
1071
|
+
// Find Claude's project directory for this workspace
|
|
1072
|
+
const projectPath = toClaudeProjectPath(await resolveWorkspaceForClaude(session.workspacePath));
|
|
1073
|
+
const projectDir = join(homedir(), ".claude", "projects", projectPath);
|
|
1074
|
+
// Find the latest session JSONL file
|
|
1075
|
+
const sessionFile = await findLatestSessionFile(projectDir);
|
|
1076
|
+
if (!sessionFile)
|
|
1077
|
+
return null;
|
|
1078
|
+
// Extract session UUID from filename (e.g. "abc123-def456.jsonl" → "abc123-def456")
|
|
1079
|
+
sessionUuid = basename(sessionFile, ".jsonl");
|
|
1080
|
+
}
|
|
1081
|
+
if (!sessionUuid)
|
|
1082
|
+
return null;
|
|
1083
|
+
// Build resume command
|
|
1084
|
+
const parts = ["claude", "--resume", shellEscape(sessionUuid)];
|
|
1085
|
+
const permissionMode = normalizeAgentPermissionMode(project.agentConfig?.permissions);
|
|
1086
|
+
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
|
|
1087
|
+
parts.push("--dangerously-skip-permissions");
|
|
1088
|
+
}
|
|
1089
|
+
if (project.agentConfig?.model) {
|
|
1090
|
+
parts.push("--model", shellEscape(project.agentConfig.model));
|
|
1091
|
+
}
|
|
1092
|
+
return parts.join(" ");
|
|
1093
|
+
},
|
|
1094
|
+
async setupWorkspaceHooks(workspacePath, _config) {
|
|
1095
|
+
// Relative path so that symlinked .claude/ dirs across worktrees
|
|
1096
|
+
// all produce the same settings.json (last writer doesn't clobber).
|
|
1097
|
+
await setupHookInWorkspace(workspacePath);
|
|
1098
|
+
},
|
|
1099
|
+
async postLaunchSetup(_session) {
|
|
1100
|
+
// Hooks are installed pre-launch via setupWorkspaceHooks so that
|
|
1101
|
+
// PostToolUse hooks exist before the agent's first tool call.
|
|
1102
|
+
},
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
// =============================================================================
|
|
1106
|
+
// Plugin Export
|
|
1107
|
+
// =============================================================================
|
|
1108
|
+
export function create() {
|
|
1109
|
+
return createClaudeCodeAgent();
|
|
1110
|
+
}
|
|
1111
|
+
export function detect() {
|
|
1112
|
+
try {
|
|
1113
|
+
// Use --version instead of `which` for cross-platform compatibility (Windows has no `which`).
|
|
1114
|
+
// shell:true on Windows so cmd.exe consults PATHEXT and finds .cmd shims (npm-installed CLIs).
|
|
1115
|
+
execFileSync("claude", ["--version"], {
|
|
1116
|
+
stdio: "ignore",
|
|
1117
|
+
shell: isWindows(),
|
|
1118
|
+
windowsHide: true,
|
|
1119
|
+
});
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
export default { manifest, create, detect };
|
|
1127
|
+
//# sourceMappingURL=index.js.map
|