Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -76,4 +76,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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codez",
"version": "2.9.3",
"version": "2.10.1-better-indicators-1",
"productName": "Codez",
"description": "A macOS desktop app for managing AI coding agent sessions across git worktrees.",
"author": "Daniel Klevebring",
Expand Down
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