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:
parent
032d793550
commit
582c5373dc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue