Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ cli/src/
| `init --url <url>` | 初期設定 + hooks + MCP インストール |
| `init --url <url> --proxy <proxy-url>` | プロキシ経由で接続 |
| `init --url <url> --dev` | 開発モード(ローカルCLIパス使用) |
| `init --url <url> --async` | 非同期送信モードを有効化(`send_mode: async`) |
| `init --url <url> --local` | プロジェクト単位で hooks/MCP を設定 |
| `init --url <url> --local --separate-local-config` | プロジェクト単位で config も作成 |
| `login` | Webログイン URL 発行 |
| `send` | transcript 差分送信(hooks用、stdin から JSON 受け取り) |
| `send --claude-session-id <id>` | 既存セッションを手動送信(差分のみ) |
| `mcp-server` | MCPサーバー起動(stdio通信) |
| `on` / `off` | hooks + MCP 有効化/無効化 |
| `on --async` | `send_mode` を async に切替(保存) |
| `on --local` / `off --local` | プロジェクト単位で hooks + MCP 有効化/無効化 |
| `uninstall` | hooks/MCP/config 削除 |
| `uninstall --local` | プロジェクト単位の hooks/MCP/config 削除 |
Expand All @@ -108,12 +110,22 @@ cli/src/
{
"server_url": "http://localhost:8080",
"api_key": "agtr_xxxxxxxxxxxxxxxxxxxxxxxx",
"proxy_url": "http://proxy.example.com:8080"
"proxy_url": "http://proxy.example.com:8080",
"send_mode": "async"
}
```

**proxy_url** はオプション。設定しない場合は環境変数 `HTTPS_PROXY` / `HTTP_PROXY` にフォールバックする。

**send_mode** はオプション(`"sync"` | `"async"`、未設定は `"sync"`)。

| モード | 挙動 |
|--------|------|
| `sync`(既定) | hook が送信(HTTPS 往復)の完了を待つ。従来どおり。 |
| `async` | hook は detached worker を spawn して即 return し、送信は背後で行う。worker は per-session ロックで同一セッションの送信を直列化する。 |

`async` への切替は `init --async` / `on --async`、確認は `doctor` の `Send mode` 行で行う。手動送信(`--claude-session-id`)は常に同期。

### ローカル設定(--local オプション使用時)

`--local` オプションを使うと、プロジェクト単位で AgenTrace を有効/無効にできる。
Expand Down
2 changes: 2 additions & 0 deletions cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
loadConfigWithFallback,
getConfigPath,
findAndLoadLocalConfig,
getSendMode,
} from "../config/manager.js";
import { createDispatcher } from "../utils/proxy.js";
import { fetch } from "undici";
Expand Down Expand Up @@ -48,6 +49,7 @@ export async function doctorCommand(): Promise<void> {
console.log(` Active config: ${configSource} (${configPath})`);
console.log(` Server URL: ${effectiveConfig.server_url}`);
console.log(` API Key: ${maskApiKey(effectiveConfig.api_key)}`);
console.log(` Send mode: ${getSendMode(effectiveConfig)}`);
if (effectiveConfig.proxy_url) {
console.log(` Proxy URL: ${effectiveConfig.proxy_url}`);
}
Expand Down
5 changes: 5 additions & 0 deletions cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface InitOptions {
dev?: boolean;
local?: boolean;
separateLocalConfig?: boolean;
async?: boolean;
}

export async function initCommand(options: InitOptions = {}): Promise<void> {
Expand Down Expand Up @@ -110,6 +111,7 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
server_url: serverUrlStr,
api_key: result.apiKey,
...(options.proxy && { proxy_url: options.proxy }),
...(options.async && { send_mode: "async" as const }),
};

if (options.local && options.separateLocalConfig && projectDir) {
Expand All @@ -125,6 +127,9 @@ export async function initCommand(options: InitOptions = {}): Promise<void> {
if (options.proxy) {
console.log(` Proxy: ${options.proxy}`);
}
if (options.async) {
console.log(` Send mode: async`);
}

// Determine hook command
let hookCommand: string | undefined;
Expand Down
12 changes: 11 additions & 1 deletion cli/src/commands/on.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { installHooks, installMcpServer, installPreToolUseHook } from "../hooks/installer.js";
import { loadConfigWithFallback } from "../config/manager.js";
import { loadConfigWithFallback, persistSendMode } from "../config/manager.js";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export interface OnOptions {
dev?: boolean;
local?: boolean;
async?: boolean;
}

export async function onCommand(options: OnOptions = {}): Promise<void> {
Expand All @@ -25,6 +26,15 @@ export async function onCommand(options: OnOptions = {}): Promise<void> {
console.log("[Local Mode] Enabling hooks/MCP for this project only\n");
}

if (options.async) {
const result = persistSendMode("async", { cwd: process.cwd() });
if (result.ok) {
console.log(`✓ Send mode set to async (${result.path})`);
} else {
console.error("✗ Failed to update send_mode: config not found");
}
}

// Determine hook command
let hookCommand: string | undefined;
if (options.dev) {
Expand Down
166 changes: 121 additions & 45 deletions cli/src/commands/send.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { execSync } from "child_process";
import { loadConfigWithFallback } from "../config/manager.js";
import { execSync, spawn } from "child_process";
import { loadConfigWithFallback, getSendMode } from "../config/manager.js";
import { getNewLines, saveCursor, hasCursor } from "../config/cursor.js";
import { sendIngest } from "../utils/http.js";
import { WORKER_ENV } from "../send/worker.js";
import {
findSessionFile,
extractCwdFromTranscript,
Expand All @@ -21,19 +22,38 @@ interface SendTranscriptParams {
isHook: boolean;
}

export interface RunSendParams {
sessionId: string;
transcriptPath: string;
cwd?: string;
}

export type SendOutcome =
| { status: "no-config" }
| { status: "no-lines" }
| { status: "no-valid-lines" }
| { status: "sent"; lineCount: number }
| { status: "error"; error: string };

// Event types that should not be sent to the server (high-volume, not needed for display)
const SKIPPED_EVENT_TYPES = ["progress", "file-history-snapshot"];

// Cap git lookups so a hung git (e.g. a stuck .git/index.lock) cannot block the
// send. In async mode this also keeps the session lock from being held past its
// stale timeout, which would let a later fire take over as a second holder.
const GIT_EXEC_TIMEOUT_MS = 5_000;

function getGitRemoteUrl(cwd: string): string | null {
try {
const url = execSync("git remote get-url origin", {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: GIT_EXEC_TIMEOUT_MS,
}).trim();
return url || null;
} catch {
return null; // Not a git repo or no remote
return null; // Not a git repo, no remote, or git timed out
}
}

Expand All @@ -43,6 +63,7 @@ function getGitBranch(cwd: string): string | null {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: GIT_EXEC_TIMEOUT_MS,
}).trim();
return branch || null;
} catch {
Expand All @@ -51,42 +72,28 @@ function getGitBranch(cwd: string): string | null {
}

/**
* Core logic for sending transcript data to the server.
* Shared between hook-based and manual invocations.
* Send the cursor diff to the server and advance the cursor on success.
* Returns an outcome instead of exiting so callers control reporting and,
* for the async worker, lock release.
*/
async function sendTranscript(params: SendTranscriptParams): Promise<void> {
const { sessionId, transcriptPath, cwd, isHook } = params;

const exitWithError = (message: string) => {
console.error(message);
process.exit(isHook ? 0 : 1);
};
export async function runSend(params: RunSendParams): Promise<SendOutcome> {
const { sessionId, transcriptPath, cwd } = params;

// Check if config exists (local config takes precedence over global)
const config = loadConfigWithFallback(cwd);
if (!config) {
exitWithError(
"[agentrace] Warning: Config not found. Run 'npx agentrace init' first."
);
return;
return { status: "no-config" };
}

// Get new lines from transcript
const { lines, totalLineCount } = getNewLines(transcriptPath, sessionId);

if (lines.length === 0) {
if (!isHook) {
console.log("[agentrace] No new lines to send.");
}
process.exit(0);
return { status: "no-lines" };
}

// Parse JSONL lines and filter out skipped event types
const transcriptLines: unknown[] = [];
for (const line of lines) {
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
// Skip high-volume event types that are not needed for display
if (typeof parsed.type === "string" && SKIPPED_EVENT_TYPES.includes(parsed.type)) {
continue;
}
Expand All @@ -97,10 +104,7 @@ async function sendTranscript(params: SendTranscriptParams): Promise<void> {
}

if (transcriptLines.length === 0) {
if (!isHook) {
console.log("[agentrace] No valid transcript lines to send.");
}
process.exit(0);
return { status: "no-valid-lines" };
}

// Detect subagent (Task tool) sessions from first transcript line
Expand Down Expand Up @@ -135,15 +139,13 @@ async function sendTranscript(params: SendTranscriptParams): Promise<void> {
gitBranch = getGitBranch(cwd) ?? undefined;
}

// Send to server
const result = await sendIngest(
{
session_id: sessionId,
transcript_lines: transcriptLines,
cwd: cwd,
git_remote_url: gitRemoteUrl,
git_branch: gitBranch,
// Subagent fields
parent_session_id: parentSessionId,
agent_id: agentId,
is_sidechain: isSidechain || undefined,
Expand All @@ -152,20 +154,56 @@ async function sendTranscript(params: SendTranscriptParams): Promise<void> {
cwd
);

if (result.ok) {
// Update cursor on success
saveCursor(sessionId, totalLineCount);
if (!isHook) {
console.log(
`[agentrace] Sent ${transcriptLines.length} lines for session ${sessionId}`
);
}
} else {
exitWithError(`[agentrace] Warning: ${result.error}`);
return;
if (!result.ok) {
return { status: "error", error: result.error ?? "unknown error" };
}

process.exit(0);
// Update cursor only on success so a failed send is retried by the next fire.
saveCursor(sessionId, totalLineCount);
return { status: "sent", lineCount: transcriptLines.length };
}

/**
* Send wrapper for the synchronous hook path and manual invocation.
* Maps the outcome to logging and the existing exit-code contract
* (hook: always exit 0; manual: exit 1 on error).
*/
async function sendTranscript(params: SendTranscriptParams): Promise<void> {
const { sessionId, transcriptPath, cwd, isHook } = params;

const outcome = await runSend({ sessionId, transcriptPath, cwd });

let exitCode = 0;
switch (outcome.status) {
case "no-config":
console.error(
"[agentrace] Warning: Config not found. Run 'npx agentrace init' first."
);
exitCode = isHook ? 0 : 1;
break;
case "no-lines":
if (!isHook) {
console.log("[agentrace] No new lines to send.");
}
break;
case "no-valid-lines":
if (!isHook) {
console.log("[agentrace] No valid transcript lines to send.");
}
break;
case "sent":
if (!isHook) {
console.log(
`[agentrace] Sent ${outcome.lineCount} lines for session ${sessionId}`
);
}
break;
case "error":
console.error(`[agentrace] Warning: ${outcome.error}`);
exitCode = isHook ? 0 : 1;
break;
}
process.exit(exitCode);
}

/**
Expand Down Expand Up @@ -204,15 +242,30 @@ export async function sendCommand(): Promise<void> {
process.exit(0);
}

// Use CLAUDE_PROJECT_DIR (stable project root) instead of cwd (can change during builds)
const projectDir = process.env.CLAUDE_PROJECT_DIR || data.cwd;

// Async mode: hand off to a detached worker and return immediately, keeping
// the HTTPS send off the hook's critical path (the 10s UserPromptSubmit wait
// is sync-only). spawn() reports launch failures asynchronously, not as a
// throw, so the catch below only covers a synchronous spawn() error; a worker
// that fails to launch is harmless because the cursor only advances on HTTP
// 200, so the batch is retried on the next fire.
if (getSendMode(loadConfigWithFallback(projectDir)) === "async") {
try {
spawnWorker({ sessionId, transcriptPath, projectDir });
process.exit(0);
} catch {
// fall through to the synchronous send below
}
}

// For UserPromptSubmit, wait for transcript to be written
// (Claude hasn't started processing yet, so transcript may not be updated)
if (data.hook_event_name === "UserPromptSubmit") {
await sleep(10000);
}

// Use CLAUDE_PROJECT_DIR (stable project root) instead of cwd (can change during builds)
const projectDir = process.env.CLAUDE_PROJECT_DIR || data.cwd;

await sendTranscript({
sessionId,
transcriptPath,
Expand All @@ -221,6 +274,29 @@ export async function sendCommand(): Promise<void> {
});
}

function spawnWorker(payload: {
sessionId: string;
transcriptPath: string;
projectDir?: string;
}): void {
const child = spawn(
process.execPath,
[...process.execArgv, process.argv[1], "__send-worker"],
{
detached: true,
stdio: "ignore",
env: {
...process.env,
[WORKER_ENV.sessionId]: payload.sessionId,
[WORKER_ENV.transcriptPath]: payload.transcriptPath,
[WORKER_ENV.projectDir]: payload.projectDir ?? "",
},
}
);
child.on("error", () => {});
child.unref();
}

/**
* Manual send command.
* Finds session file by ID and sends to server.
Expand Down
Loading