Skip to content
Merged
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
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
3 changes: 3 additions & 0 deletions src/mcp/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -187,6 +188,7 @@ export function buildActiveSessionState(
config.thresholds.activeSessionWindowMin,
5,
now,
process.ppid,
);
if (!active) return null;
let cumulative: number | null = null;
Expand All @@ -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,
};
}

Expand Down
26 changes: 26 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}): void {
const line = JSON.stringify({ ts: new Date().toISOString(), stage, ...extra });
process.stderr.write(line + "\n");
}

export async function runMcpServer(): Promise<void> {
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 };

Expand Down Expand Up @@ -92,9 +105,22 @@ export async function runMcpServer(): Promise<void> {

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 で自動的に切断される
}

Expand Down
3 changes: 3 additions & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
109 changes: 105 additions & 4 deletions src/observers/claude_code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -156,28 +157,127 @@ export function snapshotRecentSessions(
});
}

/**
* Linux 専用: 指定 pid のプロセス起動時刻 (epoch ms) を返す。
* /proc/<pid>/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(
logDir: string,
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) {
Expand All @@ -192,6 +292,7 @@ export function findActiveSession(
lastUserAt: ts.lastUserAt,
lastAssistantAt: ts.lastAssistantAt,
currentPermissionMode: ts.currentPermissionMode,
resolution: "mtime-recent",
};
}
}
Expand Down
150 changes: 150 additions & 0 deletions tests/active-session.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading