feat: auto-update session metadata via Claude Code hooks (#34)
* feat: auto-update session metadata via Claude Code hooks Implements INT-1355: Auto-update session metadata when agent creates PR or switches branch. ## What Changed - **agent-claude-code plugin**: Added `postLaunchSetup` method that writes a Claude Code hook to `.claude/settings.json` and `.claude/metadata-updater.sh` in each workspace - **session-manager**: Added `AO_DATA_DIR` environment variable to agent sessions - **Hook script**: Monitors Bash commands and automatically updates metadata on: - `gh pr create` → extracts PR URL, sets `pr=<url>` and `status=pr_open` - `git checkout -b` / `git switch -c` → extracts branch name, sets `branch=<name>` - `git checkout` / `git switch` (existing branches) → updates branch name for feature branches - `gh pr merge` → sets `status=merged` ## How It Works 1. When a session is spawned, `postLaunchSetup` runs after the agent launches 2. Creates `.claude/settings.json` with a PostToolUse hook for Bash commands 3. Writes the metadata updater script to `.claude/metadata-updater.sh` 4. The hook fires after every Bash command execution 5. Parses command and output to detect git/gh operations 6. Updates the session metadata file directly using atomic file operations ## Benefits - Dashboard shows correct PR associations immediately without manual intervention - Branch tracking stays in sync with agent actions - Orchestrator has accurate session state for automation and notifications Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: correct hook payload field name and JSON parsing Fixes bugbot issues from PR #34: 1. Changed tool_output to tool_response - Claude Code hooks provide output in the tool_response field, not tool_output. This fixes PR URL extraction from gh pr create commands. 2. Replaced over-escaped sed patterns with cut - The fallback JSON parser now uses 'cut -d\" -f4' instead of sed with capture groups, which is simpler and avoids escaping issues. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: escape special characters in metadata values Fixes bugbot issue: metadata updates break on special branch names. The update_metadata_key function now escapes sed special characters (& | / \) in values before using them in sed replacement. This prevents branch names or PR URLs containing these characters from breaking the metadata update. Example: branch name "feature/foo&bar" now correctly updates metadata instead of causing sed to interpret "&" as a backreference. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: only update metadata on successful commands Fixes bugbot issue: metadata updates ignore command failure. The hook now checks the exit_code field from the hook payload and only updates metadata if the command succeeded (exit code 0). This prevents updating metadata when git/gh commands fail. Example: if 'gh pr create' fails due to auth issues, the metadata won't be incorrectly updated with a pr= line. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: use correct timeout units for Claude Code hook Fixes bugbot issue: hook timeout uses wrong units (High Severity). Claude Code hook timeouts are in milliseconds, not seconds. Changed timeout from 5 to 5000 (5 seconds) to give the metadata updater script enough time to parse tool output and update metadata. Note: The hook command path is properly handled by JSON.stringify and does not need additional escaping as Claude Code handles command execution correctly. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: PR URL regex and stale hook path issues Fixes 2 bugbot issues: 1. PR URL extraction regex never matches (High Severity): Changed 'github\.com' to 'github[.]com' because in single-quoted bash strings, backslashes are literal. The [.] syntax correctly matches a literal dot in grep regex. 2. Shared Claude settings keep stale hook path (Medium Severity): Changed hook detection to always update the command path to the current workspace, not just check for existence. This handles cases where .claude settings are shared/symlinked across workspaces. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
620bad9053
commit
8e5b23e6a0
|
|
@ -279,6 +279,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
|
|||
environment: {
|
||||
...environment,
|
||||
AO_SESSION: sessionId,
|
||||
AO_DATA_DIR: config.dataDir,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,155 @@ import {
|
|||
type Session,
|
||||
} from "@agent-orchestrator/core";
|
||||
import { execFile } from "node:child_process";
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { open, readdir, readFile, stat, writeFile, mkdir, chmod } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// =============================================================================
|
||||
// Metadata Updater Hook Script
|
||||
// =============================================================================
|
||||
|
||||
/** Hook script content that updates session metadata on git/gh commands */
|
||||
const METADATA_UPDATER_SCRIPT = `#!/usr/bin/env bash
|
||||
# Metadata Updater Hook for Agent Orchestrator
|
||||
#
|
||||
# This PostToolUse hook automatically updates session metadata when:
|
||||
# - gh pr create: extracts PR URL and writes to metadata
|
||||
# - git checkout -b / git switch -c: extracts branch name and writes to metadata
|
||||
# - gh pr merge: updates status to "merged"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
AO_DATA_DIR="\${AO_DATA_DIR:-$HOME/.ao-sessions}"
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
|
||||
# Extract fields from JSON (using jq if available, otherwise basic parsing)
|
||||
if command -v jq &>/dev/null; then
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
output=$(echo "$input" | jq -r '.tool_response // empty')
|
||||
exit_code=$(echo "$input" | jq -r '.exit_code // 0')
|
||||
else
|
||||
# Fallback: basic JSON parsing without jq
|
||||
tool_name=$(echo "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4 || echo "")
|
||||
command=$(echo "$input" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4 || echo "")
|
||||
output=$(echo "$input" | grep -o '"tool_response"[[:space:]]*:[[:space:]]*"[^"]*"' | cut -d'"' -f4 || echo "")
|
||||
exit_code=$(echo "$input" | grep -o '"exit_code"[[:space:]]*:[[:space:]]*[0-9]*' | grep -o '[0-9]*$' || echo "0")
|
||||
fi
|
||||
|
||||
# Only process successful commands (exit code 0)
|
||||
if [[ "$exit_code" -ne 0 ]]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only process Bash tool calls
|
||||
if [[ "$tool_name" != "Bash" ]]; then
|
||||
echo '{}' # Empty JSON output
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate AO_SESSION is set
|
||||
if [[ -z "\${AO_SESSION:-}" ]]; then
|
||||
echo '{"systemMessage": "AO_SESSION environment variable not set, skipping metadata update"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
metadata_file="$AO_DATA_DIR/$AO_SESSION"
|
||||
|
||||
# Ensure metadata file exists
|
||||
if [[ ! -f "$metadata_file" ]]; then
|
||||
echo '{"systemMessage": "Metadata file not found: '"$metadata_file"'"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Update a single key in metadata
|
||||
update_metadata_key() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
|
||||
# Create temp file
|
||||
local temp_file="\${metadata_file}.tmp"
|
||||
|
||||
# Escape special sed characters in value (& | / \\)
|
||||
local escaped_value=$(echo "$value" | sed 's/[&|\\/]/\\\\&/g')
|
||||
|
||||
# Check if key already exists
|
||||
if grep -q "^$key=" "$metadata_file" 2>/dev/null; then
|
||||
# Update existing key
|
||||
sed "s|^$key=.*|$key=$escaped_value|" "$metadata_file" > "$temp_file"
|
||||
else
|
||||
# Append new key
|
||||
cp "$metadata_file" "$temp_file"
|
||||
echo "$key=$value" >> "$temp_file"
|
||||
fi
|
||||
|
||||
# Atomic replace
|
||||
mv "$temp_file" "$metadata_file"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Command Detection and Parsing
|
||||
# ============================================================================
|
||||
|
||||
# Detect: gh pr create
|
||||
if [[ "$command" =~ ^gh[[:space:]]+pr[[:space:]]+create ]]; then
|
||||
# Extract PR URL from output
|
||||
pr_url=$(echo "$output" | grep -Eo 'https://github[.]com/[^/]+/[^/]+/pull/[0-9]+' | head -1)
|
||||
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
update_metadata_key "pr" "$pr_url"
|
||||
update_metadata_key "status" "pr_open"
|
||||
echo '{"systemMessage": "Updated metadata: PR created at '"$pr_url"'"}'
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect: git checkout -b <branch> or git switch -c <branch>
|
||||
if [[ "$command" =~ ^git[[:space:]]+checkout[[:space:]]+-b[[:space:]]+([^[:space:]]+) ]] || \\
|
||||
[[ "$command" =~ ^git[[:space:]]+switch[[:space:]]+-c[[:space:]]+([^[:space:]]+) ]]; then
|
||||
branch="\${BASH_REMATCH[1]}"
|
||||
|
||||
if [[ -n "$branch" ]]; then
|
||||
update_metadata_key "branch" "$branch"
|
||||
echo '{"systemMessage": "Updated metadata: branch = '"$branch"'"}'
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect: git checkout <branch> (without -b) or git switch <branch> (without -c)
|
||||
# Only update if the branch name looks like a feature branch (contains / or -)
|
||||
if [[ "$command" =~ ^git[[:space:]]+checkout[[:space:]]+([^[:space:]-]+[/-][^[:space:]]+) ]] || \\
|
||||
[[ "$command" =~ ^git[[:space:]]+switch[[:space:]]+([^[:space:]-]+[/-][^[:space:]]+) ]]; then
|
||||
branch="\${BASH_REMATCH[1]}"
|
||||
|
||||
# Avoid updating for checkout of commits/tags
|
||||
if [[ -n "$branch" && "$branch" != "HEAD" ]]; then
|
||||
update_metadata_key "branch" "$branch"
|
||||
echo '{"systemMessage": "Updated metadata: branch = '"$branch"'"}'
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Detect: gh pr merge
|
||||
if [[ "$command" =~ ^gh[[:space:]]+pr[[:space:]]+merge ]]; then
|
||||
update_metadata_key "status" "merged"
|
||||
echo '{"systemMessage": "Updated metadata: status = merged"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No matching command, exit silently
|
||||
echo '{}'
|
||||
exit 0
|
||||
`;
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest
|
||||
// =============================================================================
|
||||
|
|
@ -462,6 +604,89 @@ function createClaudeCodeAgent(): Agent {
|
|||
lastLogModified,
|
||||
};
|
||||
},
|
||||
|
||||
async postLaunchSetup(session: Session): Promise<void> {
|
||||
if (!session.workspacePath) return;
|
||||
|
||||
// Path to Claude settings directory in workspace
|
||||
const claudeDir = join(session.workspacePath, ".claude");
|
||||
const settingsPath = join(claudeDir, "settings.json");
|
||||
const hookScriptPath = join(claudeDir, "metadata-updater.sh");
|
||||
|
||||
// Create .claude directory if it doesn't exist
|
||||
try {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
|
||||
// Write the metadata updater script to the workspace
|
||||
await writeFile(hookScriptPath, METADATA_UPDATER_SCRIPT, "utf-8");
|
||||
await chmod(hookScriptPath, 0o755); // Make executable
|
||||
|
||||
// Read existing settings if present
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = await readFile(settingsPath, "utf-8");
|
||||
existingSettings = JSON.parse(content) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Invalid JSON or read error — start fresh
|
||||
}
|
||||
}
|
||||
|
||||
// Merge hooks configuration
|
||||
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
|
||||
const postToolUse = (hooks["PostToolUse"] as Array<unknown>) ?? [];
|
||||
|
||||
// Check if our hook is already configured with the correct path
|
||||
let hookIndex = -1;
|
||||
let hookDefIndex = -1;
|
||||
for (let i = 0; i < postToolUse.length; i++) {
|
||||
const hook = postToolUse[i];
|
||||
if (typeof hook !== "object" || hook === null || Array.isArray(hook)) continue;
|
||||
const h = hook as Record<string, unknown>;
|
||||
const hooksList = h["hooks"];
|
||||
if (!Array.isArray(hooksList)) continue;
|
||||
for (let j = 0; j < hooksList.length; j++) {
|
||||
const hDef = hooksList[j];
|
||||
if (typeof hDef !== "object" || hDef === null || Array.isArray(hDef)) continue;
|
||||
const def = hDef as Record<string, unknown>;
|
||||
if (typeof def["command"] === "string" && def["command"].includes("metadata-updater.sh")) {
|
||||
hookIndex = i;
|
||||
hookDefIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hookIndex >= 0) break;
|
||||
}
|
||||
|
||||
// Add or update our hook to ensure it points to the current workspace
|
||||
if (hookIndex === -1) {
|
||||
// No metadata hook exists, add it
|
||||
postToolUse.push({
|
||||
matcher: "Bash",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: hookScriptPath,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
// Hook exists, update the path to current workspace
|
||||
const hook = postToolUse[hookIndex] as Record<string, unknown>;
|
||||
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
|
||||
hooksList[hookDefIndex]["command"] = hookScriptPath;
|
||||
}
|
||||
|
||||
hooks["PostToolUse"] = postToolUse;
|
||||
existingSettings["hooks"] = hooks;
|
||||
|
||||
// Write updated settings
|
||||
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue