agent-orchestrator/packages/plugins/agent-claude-code/src/index.ts

869 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
shellEscape,
readLastJsonlEntry,
DEFAULT_READY_THRESHOLD_MS,
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
type ActivityDetection,
type ActivityState,
type CostEstimate,
type PluginModule,
type ProjectConfig,
type RuntimeHandle,
type Session,
type WorkspaceHooksConfig,
} from "@composio/ao-core";
import { execFile, execFileSync } from "node:child_process";
import { readdir, readFile, stat, open, 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);
function normalizePermissionMode(mode: string | undefined): "permissionless" | "default" | "auto-edit" | "suggest" | undefined {
if (!mode) return undefined;
if (mode === "skip") return "permissionless";
if (mode === "permissionless" || mode === "default" || mode === "auto-edit" || mode === "suggest") {
return mode;
}
return undefined;
}
// =============================================================================
// Metadata Updater Hook Script
// =============================================================================
/** Hook script content that updates session metadata on git/gh commands.
* Exported for integration testing. */
export 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
# Construct metadata file path
# AO_DATA_DIR is already set to the project-specific sessions directory
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
# ============================================================================
# Strip leading directory-change prefixes so that commands like
# cd ~/.worktrees/project && gh pr create ...
# are correctly detected. Agents frequently cd into a worktree first.
# Store the regex pattern in a variable for clarity (avoids shell quoting confusion).
# Uses space-padded (&&|;) to avoid breaking on paths containing & or ; chars.
cd_prefix_pattern='^[[:space:]]*cd[[:space:]]+.*[[:space:]]+(&&|;)[[:space:]]+(.*)'
clean_command="$command"
while [[ "$clean_command" =~ ^[[:space:]]*cd[[:space:]] ]]; do
if [[ "$clean_command" =~ $cd_prefix_pattern ]]; then
clean_command="\${BASH_REMATCH[2]}"
else
break
fi
done
# Detect: gh pr create
if [[ "$clean_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 [[ "$clean_command" =~ ^git[[:space:]]+checkout[[:space:]]+-b[[:space:]]+([^[:space:]]+) ]] || \\
[[ "$clean_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 [[ "$clean_command" =~ ^git[[:space:]]+checkout[[:space:]]+([^[:space:]-]+[/-][^[:space:]]+) ]] || \\
[[ "$clean_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 [[ "$clean_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
// =============================================================================
export const manifest = {
name: "claude-code",
slot: "agent" as const,
description: "Agent plugin: Claude Code CLI",
version: "0.1.0",
displayName: "Claude Code",
};
// =============================================================================
// JSONL Helpers
// =============================================================================
/**
* Convert a workspace path to Claude's project directory path.
* Claude stores sessions at ~/.claude/projects/{encoded-path}/
*
* Verified against Claude Code's actual encoding (as of v1.x):
* the path has its leading / stripped, then all / and . are replaced with -.
* e.g. /Users/dev/.worktrees/ao → Users-dev--worktrees-ao
*
* If Claude Code changes its encoding scheme this will silently break
* introspection. The path can be validated at runtime by checking whether
* the resulting directory exists.
*
* Exported for testing purposes.
*/
export function toClaudeProjectPath(workspacePath: string): string {
// Handle Windows drive letters (C:\Users\... → C-Users-...)
const normalized = workspacePath.replace(/\\/g, "/");
// Claude Code replaces / and . with - (keeping the leading slash as a leading -)
return normalized.replace(/:/g, "").replace(/[/.]/g, "-");
}
/** Find the most recently modified .jsonl session file in a directory */
async function findLatestSessionFile(projectDir: string): Promise<string | null> {
let entries: string[];
try {
entries = await readdir(projectDir);
} catch {
return null;
}
const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
if (jsonlFiles.length === 0) return null;
// Sort by mtime descending
const withStats = await Promise.all(
jsonlFiles.map(async (f) => {
const fullPath = join(projectDir, f);
try {
const s = await stat(fullPath);
return { path: fullPath, mtime: s.mtimeMs };
} catch {
return { path: fullPath, mtime: 0 };
}
}),
);
withStats.sort((a, b) => b.mtime - a.mtime);
return withStats[0]?.path ?? null;
}
interface JsonlLine {
type?: string;
summary?: string;
message?: { content?: string; role?: string };
// Cost/usage fields
costUSD?: number;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
inputTokens?: number;
outputTokens?: number;
estimatedCostUsd?: number;
}
/**
* Read only the last chunk of a JSONL file to extract the last entry's type
* and the file's modification time. This is optimized for polling — it avoids
* reading the entire file (which `getSessionInfo()` does for full cost/summary).
* Now uses the shared readLastJsonlEntry utility from @composio/ao-core.
*/
/**
* Parse only the last `maxBytes` of a JSONL file.
* Summaries and recent activity are always near the end, so reading the whole
* file (which can be 100MB+) is wasteful. For files smaller than maxBytes,
* readFile is used directly. For large files, only the tail is read via a
* file handle to avoid loading the entire file into memory.
*/
async function parseJsonlFileTail(filePath: string, maxBytes = 131_072): Promise<JsonlLine[]> {
let content: string;
let offset: number;
try {
const { size = 0 } = await stat(filePath);
offset = Math.max(0, size - maxBytes);
if (offset === 0) {
// Small file (or unknown size) — read it whole
content = await readFile(filePath, "utf-8");
} else {
// Large file — read only the tail via a file handle
const handle = await open(filePath, "r");
try {
const length = size - offset;
const buffer = Buffer.allocUnsafe(length);
await handle.read(buffer, 0, length, offset);
content = buffer.toString("utf-8");
} finally {
await handle.close();
}
}
} catch {
return [];
}
// Skip potentially truncated first line only when we started mid-file.
// If offset === 0 we read from the start so the first line is complete.
const firstNewline = content.indexOf("\n");
const safeContent =
offset > 0 && firstNewline >= 0 ? content.slice(firstNewline + 1) : content;
const lines: JsonlLine[] = [];
for (const line of safeContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
lines.push(parsed as JsonlLine);
}
} catch {
// Skip malformed lines
}
}
return lines;
}
/** Extract auto-generated summary from JSONL (last "summary" type entry) */
function extractSummary(
lines: JsonlLine[],
): { summary: string; isFallback: boolean } | null {
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line?.type === "summary" && line.summary) {
return { summary: line.summary, isFallback: false };
}
}
// Fallback: first user message truncated to 120 chars
for (const line of lines) {
if (
line?.type === "user" &&
line.message?.content &&
typeof line.message.content === "string"
) {
const msg = line.message.content.trim();
if (msg.length > 0) {
return {
summary: msg.length > 120 ? msg.substring(0, 120) + "..." : msg,
isFallback: true,
};
}
}
}
return null;
}
/** Aggregate cost estimate from JSONL usage events */
function extractCost(lines: JsonlLine[]): CostEstimate | undefined {
let inputTokens = 0;
let outputTokens = 0;
let totalCost = 0;
for (const line of lines) {
// Handle direct cost fields — prefer costUSD; only use estimatedCostUsd
// as fallback to avoid double-counting when both are present.
if (typeof line.costUSD === "number") {
totalCost += line.costUSD;
} else if (typeof line.estimatedCostUsd === "number") {
totalCost += line.estimatedCostUsd;
}
// Handle token counts — prefer the structured `usage` object when present;
// only fall back to flat `inputTokens`/`outputTokens` fields to avoid
// double-counting if a line contains both.
if (line.usage) {
inputTokens += line.usage.input_tokens ?? 0;
inputTokens += line.usage.cache_read_input_tokens ?? 0;
inputTokens += line.usage.cache_creation_input_tokens ?? 0;
outputTokens += line.usage.output_tokens ?? 0;
} else {
if (typeof line.inputTokens === "number") {
inputTokens += line.inputTokens;
}
if (typeof line.outputTokens === "number") {
outputTokens += line.outputTokens;
}
}
}
if (inputTokens === 0 && outputTokens === 0 && totalCost === 0) {
return undefined;
}
// Rough estimate when no direct cost data — uses Sonnet 4.5 pricing as a
// baseline. Will be inaccurate for other models (Opus, Haiku) but provides
// a useful order-of-magnitude signal. TODO: make pricing configurable or
// infer from model field in JSONL.
if (totalCost === 0 && (inputTokens > 0 || outputTokens > 0)) {
totalCost = (inputTokens / 1_000_000) * 3.0 + (outputTokens / 1_000_000) * 15.0;
}
return { inputTokens, outputTokens, estimatedCostUsd: totalCost };
}
// =============================================================================
// Process Detection
// =============================================================================
/**
* TTL cache for `ps -eo pid,tty,args` output. Without this, listing N sessions
* would spawn N concurrent `ps` processes, each taking 30+ seconds on machines
* with many processes. The cache ensures `ps` is called at most once per TTL
* window regardless of how many sessions are being enriched.
*/
let psCache: { output: string; timestamp: number; promise?: Promise<string> } | null = null;
const PS_CACHE_TTL_MS = 5_000;
/** Reset the ps cache. Exported for testing only. */
export function resetPsCache(): void {
psCache = null;
}
async function getCachedProcessList(): Promise<string> {
const now = Date.now();
if (psCache && now - psCache.timestamp < PS_CACHE_TTL_MS) {
// Cache hit — return resolved output or wait for in-flight request
if (psCache.promise) return psCache.promise;
return psCache.output;
}
// Cache miss or expired — start a single `ps` call and share the promise.
// Guard both callbacks so they only update psCache if it still belongs to
// this request — a newer request may have replaced it while we were waiting.
const promise = execFileAsync("ps", ["-eo", "pid,tty,args"], {
timeout: 5_000,
}).then(({ stdout }) => {
if (psCache?.promise === promise) {
psCache = { output: stdout, timestamp: Date.now() };
}
return stdout;
});
// Store the in-flight promise so concurrent callers share it
psCache = { output: "", timestamp: now, promise };
try {
return await promise;
} catch {
// On failure, clear cache so the next caller retries — but only if
// psCache still points to this request (avoid clobbering a newer entry)
if (psCache?.promise === promise) {
psCache = null;
}
return "";
}
}
/**
* Check if a process named "claude" is running in the given runtime handle's context.
* Uses ps to find processes by TTY (for tmux) or by PID.
*/
async function findClaudeProcess(handle: RuntimeHandle): Promise<number | null> {
try {
// For tmux runtime, get the pane TTY and find claude on it
if (handle.runtimeName === "tmux" && handle.id) {
const { stdout: ttyOut } = await execFileAsync(
"tmux",
["list-panes", "-t", handle.id, "-F", "#{pane_tty}"],
{ timeout: 5_000 },
);
// Iterate all pane TTYs (multi-pane sessions) — succeed on any match
const ttys = ttyOut
.trim()
.split("\n")
.map((t) => t.trim())
.filter(Boolean);
if (ttys.length === 0) return null;
const psOut = await getCachedProcessList();
if (!psOut) return null;
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
// Match "claude" as a word boundary — prevents false positives on
// names like "claude-code" or paths that merely contain the substring.
const processRe = /(?:^|\/)claude(?:\s|$)/;
for (const line of psOut.split("\n")) {
const cols = line.trimStart().split(/\s+/);
if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue;
const args = cols.slice(2).join(" ");
if (processRe.test(args)) {
return parseInt(cols[0] ?? "0", 10);
}
}
return null;
}
// For process runtime, check if the PID stored in handle data is alive
const rawPid = handle.data["pid"];
const pid = typeof rawPid === "number" ? rawPid : Number(rawPid);
if (Number.isFinite(pid) && pid > 0) {
try {
process.kill(pid, 0); // Signal 0 = check existence
return pid;
} catch (err: unknown) {
// EPERM means the process exists but we lack permission to signal it
if (err instanceof Error && "code" in err && err.code === "EPERM") {
return pid;
}
return null;
}
}
// No reliable way to identify the correct process for this session
return null;
} catch {
return null;
}
}
// =============================================================================
// Terminal Output Patterns for detectActivity
// =============================================================================
/** Classify Claude Code's activity state from terminal output (pure, sync). */
function classifyTerminalOutput(terminalOutput: string): ActivityState {
// Empty output — can't determine state
if (!terminalOutput.trim()) return "idle";
const lines = terminalOutput.trim().split("\n");
const lastLine = lines[lines.length - 1]?.trim() ?? "";
// Check the last line FIRST — if the prompt is visible, the agent is idle
// regardless of historical output (e.g. "Reading file..." from earlier).
// The is Claude Code's prompt character.
if (/^[>$#]\s*$/.test(lastLine)) return "idle";
// Check the bottom of the buffer for permission prompts BEFORE checking
// full-buffer active indicators. Historical "Thinking"/"Reading" text in
// the buffer must not override a current permission prompt at the bottom.
const tail = lines.slice(-5).join("\n");
if (/Do you want to proceed\?/i.test(tail)) return "waiting_input";
if (/\(Y\)es.*\(N\)o/i.test(tail)) return "waiting_input";
if (/bypass.*permissions/i.test(tail)) return "waiting_input";
// Everything else is "active" — the agent is processing, waiting for
// output, or showing content. Specific patterns (e.g. "esc to interrupt",
// "Thinking", "Reading") all map to "active" so no need to check them
// individually.
return "active";
}
// =============================================================================
// Hook Setup Helper
// =============================================================================
/**
* Shared helper to setup PostToolUse hooks in a workspace.
* Writes metadata-updater.sh script and updates settings.json.
*
* @param workspacePath - Path to the workspace directory
* @param hookCommand - Command string for the hook (can use variables like $CLAUDE_PROJECT_DIR)
*/
async function setupHookInWorkspace(workspacePath: string, hookCommand: string): Promise<void> {
const claudeDir = join(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
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
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
if (hookIndex === -1) {
// No metadata hook exists, add it
postToolUse.push({
matcher: "Bash",
hooks: [
{
type: "command",
command: hookCommand,
timeout: 5000,
},
],
});
} else {
// Hook exists, update the command
const hook = postToolUse[hookIndex] as Record<string, unknown>;
const hooksList = hook["hooks"] as Array<Record<string, unknown>>;
hooksList[hookDefIndex]["command"] = hookCommand;
}
hooks["PostToolUse"] = postToolUse;
existingSettings["hooks"] = hooks;
// Write updated settings
await writeFile(settingsPath, JSON.stringify(existingSettings, null, 2) + "\n", "utf-8");
}
// =============================================================================
// Agent Implementation
// =============================================================================
function createClaudeCodeAgent(): Agent {
return {
name: "claude-code",
processName: "claude",
promptDelivery: "post-launch",
getLaunchCommand(config: AgentLaunchConfig): string {
// Note: CLAUDECODE is unset via getEnvironment() (set to ""), not here.
// This command must be safe for both shell and execFile contexts.
const parts: string[] = ["claude"];
const permissionMode = normalizePermissionMode(config.permissions);
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
parts.push("--dangerously-skip-permissions");
}
if (config.model) {
parts.push("--model", shellEscape(config.model));
}
if (config.systemPromptFile) {
// Use shell command substitution to read from file at launch time.
// This avoids tmux truncation when inlining 2000+ char prompts.
// The double quotes allow $() expansion; inner path is single-quoted for safety.
parts.push("--append-system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
} else if (config.systemPrompt) {
parts.push("--append-system-prompt", shellEscape(config.systemPrompt));
}
// NOTE: prompt is NOT included here — it's delivered post-launch via
// runtime.sendMessage() to keep Claude in interactive mode.
// Using -p causes one-shot mode (Claude exits after responding).
return parts.join(" ");
},
getEnvironment(config: AgentLaunchConfig): Record<string, string> {
const env: Record<string, string> = {};
// Unset CLAUDECODE to avoid nested agent conflicts
env["CLAUDECODE"] = "";
// Set session info for introspection
env["AO_SESSION_ID"] = config.sessionId;
// NOTE: AO_PROJECT_ID is NOT set here - it's the caller's responsibility
// to set it based on their metadata path scheme:
// - spawn.ts sets it to projectId for project-specific directories
// - start.ts omits it for orchestrator (flat directories)
// - session manager omits it (flat directories)
if (config.issueId) {
env["AO_ISSUE_ID"] = config.issueId;
}
return env;
},
detectActivity(terminalOutput: string): ActivityState {
return classifyTerminalOutput(terminalOutput);
},
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
const pid = await findClaudeProcess(handle);
return pid !== null;
},
async getActivityState(
session: Session,
readyThresholdMs?: number,
): Promise<ActivityDetection | null> {
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
// Check if process is running first
const exitedAt = new Date();
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
const running = await this.isProcessRunning(session.runtimeHandle);
if (!running) return { state: "exited", timestamp: exitedAt };
// Process is running - check JSONL session file for activity
if (!session.workspacePath) {
// No workspace path — cannot determine activity without it
return null;
}
const projectPath = toClaudeProjectPath(session.workspacePath);
const projectDir = join(homedir(), ".claude", "projects", projectPath);
const sessionFile = await findLatestSessionFile(projectDir);
if (!sessionFile) {
// No session file found — cannot determine activity
return null;
}
const entry = await readLastJsonlEntry(sessionFile);
if (!entry) {
// Empty file or read error — cannot determine activity
return null;
}
const ageMs = Date.now() - entry.modifiedAt.getTime();
const timestamp = entry.modifiedAt;
switch (entry.lastType) {
case "user":
case "tool_use":
case "progress":
return { state: ageMs > threshold ? "idle" : "active", timestamp };
case "assistant":
case "system":
case "summary":
case "result":
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
case "permission_request":
return { state: "waiting_input", timestamp };
case "error":
return { state: "blocked", timestamp };
default:
return { state: ageMs > threshold ? "idle" : "active", timestamp };
}
},
async getSessionInfo(session: Session): Promise<AgentSessionInfo | null> {
if (!session.workspacePath) return null;
// Build the Claude project directory path
const projectPath = toClaudeProjectPath(session.workspacePath);
const projectDir = join(homedir(), ".claude", "projects", projectPath);
// Find the latest session JSONL file
const sessionFile = await findLatestSessionFile(projectDir);
if (!sessionFile) return null;
// Parse only the tail — summaries are always near the end, files can be 100MB+
const lines = await parseJsonlFileTail(sessionFile);
if (lines.length === 0) return null;
// Extract session ID from filename
const agentSessionId = basename(sessionFile, ".jsonl");
const summaryResult = extractSummary(lines);
return {
summary: summaryResult?.summary ?? null,
summaryIsFallback: summaryResult?.isFallback,
agentSessionId,
cost: extractCost(lines),
};
},
async getRestoreCommand(session: Session, project: ProjectConfig): Promise<string | null> {
if (!session.workspacePath) return null;
// Find Claude's project directory for this workspace
const projectPath = toClaudeProjectPath(session.workspacePath);
const projectDir = join(homedir(), ".claude", "projects", projectPath);
// Find the latest session JSONL file
const sessionFile = await findLatestSessionFile(projectDir);
if (!sessionFile) return null;
// Extract session UUID from filename (e.g. "abc123-def456.jsonl" → "abc123-def456")
const sessionUuid = basename(sessionFile, ".jsonl");
if (!sessionUuid) return null;
// Build resume command
const parts: string[] = ["claude", "--resume", shellEscape(sessionUuid)];
const permissionMode = normalizePermissionMode(project.agentConfig?.permissions);
if (permissionMode === "permissionless" || permissionMode === "auto-edit") {
parts.push("--dangerously-skip-permissions");
}
if (project.agentConfig?.model) {
parts.push("--model", shellEscape(project.agentConfig.model as string));
}
return parts.join(" ");
},
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
// Relative path so that symlinked .claude/ dirs across worktrees
// all produce the same settings.json (last writer doesn't clobber).
await setupHookInWorkspace(workspacePath, ".claude/metadata-updater.sh");
},
async postLaunchSetup(session: Session): Promise<void> {
if (!session.workspacePath) return;
await setupHookInWorkspace(session.workspacePath, ".claude/metadata-updater.sh");
},
};
}
// =============================================================================
// Plugin Export
// =============================================================================
export function create(): Agent {
return createClaudeCodeAgent();
}
export function detect(): boolean {
try {
// Use --version instead of `which` for cross-platform compatibility (Windows has no `which`)
execFileSync("claude", ["--version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
export default { manifest, create, detect } satisfies PluginModule<Agent>;