fix(windows): QA fixes — session attach, ao stop, activity detection

- 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) <noreply@anthropic.com>
This commit is contained in:
Priyanshu Choudhary 2026-04-10 17:21:37 +05:30
parent 032d793550
commit 582c5373dc
4 changed files with 153 additions and 35 deletions

View File

@ -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>", "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<void>((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<void>((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

View File

@ -616,7 +616,7 @@ async function autoCreateConfig(workingDir: string): Promise<OrchestratorConfig>
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);

View File

@ -31,7 +31,7 @@ function makeSession(overrides: Partial<Session> = {}): 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", () => {

View File

@ -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;