From c307ae30e000ae06610e4ccf476232bc317917ba Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Wed, 25 Feb 2026 15:58:25 +0530 Subject: [PATCH] feat(agent-codex): implement mtime-based activity state detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plugins/agent-codex/src/index.test.ts | 57 ++++++++++++++----- packages/plugins/agent-codex/src/index.ts | 45 ++++++++++----- 2 files changed, 76 insertions(+), 26 deletions(-) diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index f272775bd..1a7d383e4 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -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(); - }); }); // ========================================================================= diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index b3d700c92..c1dd03d75 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -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 { - // 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 { + 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 {