Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,5 @@ jobs:
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="$TAG" \
--jq '.body')
gh release edit "$TAG" --notes "$NOTES"
PRERELEASE_FLAG=$(echo "${{ steps.version.outputs.version }}" | grep -qE '[-]' && echo '--prerelease' || echo '--no-prerelease')
gh release edit "$TAG" --notes "$NOTES" $PRERELEASE_FLAG
11 changes: 8 additions & 3 deletions src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import {
updateSessionStatus,
} from "./db/sessions.js";
import { applyDockIcon, getIconsDir } from "./dock.js";
import { getHookSettingsPath, getHookSignalPath } from "./paths.js";
import { PtyManager } from "./services/pty-manager.js";
import { SessionLifecycle } from "./services/session-lifecycle.js";
import { SessionStopWatcher } from "./services/session-stop-watcher.js";
import { getShortcutOverrides, readSettings, saveShortcutOverrides, writeSettings } from "./settings.js";
import { createWorktree, getDefaultBranch, listLocalBranches, removeWorktree } from "./worktree/worktree-manager.js";

Expand All @@ -49,9 +51,12 @@ export function registerIpcHandlers(options: RegisterHandlersOptions): void {
});

// --- PTY Manager ---
const ptyManager = new PtyManager((file, args, options) => {
return pty.spawn(file, args, options);
});
const ptyManager = new PtyManager(
(file, args, options) => pty.spawn(file, args, options),
undefined,
(sessionId, onIdle) =>
new SessionStopWatcher(getHookSettingsPath(sessionId), getHookSignalPath(sessionId), onIdle),
);

ptyManager.on("data", (sessionId: string, data: string) => {
const window = getMainWindow();
Expand Down
8 changes: 8 additions & 0 deletions src/main/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,11 @@ export function getDbPath(): string {
export function getSettingsPath(): string {
return path.join(getDataDir(), "settings.json");
}

export function getHookSettingsPath(sessionId: string): string {
return path.join(getDataDir(), "hook-settings", `${sessionId}.json`);
}

export function getHookSignalPath(sessionId: string): string {
return path.join(getDataDir(), "hook-signals", sessionId);
}
106 changes: 103 additions & 3 deletions src/main/services/pty-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ describe("PtyManager", () => {
expect(exits).toEqual([{ sessionId: "session-1", exitCode: 0 }]);
});

it("emits statusChanged from sideband detector after idle timeout", () => {
it("emits statusChanged from sideband detector after idle timeout (fallback for non-claude agents)", () => {
vi.useFakeTimers();
try {
const { manager, getLastPty } = createManager();
Expand All @@ -230,11 +230,11 @@ describe("PtyManager", () => {
statuses.push({ sessionId, status });
});

manager.create("session-1", "claude", "/repo", 80, 24);
manager.create("session-1", "gemini", "/repo", 80, 24);
getLastPty()._emitData("some output");
expect(statuses).toEqual([]);

vi.advanceTimersByTime(500);
vi.advanceTimersByTime(10_000);
expect(statuses).toEqual([{ sessionId: "session-1", status: "waiting_for_input" }]);
} finally {
vi.useRealTimers();
Expand All @@ -250,4 +250,104 @@ describe("PtyManager", () => {
expect(() => manager.write("session-1", "hello")).toThrow();
});
});

describe("stop hook watcher (claude sessions)", () => {
function createMockStopWatcher() {
let idleCallback: (() => void) | null = null;
const watcherDispose = vi.fn();
const factory = vi.fn((sessionId: string, onIdle: () => void) => {
idleCallback = onIdle;
return {
settingsFilePath: `/mock-hook-settings/${sessionId}.json`,
dispose: watcherDispose,
};
});
return {
factory,
watcherDispose,
triggerIdle: () => idleCallback?.(),
};
}

function createManagerWithWatcher() {
const { factory, watcherDispose, triggerIdle } = createMockStopWatcher();
let lastMockPty: MockPty | null = null;
const spawnFn = vi.fn((_file: string, _args: string[], _options: unknown) => {
lastMockPty = createMockPty();
return lastMockPty;
});
const manager = new PtyManager(spawnFn, () => ({}), factory);
return { manager, spawnFn, factory, watcherDispose, triggerIdle, getLastPty: () => lastMockPty as MockPty };
}

it("appends --settings <path> to args for claude sessions when factory is provided", () => {
const { manager, spawnFn } = createManagerWithWatcher();
manager.create("session-1", "claude", "/repo", 80, 24);

const calledArgs = spawnFn.mock.calls[0][1] as string[];
const settingsIndex = calledArgs.indexOf("--settings");
expect(settingsIndex).not.toBe(-1);
expect(calledArgs[settingsIndex + 1]).toBe("/mock-hook-settings/session-1.json");
});

it("does not append --settings for non-claude agents even when factory is provided", () => {
const { manager, spawnFn } = createManagerWithWatcher();
manager.create("session-1", "gemini", "/repo", 80, 24);

const calledArgs = spawnFn.mock.calls[0][1] as string[];
expect(calledArgs).not.toContain("--settings");
});

it("emits waiting_for_input when the stop hook fires", () => {
const { manager, triggerIdle } = createManagerWithWatcher();
const statuses: Array<{ sessionId: string; status: string }> = [];
manager.on("statusChanged", (sessionId: string, status: string) => {
statuses.push({ sessionId, status });
});

manager.create("session-1", "claude", "/repo", 80, 24);
triggerIdle();

expect(statuses).toEqual([{ sessionId: "session-1", status: "waiting_for_input" }]);
});

it("emits running when the user presses Enter while waiting", () => {
const { manager, triggerIdle } = createManagerWithWatcher();
const statuses: Array<{ sessionId: string; status: string }> = [];
manager.on("statusChanged", (sessionId: string, status: string) => {
statuses.push({ sessionId, status });
});

manager.create("session-1", "claude", "/repo", 80, 24);
triggerIdle(); // now waiting_for_input
manager.write("session-1", "\r"); // user presses Enter

expect(statuses).toEqual([
{ sessionId: "session-1", status: "waiting_for_input" },
{ sessionId: "session-1", status: "running" },
]);
});

it("does not emit running on Enter when already running", () => {
const { manager } = createManagerWithWatcher();
const statuses: Array<{ sessionId: string; status: string }> = [];
manager.on("statusChanged", (sessionId: string, status: string) => {
statuses.push({ sessionId, status });
});

manager.create("session-1", "claude", "/repo", 80, 24);
// Still in initial "running" state — pressing Enter should not re-emit running
manager.write("session-1", "\r");

expect(statuses).toEqual([]);
});

it("disposes the watcher on session cleanup", () => {
const { manager, getLastPty, watcherDispose } = createManagerWithWatcher();
manager.create("session-1", "claude", "/repo", 80, 24);
getLastPty()._emitExit(0);

expect(watcherDispose).toHaveBeenCalled();
});
});
});
70 changes: 51 additions & 19 deletions src/main/services/pty-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EventEmitter } from "node:events";
import type { AgentType } from "../../shared/agent-types.js";
import { getShellEnv, parseEnvOutput } from "../shell-env.js";
import type { StopWatcherFactory } from "./session-stop-watcher.js";
import { SidebandDetector } from "./sideband-detector.js";

type ShellEnvProvider = () => Record<string, string>;
Expand All @@ -22,19 +23,30 @@ type SpawnFn = (

interface PtySession {
pty: PtyLike;
detector: SidebandDetector;
// Unified interface — either a SessionStopWatcher (claude) or SidebandDetector (other agents)
statusTracker: { dispose(): void };
// Only set when using SidebandDetector — feeds PTY data into the timing heuristic
detector: SidebandDetector | null;
disposables: Array<{ dispose: () => void }>;
// Tracks status locally so write() can avoid emitting redundant "running" events
currentStatus: "running" | "waiting_for_input";
}

export class PtyManager extends EventEmitter {
private sessions = new Map<string, PtySession>();
private spawnFn: SpawnFn;
private shellEnvProvider: ShellEnvProvider;
private createStopWatcher: StopWatcherFactory | null;

constructor(spawnFn: SpawnFn, shellEnvProvider: ShellEnvProvider = getShellEnv) {
constructor(
spawnFn: SpawnFn,
shellEnvProvider: ShellEnvProvider = getShellEnv,
createStopWatcher: StopWatcherFactory | null = null,
) {
super();
this.spawnFn = spawnFn;
this.shellEnvProvider = shellEnvProvider;
this.createStopWatcher = createStopWatcher;
}

create(
Expand All @@ -53,10 +65,6 @@ export class PtyManager extends EventEmitter {

const binaryName = binaryNameOverride ?? agentType;

// Build args — for Claude, pin each Codez session to a specific
// Claude session ID so multiple sessions in the same repo don't collide.
// First launch: --session-id <id> (assigns our UUID to Claude)
// Subsequent: --resume <id> (resumes that specific conversation)
const args: string[] = [];
if (agentType === "claude") {
if (agentSessionId) {
Expand All @@ -70,12 +78,27 @@ export class PtyManager extends EventEmitter {
args.push(...parsedExtra);
}

// Start with the user's login+interactive shell env so PATH, API keys,
// and other vars from .zshrc/.zprofile are available to the agent.
// The shell was spawned from this process so it inherits process.env too,
// meaning shellEnv is already a superset — use it directly.
// Build status tracker: use the stop hook watcher for claude sessions when
// a factory is available, fall back to the sideband detector otherwise.
let statusTracker: { dispose(): void };
let detector: SidebandDetector | null = null;

if (agentType === "claude" && this.createStopWatcher) {
const watcher = this.createStopWatcher(sessionId, () => {
const session = this.sessions.get(sessionId);
if (session) session.currentStatus = "waiting_for_input";
this.emit("statusChanged", sessionId, "waiting_for_input");
});
args.push("--settings", watcher.settingsFilePath);
statusTracker = watcher;
} else {
detector = new SidebandDetector(agentType, (status) => {
this.emit("statusChanged", sessionId, status);
});
statusTracker = detector;
}

const cleanEnv: Record<string, string> = { ...this.shellEnvProvider() };
// Inject preset-level env vars last so they take priority over shell env.
if (envVarsStr) {
Object.assign(cleanEnv, parseEnvOutput(envVarsStr));
}
Expand All @@ -90,16 +113,12 @@ export class PtyManager extends EventEmitter {
name: "xterm-256color",
});

const detector = new SidebandDetector(agentType, (status) => {
this.emit("statusChanged", sessionId, status);
});

const disposables: Array<{ dispose: () => void }> = [];

disposables.push(
pty.onData((data: string) => {
this.emit("data", sessionId, data);
detector.feed(data);
detector?.feed(data);
}),
);

Expand All @@ -110,13 +129,26 @@ export class PtyManager extends EventEmitter {
}),
);

this.sessions.set(sessionId, { pty, detector, disposables });
this.sessions.set(sessionId, {
pty,
statusTracker,
detector,
disposables,
currentStatus: "running",
});
}

write(sessionId: string, data: string): void {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`No PTY session found for ${sessionId}`);
session.pty.write(data);

// When using the stop hook (no detector), flip to "running" on Enter.
// This gives immediate feedback when the user submits a prompt.
if (session.detector === null && session.currentStatus === "waiting_for_input" && data.includes("\r")) {
session.currentStatus = "running";
this.emit("statusChanged", sessionId, "running");
}
}

resize(sessionId: string, cols: number, rows: number): void {
Expand All @@ -134,7 +166,7 @@ export class PtyManager extends EventEmitter {

killAll(): void {
for (const [, session] of this.sessions) {
session.detector.dispose();
session.statusTracker.dispose();
session.pty.kill();
for (const disposable of session.disposables) {
disposable.dispose();
Expand All @@ -146,7 +178,7 @@ export class PtyManager extends EventEmitter {
private cleanup(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
session.detector.dispose();
session.statusTracker.dispose();
for (const disposable of session.disposables) {
disposable.dispose();
}
Expand Down
Loading
Loading