From 582c5373dc1d6ff958f956aff00fd1d1441815bc Mon Sep 17 00:00:00 2001 From: Priyanshu Choudhary <57816400+Priyanchew@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:21:37 +0530 Subject: [PATCH] =?UTF-8?q?fix(windows):=20QA=20fixes=20=E2=80=94=20sessio?= =?UTF-8?q?n=20attach,=20ao=20stop,=20activity=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli/session: add Windows ao session attach via named pipe relay with raw stdin mode and Ctrl+\ to detach; skip getTmuxActivity on Windows - cli/start: fix ao stop looking for "tr-orchestrator" instead of the actual numbered session (e.g. tr-orchestrator-5) — also fixes Linux - agent-claude-code: fix toClaudeProjectPath dropping Windows drive colon (C:\→C- not C) breaking JSONL lookup; ignore stale JSONL entries from previous sessions in reused worktrees - pty-client: use \r (carriage return) instead of \n for PTY Enter key Addresses blockers W13 (partial), W14. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/session.ts | 143 +++++++++++++++--- packages/cli/src/commands/start.ts | 28 ++-- .../src/__tests__/activity-detection.test.ts | 4 +- .../plugins/agent-claude-code/src/index.ts | 13 +- 4 files changed, 153 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index d66e01353..b06a5801d 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -1,7 +1,8 @@ import { spawn } from "node:child_process"; +import { connect as netConnect } from "node:net"; import chalk from "chalk"; import type { Command } from "commander"; -import { loadConfig, SessionNotRestorableError, WorkspaceMissingError } from "@aoagents/ao-core"; +import { generateConfigHash, isWindows, loadConfig, SessionNotRestorableError, WorkspaceMissingError } from "@aoagents/ao-core"; import { DEFAULT_PORT } from "../lib/constants.js"; import { git, getTmuxActivity, tmux } from "../lib/shell.js"; import { formatAge } from "../lib/format.js"; @@ -64,6 +65,7 @@ export function registerSession(program: Command): void { const activities = await Promise.all( projectSessions.map((s) => { + if (isWindows()) return Promise.resolve(null); const tmuxTarget = s.runtimeHandle?.id ?? s.id; return getTmuxActivity(tmuxTarget).catch(() => null); }), @@ -92,34 +94,133 @@ export function registerSession(program: Command): void { session .command("attach") - .description("Attach to a session's tmux window") + .description("Attach to a session's terminal") .argument("", "Session name to attach") .action(async (sessionName: string) => { const config = loadConfig(); const sm = await getSessionManager(config); const sessionInfo = await sm.get(sessionName); - const tmuxTarget = sessionInfo?.runtimeHandle?.id ?? sessionName; - const exists = await tmux("has-session", "-t", tmuxTarget); - if (exists === null) { - console.error(chalk.red(`Session '${sessionName}' does not exist`)); - process.exit(1); - } + if (isWindows()) { + // Windows: connect to PTY host named pipe and relay raw terminal I/O + const handleId = + sessionInfo?.runtimeHandle?.id ?? + (config.configPath + ? `${generateConfigHash(config.configPath)}-${sessionName}` + : sessionName); + const pipePath = `\\\\.\\pipe\\ao-pty-${handleId}`; - await new Promise((resolve, reject) => { - const child = spawn("tmux", ["attach", "-t", tmuxTarget], { stdio: "inherit" }); - child.once("error", (err) => reject(err)); - child.once("exit", (code) => { - if (code === 0 || code === null) { - resolve(); - return; - } - reject(new Error(`tmux attach exited with code ${code}`)); + const sock = netConnect(pipePath); + + sock.on("error", (err: Error) => { + console.error(chalk.red(`Cannot attach to ${sessionName}: ${err.message}`)); + process.exit(1); }); - }).catch((err) => { - console.error(chalk.red(`Failed to attach to session ${sessionName}: ${err}`)); - process.exit(1); - }); + + sock.on("connect", () => { + // Raw mode so keystrokes pass through directly (like tmux attach) + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + + // Binary protocol framing buffer + let buf = Buffer.alloc(0); + + // PTY host → stdout + sock.on("data", (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + while (buf.length >= 5) { + const msgType = buf.readUInt8(0); + const len = buf.readUInt32BE(1); + if (buf.length < 5 + len) break; + const payload = buf.subarray(5, 5 + len); + buf = buf.subarray(5 + len); + + // 0x01 = MSG_TERMINAL_DATA + if (msgType === 0x01) { + process.stdout.write(payload); + } + // 0x07 = MSG_STATUS_RES (PTY exited) + if (msgType === 0x07) { + try { + const status = JSON.parse(payload.toString()) as { alive: boolean; exitCode?: number }; + if (!status.alive) { + cleanup(); + console.log(`\n[session exited with code ${status.exitCode ?? "unknown"}]`); + process.exit(status.exitCode ?? 0); + } + } catch { /* ignore parse errors */ } + } + } + }); + + // stdin → PTY host (MSG_TERMINAL_INPUT = 0x02) + // Ctrl+\ (0x1c) = detach without killing (like tmux Ctrl+B,D) + process.stdin.on("data", (data: Buffer) => { + if (data.length === 1 && data[0] === 0x1c) { + console.log("\n[detached]"); + cleanup(); + process.exit(0); + return; + } + const header = Buffer.alloc(5); + header.writeUInt8(0x02, 0); + header.writeUInt32BE(data.length, 1); + sock.write(Buffer.concat([header, data])); + }); + + // Send terminal resize (MSG_RESIZE = 0x03) + const sendResize = () => { + const payload = Buffer.from( + JSON.stringify({ cols: process.stdout.columns, rows: process.stdout.rows }), + ); + const header = Buffer.alloc(5); + header.writeUInt8(0x03, 0); + header.writeUInt32BE(payload.length, 1); + sock.write(Buffer.concat([header, payload])); + }; + process.stdout.on("resize", sendResize); + sendResize(); // send initial size + + const cleanup = () => { + if (process.stdin.isTTY) process.stdin.setRawMode(false); + sock.destroy(); + }; + + sock.on("close", () => { + cleanup(); + process.exit(0); + }); + }); + + // Keep process alive until pipe closes + await new Promise(() => {}); + } else { + // Unix: tmux attach (unchanged) + const tmuxTarget = sessionInfo?.runtimeHandle?.id ?? sessionName; + + const exists = await tmux("has-session", "-t", tmuxTarget); + if (exists === null) { + console.error(chalk.red(`Session '${sessionName}' does not exist`)); + process.exit(1); + } + + await new Promise((resolve, reject) => { + const child = spawn("tmux", ["attach", "-t", tmuxTarget], { stdio: "inherit" }); + child.once("error", (err) => reject(err)); + child.once("exit", (code) => { + if (code === 0 || code === null) { + resolve(); + return; + } + reject(new Error(`tmux attach exited with code ${code}`)); + }); + }).catch((err) => { + console.error(chalk.red(`Failed to attach to session ${sessionName}: ${err}`)); + process.exit(1); + }); + } }); session diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 8bf282775..6116a9cd8 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -616,7 +616,7 @@ async function autoCreateConfig(workingDir: string): Promise console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents.\n")); } - if (!env.hasTmux) { + if (!env.hasTmux && getDefaultRuntime() === "tmux") { console.log(chalk.yellow("⚠ tmux not found — will prompt to install at startup")); } if (!env.hasGh) { @@ -1435,22 +1435,32 @@ export function registerStop(program: Command): void { const config = loadConfig(); const { projectId: _projectId, project } = await resolveProject(config, projectArg, "stop"); - const sessionId = `${project.sessionPrefix}-orchestrator`; const port = config.port ?? DEFAULT_PORT; console.log(chalk.bold(`\nStopping orchestrator for ${chalk.cyan(project.name)}\n`)); - // Kill orchestrator session via SessionManager + // Find the actual running orchestrator session (numbered, e.g. tr-orchestrator-5) const sm = await getSessionManager(config); - const existing = await sm.get(sessionId); + const allSessions = await sm.list(_projectId); + const allSessionPrefixes = Object.entries(config.projects).map( + ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), + ); + const orchestratorSessions = allSessions.filter( + (s) => + isOrchestratorSession(s, project.sessionPrefix ?? _projectId, allSessionPrefixes) && + !isTerminalSession(s), + ); - if (existing) { - const spinner = ora("Stopping orchestrator session").start(); + if (orchestratorSessions.length > 0) { + const spinner = ora("Stopping orchestrator session(s)").start(); const purgeOpenCode = opts?.purgeSession === true; - await sm.kill(sessionId, { purgeOpenCode }); - spinner.succeed("Orchestrator session stopped"); + for (const orch of orchestratorSessions) { + await sm.kill(orch.id, { purgeOpenCode }); + } + const names = orchestratorSessions.map((s) => s.id).join(", "); + spinner.succeed(`Orchestrator session(s) stopped: ${names}`); } else { - console.log(chalk.yellow(`Orchestrator session "${sessionId}" is not running`)); + console.log(chalk.yellow("No running orchestrator sessions found")); } const lifecycleStopped = await stopLifecycleWorker(config, _projectId); diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index a2be12ee6..51b1db271 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -31,7 +31,7 @@ function makeSession(overrides: Partial = {}): Session { workspacePath, runtimeHandle: handle, agentInfo: null, - createdAt: new Date(), + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago (well before any JSONL entries) lastActivityAt: new Date(), metadata: {}, ...overrides, @@ -71,7 +71,7 @@ describe("Claude Code Activity Detection", () => { }); it("handles Windows paths (no leading slash)", () => { - expect(toClaudeProjectPath("C:\\Users\\dev\\project")).toBe("C-Users-dev-project"); + expect(toClaudeProjectPath("C:\\Users\\dev\\project")).toBe("C--Users-dev-project"); }); it("handles consecutive dots and slashes", () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 426c7f405..d7f4e26b8 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -383,10 +383,11 @@ export const manifest = { * Exported for testing purposes. */ export function toClaudeProjectPath(workspacePath: string): string { - // Handle Windows drive letters (C:\Users\... → C-Users-...) + // Claude Code encodes project paths by replacing special chars with dashes. + // On Windows: C:\Users\foo → C--Users-foo (colon becomes dash, backslash becomes dash) + // On Unix: /home/foo → -home-foo (leading slash becomes leading dash) const normalized = workspacePath.replace(/\\/g, "/"); - // Claude Code replaces / and . with - (keeping the leading slash as a leading -) - return normalized.replace(/:/g, "").replace(/[/.]/g, "-"); + return normalized.replace(/[/:. ]/g, "-"); } /** Find the most recently modified .jsonl session file in a directory */ @@ -940,6 +941,12 @@ function createClaudeCodeAgent(): Agent { return null; } + // If the JSONL entry predates this session, it's from a previous session + // in the same worktree. Treat as no data (agent hasn't written yet). + if (session.createdAt && entry.modifiedAt < session.createdAt) { + return { state: "idle", timestamp: session.createdAt }; + } + const ageMs = Date.now() - entry.modifiedAt.getTime(); const timestamp = entry.modifiedAt;