diff --git a/src/index.ts b/src/index.ts index 2898019..b308d22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -289,4 +289,15 @@ program await runMcpServer(); }); -program.parseAsync(process.argv); +program.parseAsync(process.argv).catch((err) => { + // 想定外の例外は必ず stderr に出して非ゼロ終了する。 + // MCP サーバ (mcp サブコマンド) のように Promise を返したまま終わる + // ハンドラで例外が起きた時、無音で落ちないようにする。 + const line = JSON.stringify({ + ts: new Date().toISOString(), + stage: "fatal", + error: err instanceof Error ? { message: err.message, stack: err.stack } : err, + }); + process.stderr.write(line + "\n"); + process.exit(1); +}); diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index 4442865..00c8a14 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -175,6 +175,7 @@ export type ActiveSessionPayload = { last_user_at: string | null; last_assistant_at: string | null; cumulative_uncached_tokens: number | null; + resolution: "parent-pid" | "mtime-recent"; } | null; export function buildActiveSessionState( @@ -187,6 +188,7 @@ export function buildActiveSessionState( config.thresholds.activeSessionWindowMin, 5, now, + process.ppid, ); if (!active) return null; let cumulative: number | null = null; @@ -207,6 +209,7 @@ export function buildActiveSessionState( last_user_at: active.lastUserAt?.toISOString() ?? null, last_assistant_at: active.lastAssistantAt?.toISOString() ?? null, cumulative_uncached_tokens: cumulative, + resolution: active.resolution, }; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 690a692..45cd54d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -28,8 +28,21 @@ import { registerPrompts } from "./prompts.ts"; const PKG_NAME = "cogsync-cli"; const PKG_VERSION = "1.0.0-alpha.1"; +/** + * stderr 経由で起動ログを出す。Claude Code 等のクライアントは + * stdout を JSON-RPC に予約しているので、診断用ログは必ず stderr 側に流す。 + * MCP クライアントの mcp-logs にはこの行は載らないが、`cogsync mcp 2>cogsync.log` + * のような手動診断時や、Claude Code が今後 stderr 取り込みに対応した時に役立つ。 + */ +function logStartup(stage: string, extra: Record = {}): void { + const line = JSON.stringify({ ts: new Date().toISOString(), stage, ...extra }); + process.stderr.write(line + "\n"); +} + export async function runMcpServer(): Promise { + logStartup("boot", { pid: process.pid, ppid: process.ppid, node: process.version }); const { config } = loadConfig(); + logStartup("config-loaded"); const store = new JsonStore(); const ctx: ResourceContext = { config, store }; @@ -92,9 +105,22 @@ export async function runMcpServer(): Promise { registerTools(server, ctx); registerPrompts(server); + logStartup("handlers-registered"); + + // 親プロセスが SIGTERM/SIGINT で落とした場合、確実に exit する。 + // 既定では node はシグナルで死ぬが、明示しておく方が再接続時の race を減らせる。 + // (観測: npm link 直後の MCP 再接続で旧プロセス exit と新プロセス spawn が + // 重なると、まれに新プロセスの initialize 応答が 30s タイムアウトする事象あり。) + const onSignal = (sig: NodeJS.Signals) => { + logStartup("signal", { sig }); + process.exit(0); + }; + process.once("SIGTERM", onSignal); + process.once("SIGINT", onSignal); const transport = new StdioServerTransport(); await server.connect(transport); + logStartup("connected"); // stdio transport は process.stdin の close で自動的に切断される } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index be1a824..b1ad2e0 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -202,6 +202,9 @@ function safeReadLatestSession(config: ResourceContext["config"]) { return findActiveSession( config.observers.claudeCode.logDir, config.thresholds.activeSessionWindowMin, + 5, + new Date(), + process.ppid, ); } catch { return null; diff --git a/src/observers/claude_code.ts b/src/observers/claude_code.ts index f6d54b3..f9ae16c 100644 --- a/src/observers/claude_code.ts +++ b/src/observers/claude_code.ts @@ -10,6 +10,7 @@ import { readdirSync, readFileSync, statSync, existsSync } from "node:fs"; import { join } from "node:path"; +import { platform } from "node:os"; export type SessionTokenSample = { sessionId: string; @@ -156,15 +157,101 @@ export function snapshotRecentSessions( }); } +/** + * Linux 専用: 指定 pid のプロセス起動時刻 (epoch ms) を返す。 + * /proc//stat (field 22) と /proc/stat (btime) と CLK_TCK から算出。 + * 解決できなければ null。 + * + * MCP server は Claude Code から stdio で spawn されるので、process.ppid が + * Claude Code のプロセス ID になる。その起動時刻を session JSONL の first_ts と + * 突き合わせれば「呼び出し元 Claude Code がどのセッションファイルを書いているか」 + * を高精度に同定できる。 + */ +export function readProcessStartMs(pid: number): number | null { + if (platform() !== "linux") return null; + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const rparen = stat.lastIndexOf(")"); + if (rparen < 0) return null; + const fields = stat.slice(rparen + 2).split(" "); + const startJiffies = Number(fields[19]); + if (!Number.isFinite(startJiffies)) return null; + const stat2 = readFileSync("/proc/stat", "utf8"); + const btimeLine = stat2.split("\n").find((l) => l.startsWith("btime ")); + if (!btimeLine) return null; + const btime = Number(btimeLine.split(" ")[1]); + if (!Number.isFinite(btime)) return null; + const clkTck = 100; + return Math.round((btime + startJiffies / clkTck) * 1000); + } catch { + return null; + } +} + +function readFirstTimestamp(path: string): Date | null { + try { + const text = readFileSync(path, "utf8"); + for (const line of text.split("\n")) { + if (line.length === 0) continue; + try { + const rec = JSON.parse(line) as { timestamp?: string }; + if (rec.timestamp) return new Date(rec.timestamp); + } catch { + continue; + } + } + return null; + } catch { + return null; + } +} + +/** + * 呼び出し元 Claude Code (parent pid) の起動時刻と各 session JSONL の first_ts を + * 突き合わせ、最も近いものを返す。tolerance を超える差しか無ければ null。 + * + * これにより、複数 Claude Code ウィンドウ並行起動時や、過去セッションが + * subagent 等で touch されている場合でも、呼び出し元自身のセッションを特定できる。 + * + * @param toleranceMs parent_start と first_ts のずれの許容範囲(既定 120 秒) + */ +export function resolveSessionByParentPid( + logDir: string, + parentPid: number | null | undefined, + toleranceMs = 120_000, + candidateLimit = 20, +): SessionFile | null { + if (!parentPid) return null; + const parentStartMs = readProcessStartMs(parentPid); + if (parentStartMs === null) return null; + const files = listSessionFiles(logDir).slice(0, candidateLimit); + let best: { file: SessionFile; delta: number } | null = null; + for (const f of files) { + const firstTs = readFirstTimestamp(f.path); + if (!firstTs) continue; + const delta = Math.abs(firstTs.getTime() - parentStartMs); + if (delta > toleranceMs) continue; + if (best === null || delta < best.delta) best = { file: f, delta }; + } + return best?.file ?? null; +} + /** * 「アクティブな」セッションを 1 件返す。 * + * 解決順序: + * 1. parentPid 指定時: 親プロセス(Claude Code)の起動時刻と各 session JSONL の + * first_ts を突き合わせて確実に同定する(multi-window 対応)。 + * 2. フォールバック: 「最新の user/assistant イベントが直近 recentMin 分以内」の + * 最 mtime セッション。standalone daemon (cogsync watch) のように parent が + * Claude Code ではない呼び出し元向け。 + * * 単純な mtime 降順 1 位だと、過去ログのちょっとした更新(subagent や別ホスト)で - * top が pivot し、cumulative tokens が tick ごとに乱変動する問題があった。 - * 「最新の user/assistant イベントが直近 recentMin 分以内」のフィルタで真にアクティブな - * セッションだけを採用する。 + * top が pivot し、cumulative tokens が tick ごとに乱変動する問題があったため、 + * MCP server からの呼び出しでは parentPid 経由の同定を優先する。 * - * @param recentMin このウィンドウ内の最新イベントを持つセッションのみ採用 + * @param parentPid 呼び出し元プロセス ID(MCP server 内では process.ppid) + * @param recentMin フォールバック時の最新イベント許容ウィンドウ * @param candidateLimit mtime 降順で上から確認する候補数 */ export function findActiveSession( @@ -172,12 +259,25 @@ export function findActiveSession( recentMin = 5, candidateLimit = 5, now: Date = new Date(), + parentPid: number | null = null, ): { file: SessionFile; lastUserAt: Date | null; lastAssistantAt: Date | null; currentPermissionMode: PermissionMode; + resolution: "parent-pid" | "mtime-recent"; } | null { + const byParent = resolveSessionByParentPid(logDir, parentPid); + if (byParent) { + const ts = readLastEventTimestamps(byParent); + return { + file: byParent, + lastUserAt: ts.lastUserAt, + lastAssistantAt: ts.lastAssistantAt, + currentPermissionMode: ts.currentPermissionMode, + resolution: "parent-pid", + }; + } const cutoffMs = now.getTime() - recentMin * 60_000; const files = listSessionFiles(logDir).slice(0, candidateLimit); for (const f of files) { @@ -192,6 +292,7 @@ export function findActiveSession( lastUserAt: ts.lastUserAt, lastAssistantAt: ts.lastAssistantAt, currentPermissionMode: ts.currentPermissionMode, + resolution: "mtime-recent", }; } } diff --git a/tests/active-session.test.ts b/tests/active-session.test.ts new file mode 100644 index 0000000..8067260 --- /dev/null +++ b/tests/active-session.test.ts @@ -0,0 +1,150 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir, platform } from "node:os"; +import { join } from "node:path"; +import { + findActiveSession, + resolveSessionByParentPid, + readProcessStartMs, +} from "../src/observers/claude_code.ts"; + +function setupLogDir(): { logDir: string; cleanup: () => void } { + const root = mkdtempSync(join(tmpdir(), "cogsync-active-")); + const logDir = join(root, "projects"); + mkdirSync(logDir, { recursive: true }); + return { logDir, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +function writeSession( + logDir: string, + project: string, + sessionId: string, + firstTs: string, + lastTs: string, +): string { + const projectDir = join(logDir, project); + mkdirSync(projectDir, { recursive: true }); + const path = join(projectDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "permission-mode", permissionMode: "bypassPermissions", sessionId }), + JSON.stringify({ type: "user", sessionId, timestamp: firstTs }), + JSON.stringify({ + type: "assistant", + sessionId, + timestamp: lastTs, + message: { model: "claude-opus-4-7", usage: { input_tokens: 10, output_tokens: 20 } }, + }), + ]; + writeFileSync(path, lines.join("\n") + "\n"); + return path; +} + +test("readProcessStartMs: 非 Linux または無効 pid は null", () => { + if (platform() === "linux") { + assert.equal(readProcessStartMs(999_999_999), null); + } else { + assert.equal(readProcessStartMs(process.pid), null); + } +}); + +test("readProcessStartMs: Linux で自プロセスの起動時刻が取れる", () => { + if (platform() !== "linux") return; + const ms = readProcessStartMs(process.pid); + assert.notEqual(ms, null); + assert.ok(ms! > 0); + assert.ok(ms! <= Date.now() + 1000); +}); + +test("resolveSessionByParentPid: parentPid 未指定なら null", () => { + const { logDir, cleanup } = setupLogDir(); + try { + assert.equal(resolveSessionByParentPid(logDir, null), null); + assert.equal(resolveSessionByParentPid(logDir, undefined), null); + assert.equal(resolveSessionByParentPid(logDir, 0), null); + } finally { + cleanup(); + } +}); + +test("resolveSessionByParentPid: tolerance 内で最も近い first_ts のセッションを返す", () => { + if (platform() !== "linux") return; + const { logDir, cleanup } = setupLogDir(); + try { + const myStartMs = readProcessStartMs(process.pid)!; + const closeTs = new Date(myStartMs + 3_000).toISOString(); + const farTs = new Date(myStartMs - 2 * 60 * 60_000).toISOString(); + writeSession(logDir, "p1", "close-session", closeTs, closeTs); + writeSession(logDir, "p1", "far-session", farTs, farTs); + const got = resolveSessionByParentPid(logDir, process.pid); + assert.notEqual(got, null); + assert.equal(got!.sessionId, "close-session"); + } finally { + cleanup(); + } +}); + +test("resolveSessionByParentPid: tolerance を超える差しか無ければ null", () => { + if (platform() !== "linux") return; + const { logDir, cleanup } = setupLogDir(); + try { + const myStartMs = readProcessStartMs(process.pid)!; + const farTs = new Date(myStartMs - 60 * 60_000).toISOString(); + writeSession(logDir, "p1", "far-session", farTs, farTs); + assert.equal(resolveSessionByParentPid(logDir, process.pid), null); + } finally { + cleanup(); + } +}); + +test("findActiveSession: parentPid 解決成功時は resolution=parent-pid", () => { + if (platform() !== "linux") return; + const { logDir, cleanup } = setupLogDir(); + try { + const myStartMs = readProcessStartMs(process.pid)!; + const closeTs = new Date(myStartMs + 1_000).toISOString(); + const recentTs = new Date(Date.now() - 60_000).toISOString(); + writeSession(logDir, "p1", "ours", closeTs, recentTs); + writeSession( + logDir, + "p1", + "other-recent", + new Date(myStartMs - 30 * 60_000).toISOString(), + recentTs, + ); + const got = findActiveSession(logDir, 5, 10, new Date(), process.pid); + assert.notEqual(got, null); + assert.equal(got!.resolution, "parent-pid"); + assert.equal(got!.file.sessionId, "ours"); + } finally { + cleanup(); + } +}); + +test("findActiveSession: parentPid 指定なしは mtime-recent フォールバック", () => { + const { logDir, cleanup } = setupLogDir(); + try { + const recentTs = new Date(Date.now() - 60_000).toISOString(); + writeSession(logDir, "p1", "recent-session", recentTs, recentTs); + const got = findActiveSession(logDir, 5, 10, new Date()); + assert.notEqual(got, null); + assert.equal(got!.resolution, "mtime-recent"); + assert.equal(got!.file.sessionId, "recent-session"); + } finally { + cleanup(); + } +}); + +test("findActiveSession: parentPid 解決失敗時は mtime-recent にフォールバック", () => { + const { logDir, cleanup } = setupLogDir(); + try { + const recentTs = new Date(Date.now() - 60_000).toISOString(); + writeSession(logDir, "p1", "recent-session", recentTs, recentTs); + const got = findActiveSession(logDir, 5, 10, new Date(), 999_999_999); + assert.notEqual(got, null); + assert.equal(got!.resolution, "mtime-recent"); + assert.equal(got!.file.sessionId, "recent-session"); + } finally { + cleanup(); + } +});