@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/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