feat(agent-codex): implement mtime-based activity state detection

Use the Codex session file's mtime as a proxy for agent activity.
Codex continuously appends to its rollout JSONL while working, so:
- recently modified file → active
- stale file (past threshold) → idle
- process not running → exited

This replaces the previous null return with real activity detection,
using the same threshold constant (DEFAULT_READY_THRESHOLD_MS = 5min)
as the Claude Code plugin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
suraj-markup 2026-02-25 15:58:25 +05:30
parent b6eb4d8888
commit c307ae30e0
2 changed files with 76 additions and 26 deletions

View File

@ -575,39 +575,70 @@ describe("getActivityState", () => {
it("returns exited when no runtimeHandle", async () => {
const session = makeSession({ runtimeHandle: null });
const result = await agent.getActivityState(session);
expect(result).toEqual({ state: "exited" });
expect(result?.state).toBe("exited");
expect(result?.timestamp).toBeInstanceOf(Date);
});
it("returns exited when process is not running", async () => {
mockExecFileAsync.mockRejectedValue(new Error("tmux not running"));
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
const result = await agent.getActivityState(session);
expect(result).toEqual({ state: "exited" });
expect(result?.state).toBe("exited");
expect(result?.timestamp).toBeInstanceOf(Date);
});
it("returns null (unknown) when process is running", async () => {
it("returns null when process is running but no workspacePath", async () => {
mockTmuxWithProcess("codex");
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: undefined });
expect(await agent.getActivityState(session)).toBeNull();
});
it("returns null when process is running but no session file found", async () => {
mockTmuxWithProcess("codex");
mockReaddir.mockRejectedValue(new Error("ENOENT"));
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
expect(await agent.getActivityState(session)).toBeNull();
});
it("returns active when session file was recently modified", async () => {
mockTmuxWithProcess("codex");
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
mockReaddir.mockResolvedValue(["sess.jsonl"]);
setupMockOpen(content);
// mtime = now (just modified)
mockStat.mockResolvedValue({ mtimeMs: Date.now(), mtime: new Date() });
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
const result = await agent.getActivityState(session);
expect(result?.state).toBe("active");
expect(result?.timestamp).toBeInstanceOf(Date);
});
it("returns idle when session file is stale", async () => {
mockTmuxWithProcess("codex");
const content = '{"type":"session_meta","cwd":"/workspace/test"}\n';
mockReaddir.mockResolvedValue(["sess.jsonl"]);
setupMockOpen(content);
// mtime = 10 minutes ago (past the 5-minute threshold)
const staleTime = Date.now() - 600_000;
mockStat.mockResolvedValue({ mtimeMs: staleTime, mtime: new Date(staleTime) });
const session = makeSession({ runtimeHandle: makeTmuxHandle(), workspacePath: "/workspace/test" });
const result = await agent.getActivityState(session);
expect(result?.state).toBe("idle");
expect(result?.timestamp).toBeInstanceOf(Date);
});
it("returns exited when process handle has dead PID", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw new Error("ESRCH");
});
const session = makeSession({ runtimeHandle: makeProcessHandle(999) });
const result = await agent.getActivityState(session);
expect(result).toEqual({ state: "exited" });
expect(result?.state).toBe("exited");
expect(result?.timestamp).toBeInstanceOf(Date);
killSpy.mockRestore();
});
it("does not include timestamp in exited state", async () => {
const session = makeSession({ runtimeHandle: null });
const result = await agent.getActivityState(session);
// The Codex implementation returns { state: "exited" } without timestamp
expect(result).toEqual({ state: "exited" });
expect(result?.timestamp).toBeUndefined();
});
});
// =========================================================================

View File

@ -1,4 +1,5 @@
import {
DEFAULT_READY_THRESHOLD_MS,
shellEscape,
type Agent,
type AgentSessionInfo,
@ -606,20 +607,38 @@ function createCodexAgent(): Agent {
return "active";
},
async getActivityState(session: Session, _readyThresholdMs?: number): Promise<ActivityDetection | null> {
// Check if process is running first
if (!session.runtimeHandle) return { state: "exited" };
const running = await this.isProcessRunning(session.runtimeHandle);
if (!running) return { state: "exited" };
async getActivityState(session: Session, readyThresholdMs?: number): Promise<ActivityDetection | null> {
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
// NOTE: Codex stores rollout files in a global ~/.codex/sessions/ directory
// without workspace-specific scoping. When multiple Codex sessions run in
// parallel, we cannot reliably determine which rollout file belongs to which
// session. Until Codex provides per-workspace session tracking, we return
// null (unknown) rather than guessing. See issue #13 for details.
//
// TODO: Implement proper per-session activity detection when Codex supports it.
return null;
// 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 };
// Use session file mtime as a proxy for activity. Codex continuously
// appends to its rollout JSONL file while working, so a recently
// modified file means the agent is active.
if (!session.workspacePath) return null;
const sessionFile = await findCodexSessionFile(session.workspacePath);
if (!sessionFile) return null;
try {
const s = await stat(sessionFile);
const timestamp = s.mtime;
const ageMs = Date.now() - s.mtimeMs;
if (ageMs <= threshold) {
// File was recently modified — agent is actively working
return { state: "active", timestamp };
}
// File is stale — agent finished or is idle
return { state: "idle", timestamp };
} catch {
return null;
}
},
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {