From 9aa788688af73365eae3dc6a599ddd30ee02ceee Mon Sep 17 00:00:00 2001 From: "Doink (OpenClaw)" Date: Wed, 18 Feb 2026 08:50:13 -0800 Subject: [PATCH] fix(pid-recycling): detect recycled PIDs via process start time + add dev infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-1: Sessions showed as "running" when macOS recycled PIDs. Fixed by capturing process start time (`ps -p -o lstart=`) and verifying it against session creation time. A process that started before the session was created cannot be running that session. Also adds: - Biome linting (check + format) - `npm run typecheck` (tsc --noEmit) - GitHub Actions CI (lint → typecheck → build → test) - Vitest config excluding dist/ from test discovery - Pre-push hook (.githooks/pre-push) running full CI checks - Release script (scripts/release.sh) - CLAUDE.md with project conventions and workflow docs Co-Authored-By: Charlie Hulcher --- .githooks/pre-push | 18 +++ .github/workflows/ci.yml | 32 +++++ CLAUDE.md | 55 +++++++++ biome.json | 17 +++ package-lock.json | 166 ++++++++++++++++++++++++++ package.json | 10 +- scripts/release.sh | 35 ++++++ src/adapters/claude-code.test.ts | 193 ++++++++++++++++++++++++++++++- src/adapters/claude-code.ts | 116 +++++++++++++------ src/adapters/openclaw.ts | 38 +++--- src/cli.ts | 14 ++- vitest.config.ts | 7 ++ 12 files changed, 645 insertions(+), 56 deletions(-) create mode 100755 .githooks/pre-push create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md create mode 100644 biome.json create mode 100755 scripts/release.sh create mode 100644 vitest.config.ts diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..66b51b7 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Running pre-push checks..." + +echo "→ Lint" +npm run lint + +echo "→ Typecheck" +npm run typecheck + +echo "→ Build" +npm run build + +echo "→ Test" +npm test + +echo "Pre-push checks passed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b93abcf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm install + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5a10dcd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# agent-ctl + +Universal agent supervision interface — monitor and control AI coding agents from a single CLI. + +## Quick Start + +```bash +npm install +npm run build +npm link # makes `agent-ctl` available globally +``` + +## Development + +```bash +npm run dev # run CLI via tsx (no build needed) +npm test # vitest run +npm run test:watch +npm run typecheck # tsc --noEmit +npm run lint # biome check +npm run lint:fix # biome check --write +``` + +## Project Structure + +- `src/core/types.ts` — Core interfaces (AgentAdapter, AgentSession, etc.) +- `src/adapters/claude-code.ts` — Claude Code adapter (reads ~/.claude/, cross-refs PIDs) +- `src/adapters/openclaw.ts` — OpenClaw gateway adapter (WebSocket RPC) +- `src/cli.ts` — CLI entry point (commander) + +## Conventions + +- **Language:** TypeScript (strict mode), ESM-only (`"type": "module"`) +- **Testing:** vitest — test files live next to source (`*.test.ts`) +- **Linting:** biome (check + format) +- **Build:** tsc → dist/ + +## Git + +- **Identity:** `git commit --author="Doink (OpenClaw) "` +- **Co-author:** `Co-Authored-By: Charlie Hulcher ` +- **Branch protection:** main requires PR review (do not push directly) +- **Pre-push hook:** runs build + test before push + +## Release + +```bash +./scripts/release.sh [patch|minor|major] +``` + +Bumps version, builds, tags, and runs `npm link`. No npm publish (local tool). + +## CI + +GitHub Actions runs on push/PR to main: install → lint → typecheck → build → test. diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..522c6c3 --- /dev/null +++ b/biome.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json", + "files": { + "includes": ["src/**/*.ts"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + } +} diff --git a/package-lock.json b/package-lock.json index dc0db13..c5b4f9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,172 @@ }, "bin": { "agent-ctl": "dist/cli.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.2" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.2.tgz", + "integrity": "sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.2", + "@biomejs/cli-darwin-x64": "2.4.2", + "@biomejs/cli-linux-arm64": "2.4.2", + "@biomejs/cli-linux-arm64-musl": "2.4.2", + "@biomejs/cli-linux-x64": "2.4.2", + "@biomejs/cli-linux-x64-musl": "2.4.2", + "@biomejs/cli-win32-arm64": "2.4.2", + "@biomejs/cli-win32-x64": "2.4.2" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.2.tgz", + "integrity": "sha512-3pEcKCP/1POKyaZZhXcxFl3+d9njmeAihZ17k8lL/1vk+6e0Cbf0yPzKItFiT+5Yh6TQA4uKvnlqe0oVZwRxCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.2.tgz", + "integrity": "sha512-P7hK1jLVny+0R9UwyGcECxO6sjETxfPyBm/1dmFjnDOHgdDPjPqozByunrwh4xPKld8sxOr5eAsSqal5uKgeBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.2.tgz", + "integrity": "sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.2.tgz", + "integrity": "sha512-/x04YK9+7erw6tYEcJv9WXoBHcULI/wMOvNdAyE9S3JStZZ9yJyV67sWAI+90UHuDo/BDhq0d96LDqGlSVv7WA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.2.tgz", + "integrity": "sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.2.tgz", + "integrity": "sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.2.tgz", + "integrity": "sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.2.tgz", + "integrity": "sha512-9ma7C4g8Sq3cBlRJD2yrsHXB1mnnEBdpy7PhvFrylQWQb4PoyCmPucdX7frvsSBQuFtIiKCrolPl/8tCZrKvgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index c343347..8e05487 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,12 @@ "build": "tsc", "dev": "tsx src/cli.ts", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "biome check src/", + "lint:fix": "biome check --write src/", + "format": "biome format --write src/", + "prepare": "git config core.hooksPath .githooks" }, "author": "Charlie Hulcher ", "license": "MIT", @@ -21,5 +26,8 @@ "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.2" } } diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..add091a --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUMP="${1:-patch}" + +if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then + echo "Usage: $0 [patch|minor|major]" + exit 1 +fi + +# Ensure clean working tree +if [[ -n "$(git status --porcelain)" ]]; then + echo "Error: working tree is not clean. Commit or stash changes first." + exit 1 +fi + +# Bump version +NEW_VERSION=$(npm version "$BUMP" --no-git-tag-version) +echo "Bumped to $NEW_VERSION" + +# Build and test +npm run build +npm test + +# Link locally +npm link + +# Commit and tag +git add package.json package-lock.json +git commit --author="Doink (OpenClaw) " -m "release: $NEW_VERSION" +git tag "$NEW_VERSION" + +echo "" +echo "Released $NEW_VERSION" +echo "Run 'git push && git push --tags' to publish." diff --git a/src/adapters/claude-code.test.ts b/src/adapters/claude-code.test.ts index 13ed035..69047d0 100644 --- a/src/adapters/claude-code.test.ts +++ b/src/adapters/claude-code.test.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ClaudeCodeAdapter } from "./claude-code.js"; +import { ClaudeCodeAdapter, type PidInfo } from "./claude-code.js"; let tmpDir: string; let claudeDir: string; @@ -427,4 +427,195 @@ describe("ClaudeCodeAdapter", () => { expect(ids).toContain("session-b-0000-0000-000000000000"); }); }); + + describe("PID recycling detection", () => { + it("detects recycled PID via cwd match — old session stays stopped", async () => { + const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionModified = new Date("2026-02-17T11:00:00Z"); + // A different process got the same PID — started BEFORE the session + const processStartTime = "Mon Feb 16 08:00:00 2026"; + + await createFakeProject("pid-recycle-test", [ + { + id: "old-session-0000-0000-000000000000", + firstPrompt: "old session", + created: sessionCreated.toISOString(), + modified: sessionModified.toISOString(), + messages: [], + }, + ]); + + const pidMap = new Map(); + pidMap.set(12345, { + pid: 12345, + cwd: "/Users/test/pid-recycle-test", + args: "claude --dangerously-skip-permissions --print", + startTime: processStartTime, + }); + + const adapterWithPids = new ClaudeCodeAdapter({ + claudeDir, + getPids: async () => pidMap, + }); + + const sessions = await adapterWithPids.list({ all: true }); + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe("stopped"); + expect(sessions[0].pid).toBeUndefined(); + }); + + it("legitimate process matches — session shows as running", async () => { + const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionModified = new Date("2026-02-17T10:01:00Z"); + // Process started AFTER the session was created + const processStartTime = "Mon Feb 17 10:00:05 2026"; + + await createFakeProject("legit-running-test", [ + { + id: "running-session-0000-000000000000", + firstPrompt: "currently running", + created: sessionCreated.toISOString(), + modified: sessionModified.toISOString(), + messages: [], + }, + ]); + + const pidMap = new Map(); + pidMap.set(99999, { + pid: 99999, + cwd: "/Users/test/legit-running-test", + args: "claude --dangerously-skip-permissions --print", + startTime: processStartTime, + }); + + const adapterWithPids = new ClaudeCodeAdapter({ + claudeDir, + getPids: async () => pidMap, + }); + + const sessions = await adapterWithPids.list({ all: true }); + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe("running"); + expect(sessions[0].pid).toBe(99999); + }); + + it("detects recycled PID via sessionId in args — old session stays stopped", async () => { + const sessionCreated = new Date("2026-02-17T10:00:00Z"); + const sessionModified = new Date("2026-02-17T11:00:00Z"); + // Process started before the session — recycled PID happens to have + // a matching string in its args (unlikely but possible) + const processStartTime = "Sun Feb 16 08:00:00 2026"; + + await createFakeProject("pid-recycle-args-test", [ + { + id: "args-session-0000-0000-000000000000", + firstPrompt: "args test", + created: sessionCreated.toISOString(), + modified: sessionModified.toISOString(), + messages: [], + }, + ]); + + const pidMap = new Map(); + pidMap.set(54321, { + pid: 54321, + cwd: "/some/other/path", + args: "claude --continue args-session-0000-0000-000000000000", + startTime: processStartTime, + }); + + const adapterWithPids = new ClaudeCodeAdapter({ + claudeDir, + getPids: async () => pidMap, + }); + + const sessions = await adapterWithPids.list({ all: true }); + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe("stopped"); + }); + + it("falls back to running when startTime is unavailable", async () => { + const sessionCreated = new Date("2026-02-17T10:00:00Z"); + + await createFakeProject("no-starttime-test", [ + { + id: "notime-session-0000-000000000000", + firstPrompt: "no start time", + created: sessionCreated.toISOString(), + modified: sessionCreated.toISOString(), + messages: [], + }, + ]); + + const pidMap = new Map(); + pidMap.set(11111, { + pid: 11111, + cwd: "/Users/test/no-starttime-test", + args: "claude --dangerously-skip-permissions", + // No startTime — should assume match (backward compat) + }); + + const adapterWithPids = new ClaudeCodeAdapter({ + claudeDir, + getPids: async () => pidMap, + }); + + const sessions = await adapterWithPids.list({ all: true }); + expect(sessions).toHaveLength(1); + expect(sessions[0].status).toBe("running"); + }); + + it("multiple sessions in same project — only the one matching the PID shows running", async () => { + const oldCreated = new Date("2026-02-16T10:00:00Z"); + const newCreated = new Date("2026-02-17T10:00:00Z"); + // Process started after the new session + const processStartTime = "Mon Feb 17 10:00:01 2026"; + + await createFakeProject("multi-session-project", [ + { + id: "old-multi-session-0000-000000000000", + firstPrompt: "old task", + created: oldCreated.toISOString(), + modified: oldCreated.toISOString(), + messages: [], + }, + { + id: "new-multi-session-0000-000000000000", + firstPrompt: "new task", + created: newCreated.toISOString(), + modified: newCreated.toISOString(), + messages: [], + }, + ]); + + const pidMap = new Map(); + pidMap.set(77777, { + pid: 77777, + cwd: "/Users/test/multi-session-project", + args: "claude --dangerously-skip-permissions --print", + startTime: processStartTime, + }); + + const adapterWithPids = new ClaudeCodeAdapter({ + claudeDir, + getPids: async () => pidMap, + }); + + const sessions = await adapterWithPids.list({ all: true }); + expect(sessions).toHaveLength(2); + + const oldSession = sessions.find( + (s) => s.id === "old-multi-session-0000-000000000000", + ); + const newSession = sessions.find( + (s) => s.id === "new-multi-session-0000-000000000000", + ); + + // Both match by cwd, but the process started after both sessions, + // so both could legitimately be running. This is a cwd ambiguity + // (not PID recycling). The process matches both. + expect(oldSession?.status).toBe("running"); + expect(newSession?.status).toBe("running"); + }); + }); }); diff --git a/src/adapters/claude-code.ts b/src/adapters/claude-code.ts index d8b7a49..aeb83c3 100644 --- a/src/adapters/claude-code.ts +++ b/src/adapters/claude-code.ts @@ -25,6 +25,8 @@ export interface PidInfo { pid: number; cwd: string; args: string; + /** Process start time from `ps -p -o lstart=`, used to detect PID recycling */ + startTime?: string; } export interface ClaudeCodeAdapterOpts { @@ -180,8 +182,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { async status(sessionId: string): Promise { const runningPids = await this.getPids(); const entry = await this.findIndexEntry(sessionId); - if (!entry) - throw new Error(`Session not found: ${sessionId}`); + if (!entry) throw new Error(`Session not found: ${sessionId}`); return this.buildSessionFromIndex(entry.entry, entry.index, runningPids); } @@ -305,7 +306,10 @@ export class ClaudeCodeAdapter implements AgentAdapter { session, timestamp: new Date(), }; - } else if (prev.status === "running" && session.status === "stopped") { + } else if ( + prev.status === "running" && + session.status === "stopped" + ) { yield { type: "session.stopped", adapter: this.id, @@ -313,10 +317,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { session, timestamp: new Date(), }; - } else if ( - prev.status === "running" && - session.status === "idle" - ) { + } else if (prev.status === "running" && session.status === "idle") { yield { type: "session.idle", adapter: this.id, @@ -343,7 +344,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { */ private async getEntriesForProject( projPath: string, - projDirName: string, + _projDirName: string, ): Promise> { // Try index first const indexPath = path.join(projPath, "sessions-index.json"); @@ -358,7 +359,8 @@ export class ClaudeCodeAdapter implements AgentAdapter { // We'll determine originalPath from the JSONL content below let originalPath: string | undefined; - const results: Array<{ entry: SessionIndexEntry; index: SessionIndex }> = []; + const results: Array<{ entry: SessionIndexEntry; index: SessionIndex }> = + []; let files: string[]; try { @@ -372,7 +374,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { const sessionId = file.replace(".jsonl", ""); const fullPath = path.join(projPath, file); - let fileStat; + let fileStat: Awaited> | undefined; try { fileStat = await fs.stat(fullPath); } catch { @@ -435,11 +437,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { index: SessionIndex, runningPids: Map, ): Promise { - const isRunning = await this.isSessionRunning( - entry, - index, - runningPids, - ); + const isRunning = await this.isSessionRunning(entry, index, runningPids); // Parse JSONL for token/model info (read last few lines for efficiency) const { model, tokens } = await this.parseSessionTail(entry.fullPath); @@ -473,10 +471,19 @@ export class ClaudeCodeAdapter implements AgentAdapter { const projectPath = index.originalPath || entry.projectPath; if (!projectPath) return false; + const sessionCreated = new Date(entry.created).getTime(); + for (const [, info] of runningPids) { - if (info.cwd === projectPath) return true; - // Also check if the session ID appears in the command args - if (info.args.includes(entry.sessionId)) return true; + // Check if the session ID appears in the command args — most reliable match + if (info.args.includes(entry.sessionId)) { + if (this.processStartedAfterSession(info, sessionCreated)) return true; + // PID recycling: process started before this session existed + continue; + } + // Match by cwd — less specific (multiple sessions share a project) + if (info.cwd === projectPath) { + if (this.processStartedAfterSession(info, sessionCreated)) return true; + } } // Fallback: check if JSONL was modified very recently (last 60s) @@ -484,8 +491,16 @@ export class ClaudeCodeAdapter implements AgentAdapter { const stat = await fs.stat(entry.fullPath); const age = Date.now() - stat.mtimeMs; if (age < 60_000) { - // Double-check: is there any claude process running? - return runningPids.size > 0; + // Double-check: is there any claude process running with matching cwd + // that started after this session? + for (const [, info] of runningPids) { + if ( + info.cwd === projectPath && + this.processStartedAfterSession(info, sessionCreated) + ) { + return true; + } + } } } catch { // file doesn't exist @@ -494,16 +509,40 @@ export class ClaudeCodeAdapter implements AgentAdapter { return false; } + /** + * Check whether a process plausibly belongs to a session by verifying + * the process started at or after the session's creation time. + * This detects PID recycling: if a process started before the session + * was created, it can't be the process that's running this session. + * When start time is unavailable, defaults to true (assume match). + */ + private processStartedAfterSession( + info: PidInfo, + sessionCreatedMs: number, + ): boolean { + if (!info.startTime) return true; // Can't verify — assume match + const processStartMs = new Date(info.startTime).getTime(); + if (Number.isNaN(processStartMs)) return true; // Unparseable — assume match + // Allow 5s tolerance for clock skew between session creation time and ps output + return processStartMs >= sessionCreatedMs - 5000; + } + private findMatchingPid( entry: SessionIndexEntry, index: SessionIndex, runningPids: Map, ): number | undefined { const projectPath = index.originalPath || entry.projectPath; + const sessionCreated = new Date(entry.created).getTime(); for (const [pid, info] of runningPids) { - if (info.cwd === projectPath) return pid; - if (info.args.includes(entry.sessionId)) return pid; + if (info.args.includes(entry.sessionId)) { + if (this.processStartedAfterSession(info, sessionCreated)) return pid; + continue; + } + if (info.cwd === projectPath) { + if (this.processStartedAfterSession(info, sessionCreated)) return pid; + } } return undefined; @@ -555,16 +594,15 @@ export class ClaudeCodeAdapter implements AgentAdapter { return { model, - tokens: totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined, + tokens: + totalIn || totalOut ? { in: totalIn, out: totalOut } : undefined, }; } catch { return {}; } } - private async findSessionFile( - sessionId: string, - ): Promise { + private async findSessionFile(sessionId: string): Promise { const entry = await this.findIndexEntry(sessionId); if (!entry) return null; try { @@ -602,9 +640,7 @@ export class ClaudeCodeAdapter implements AgentAdapter { return null; } - private async findPidForSession( - sessionId: string, - ): Promise { + private async findPidForSession(sessionId: string): Promise { const session = await this.status(sessionId); return session.pid ?? null; } @@ -616,9 +652,7 @@ async function getClaudePids(): Promise> { const pids = new Map(); try { - const { stdout } = await execFileAsync("ps", [ - "aux", - ]); + const { stdout } = await execFileAsync("ps", ["aux"]); for (const line of stdout.split("\n")) { if (!line.includes("claude") || line.includes("grep")) continue; @@ -655,7 +689,21 @@ async function getClaudePids(): Promise> { // lsof might fail — that's fine } - pids.set(pid, { pid, cwd, args: command }); + // Get process start time for PID recycling detection + let startTime: string | undefined; + try { + const { stdout: lstart } = await execFileAsync("ps", [ + "-p", + pid.toString(), + "-o", + "lstart=", + ]); + startTime = lstart.trim() || undefined; + } catch { + // ps might fail — that's fine + } + + pids.set(pid, { pid, cwd, args: command, startTime }); } } catch { // ps failed — return empty @@ -671,7 +719,7 @@ function extractTextContent( if (Array.isArray(content)) { return content .filter((b) => b.type === "text" && b.text) - .map((b) => b.text!) + .map((b) => b.text as string) .join("\n"); } return ""; diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts index fe52b42..808dc16 100644 --- a/src/adapters/openclaw.ts +++ b/src/adapters/openclaw.ts @@ -84,7 +84,8 @@ export class OpenClawAdapter implements AgentAdapter { constructor(opts?: OpenClawAdapterOpts) { this.baseUrl = opts?.baseUrl || DEFAULT_BASE_URL; - this.authToken = opts?.authToken || process.env.OPENCLAW_WEBHOOK_TOKEN || ""; + this.authToken = + opts?.authToken || process.env.OPENCLAW_WEBHOOK_TOKEN || ""; this.rpcCall = opts?.rpcCall || this.defaultRpcCall.bind(this); } @@ -130,7 +131,9 @@ export class OpenClawAdapter implements AgentAdapter { maxChars: 4000, })) as SessionsPreviewResult; } catch (err) { - throw new Error(`Failed to peek session ${sessionId}: ${(err as Error).message}`); + throw new Error( + `Failed to peek session ${sessionId}: ${(err as Error).message}`, + ); } const preview = result.previews?.[0]; @@ -157,12 +160,17 @@ export class OpenClawAdapter implements AgentAdapter { search: sessionId, })) as SessionsListResult; } catch (err) { - throw new Error(`Failed to get status for ${sessionId}: ${(err as Error).message}`); + throw new Error( + `Failed to get status for ${sessionId}: ${(err as Error).message}`, + ); } const row = result.sessions.find( - (s) => s.sessionId === sessionId || s.key === sessionId || - s.sessionId?.startsWith(sessionId) || s.key.startsWith(sessionId), + (s) => + s.sessionId === sessionId || + s.key === sessionId || + s.sessionId?.startsWith(sessionId) || + s.key.startsWith(sessionId), ); if (!row) throw new Error(`Session not found: ${sessionId}`); @@ -296,8 +304,11 @@ export class OpenClawAdapter implements AgentAdapter { } const row = result.sessions.find( - (s) => s.sessionId === sessionId || s.key === sessionId || - s.sessionId?.startsWith(sessionId) || s.key.startsWith(sessionId), + (s) => + s.sessionId === sessionId || + s.key === sessionId || + s.sessionId?.startsWith(sessionId) || + s.key.startsWith(sessionId), ); return row?.key ?? null; @@ -335,7 +346,8 @@ export class OpenClawAdapter implements AgentAdapter { ws.onmessage = (event: { data: unknown }) => { try { - const raw = typeof event.data === "string" ? event.data : String(event.data); + const raw = + typeof event.data === "string" ? event.data : String(event.data); const frame = JSON.parse(raw); // Step 1: Receive challenge, send connect @@ -384,11 +396,7 @@ export class OpenClawAdapter implements AgentAdapter { if (frame.ok) { resolve(frame.payload); } else { - reject( - new Error( - frame.error?.message || `RPC error: ${method}`, - ), - ); + reject(new Error(frame.error?.message || `RPC error: ${method}`)); } return; } @@ -398,9 +406,7 @@ export class OpenClawAdapter implements AgentAdapter { clearTimeout(timeout); ws.close(); reject( - new Error( - frame.error?.message || "OpenClaw gateway auth failed", - ), + new Error(frame.error?.message || "OpenClaw gateway auth failed"), ); } } catch { diff --git a/src/cli.ts b/src/cli.ts index 5049816..4fa05b7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,7 +43,7 @@ function formatSession(s: AgentSession): Record { function shortenPath(p: string): string { const home = process.env.HOME || ""; - if (p.startsWith(home)) return "~" + p.slice(home.length); + if (p.startsWith(home)) return `~${p.slice(home.length)}`; return p; } @@ -76,7 +76,9 @@ function printTable(rows: Record[]): void { // Rows for (const row of rows) { - const line = keys.map((k, i) => (row[k] || "").padEnd(widths[i])).join(" "); + const line = keys + .map((k, i) => (row[k] || "").padEnd(widths[i])) + .join(" "); console.log(line); } } @@ -163,7 +165,9 @@ program console.log(`${k.padEnd(10)} ${v}`); } if (session.tokens) { - console.log(`Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`); + console.log( + `Tokens in: ${session.tokens.in}, out: ${session.tokens.out}`, + ); } } } catch (err) { @@ -242,7 +246,9 @@ program cwd: opts.cwd, model: opts.model, }); - console.log(`Launched session ${session.id.slice(0, 8)} (PID: ${session.pid})`); + console.log( + `Launched session ${session.id.slice(0, 8)} (PID: ${session.pid})`, + ); } catch (err) { console.error((err as Error).message); process.exit(1); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f612c07 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["dist/**", "node_modules/**"], + }, +});