From ce9db36d91604abdce2e9cd216c056da24e1f5ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 5 Apr 2026 15:45:39 +0000 Subject: [PATCH 1/2] Bump version to 2.10.1-better-indicators-1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 426367e..cb6f786 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codez", - "version": "2.9.3", + "version": "2.10.1-better-indicators-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codez", - "version": "2.9.3", + "version": "2.10.1-better-indicators-1", "hasInstallScript": true, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/package.json b/package.json index 0832a5b..bd0ca7e 100644 --- a/package.json +++ b/package.json @@ -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", From 9a35cabb96c53af4d96784d82d06efb50a32a507 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Mon, 13 Apr 2026 20:26:48 +0200 Subject: [PATCH 2/2] feat: replace silence heuristic with Claude Stop hook for session idle detection Uses Claude Code's Stop hook to emit a signal file when a turn completes, replacing the 10s idle timer. Also fixes shell-quoting the signal file path to handle spaces and special characters. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 3 +- src/main/ipc-handlers.ts | 11 +- src/main/paths.ts | 8 ++ src/main/services/pty-manager.test.ts | 106 +++++++++++++++++- src/main/services/pty-manager.ts | 70 ++++++++---- .../services/session-stop-watcher.test.ts | 104 +++++++++++++++++ src/main/services/session-stop-watcher.ts | 86 ++++++++++++++ src/main/services/sideband-detector.test.ts | 14 +-- src/main/services/sideband-detector.ts | 2 +- 9 files changed, 370 insertions(+), 34 deletions(-) create mode 100644 src/main/services/session-stop-watcher.test.ts create mode 100644 src/main/services/session-stop-watcher.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d94685c..cefa0bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index cf1921c..7955525 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -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"; @@ -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(); diff --git a/src/main/paths.ts b/src/main/paths.ts index 20999f7..3a20eb4 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -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); +} diff --git a/src/main/services/pty-manager.test.ts b/src/main/services/pty-manager.test.ts index e9ae134..4c801a3 100644 --- a/src/main/services/pty-manager.test.ts +++ b/src/main/services/pty-manager.test.ts @@ -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(); @@ -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(); @@ -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 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(); + }); + }); }); diff --git a/src/main/services/pty-manager.ts b/src/main/services/pty-manager.ts index bb955dc..994c8eb 100644 --- a/src/main/services/pty-manager.ts +++ b/src/main/services/pty-manager.ts @@ -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; @@ -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(); 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( @@ -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 (assigns our UUID to Claude) - // Subsequent: --resume (resumes that specific conversation) const args: string[] = []; if (agentType === "claude") { if (agentSessionId) { @@ -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 = { ...this.shellEnvProvider() }; - // Inject preset-level env vars last so they take priority over shell env. if (envVarsStr) { Object.assign(cleanEnv, parseEnvOutput(envVarsStr)); } @@ -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); }), ); @@ -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 { @@ -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(); @@ -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(); } diff --git a/src/main/services/session-stop-watcher.test.ts b/src/main/services/session-stop-watcher.test.ts new file mode 100644 index 0000000..8137dd5 --- /dev/null +++ b/src/main/services/session-stop-watcher.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { SessionStopWatcher } from "./session-stop-watcher"; + +function createMockFsOps() { + let watchListener: (() => void) | null = null; + const watcher = { close: vi.fn() }; + + const fsOps = { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + rmSync: vi.fn(), + watch: vi.fn((_filePath: string, listener: () => void) => { + watchListener = listener; + return watcher; + }), + }; + + return { + fsOps, + watcher, + triggerSignal: () => watchListener?.(), + }; +} + +describe("SessionStopWatcher", () => { + it("writes settings JSON with the hook command on construction", () => { + const { fsOps } = createMockFsOps(); + new SessionStopWatcher("/data/hook-settings/s1.json", "/data/hook-signals/s1", vi.fn(), fsOps); + + const settingsCall = (fsOps.writeFileSync.mock.calls as Array<[string, string]>).find( + (call) => call[0] === "/data/hook-settings/s1.json", + ); + expect(settingsCall).toBeDefined(); + + const written = JSON.parse(settingsCall![1]); + expect(written.hooks.Stop[0].hooks[0].command).toBe("touch '/data/hook-signals/s1'"); + expect(written.hooks.Stop[0].hooks[0].async).toBe(true); + }); + + it("pre-creates the signal file so the watcher can attach", () => { + const { fsOps } = createMockFsOps(); + new SessionStopWatcher("/data/hook-settings/s1.json", "/data/hook-signals/s1", vi.fn(), fsOps); + + const signalCall = (fsOps.writeFileSync.mock.calls as Array<[string, string]>).find( + (call) => call[0] === "/data/hook-signals/s1", + ); + expect(signalCall).toBeDefined(); + expect(signalCall![1]).toBe(""); + }); + + it("calls onIdle when the signal file is touched", () => { + const { fsOps, triggerSignal } = createMockFsOps(); + const onIdle = vi.fn(); + new SessionStopWatcher("/data/hook-settings/s1.json", "/data/hook-signals/s1", onIdle, fsOps); + + triggerSignal(); + expect(onIdle).toHaveBeenCalledOnce(); + }); + + it("calls onIdle every time the signal file changes (one hook fire per turn)", () => { + const { fsOps, triggerSignal } = createMockFsOps(); + const onIdle = vi.fn(); + new SessionStopWatcher("/data/hook-settings/s1.json", "/data/hook-signals/s1", onIdle, fsOps); + + triggerSignal(); + triggerSignal(); + expect(onIdle).toHaveBeenCalledTimes(2); + }); + + it("exposes settingsFilePath for passing as --settings to Claude", () => { + const { fsOps } = createMockFsOps(); + const watcher = new SessionStopWatcher("/data/hook-settings/s1.json", "/data/hook-signals/s1", vi.fn(), fsOps); + expect(watcher.settingsFilePath).toBe("/data/hook-settings/s1.json"); + }); + + it("dispose closes the watcher and removes both files", () => { + const { fsOps, watcher } = createMockFsOps(); + const stopWatcher = new SessionStopWatcher( + "/data/hook-settings/s1.json", + "/data/hook-signals/s1", + vi.fn(), + fsOps, + ); + + stopWatcher.dispose(); + + expect(watcher.close).toHaveBeenCalled(); + expect(fsOps.rmSync).toHaveBeenCalledWith("/data/hook-settings/s1.json", { force: true }); + expect(fsOps.rmSync).toHaveBeenCalledWith("/data/hook-signals/s1", { force: true }); + }); + + it("dispose is safe to call twice", () => { + const { fsOps } = createMockFsOps(); + const stopWatcher = new SessionStopWatcher( + "/data/hook-settings/s1.json", + "/data/hook-signals/s1", + vi.fn(), + fsOps, + ); + + stopWatcher.dispose(); + expect(() => stopWatcher.dispose()).not.toThrow(); + }); +}); diff --git a/src/main/services/session-stop-watcher.ts b/src/main/services/session-stop-watcher.ts new file mode 100644 index 0000000..6028ead --- /dev/null +++ b/src/main/services/session-stop-watcher.ts @@ -0,0 +1,86 @@ +import fs from "node:fs"; +import path from "node:path"; + +interface FsOps { + mkdirSync(dirPath: string, options?: { recursive?: boolean }): void; + writeFileSync(filePath: string, data: string): void; + rmSync(filePath: string, options?: { force?: boolean }): void; + watch(filePath: string, listener: () => void): { close(): void }; +} + +// Wraps a path in single quotes, escaping any embedded single quotes. +// Handles spaces and most special shell characters. +function shellQuote(s: string): string { + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +export type StopWatcherFactory = ( + sessionId: string, + onIdle: () => void, +) => { settingsFilePath: string; dispose(): void }; + +/** + * Manages per-session Claude Code stop hook plumbing. + * + * Writes a settings JSON file that injects a Stop hook into the Claude Code + * session. The hook touches a signal file when Claude finishes a turn. + * A file watcher detects this and calls onIdle — giving us a reliable, + * semantic "Claude is done" signal with no timing heuristics. + */ +export class SessionStopWatcher { + readonly settingsFilePath: string; + private readonly signalFilePath: string; + private readonly fsOps: FsOps; + private watcher: { close(): void } | null = null; + + constructor(settingsFilePath: string, signalFilePath: string, onIdle: () => void, fsOps: FsOps = fs) { + this.settingsFilePath = settingsFilePath; + this.signalFilePath = signalFilePath; + this.fsOps = fsOps; + + fsOps.mkdirSync(path.dirname(settingsFilePath), { recursive: true }); + fsOps.mkdirSync(path.dirname(signalFilePath), { recursive: true }); + + const hookSettings = { + hooks: { + Stop: [ + { + matcher: "", + hooks: [ + { + type: "command", + command: `touch ${shellQuote(signalFilePath)}`, + async: true, + }, + ], + }, + ], + }, + }; + fsOps.writeFileSync(settingsFilePath, JSON.stringify(hookSettings, null, 2)); + + // Pre-create the signal file so fs.watch can attach before the hook fires. + fsOps.writeFileSync(signalFilePath, ""); + + this.watcher = fsOps.watch(signalFilePath, () => { + onIdle(); + }); + } + + dispose(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + try { + this.fsOps.rmSync(this.settingsFilePath, { force: true }); + } catch { + // ignore — file may already be gone + } + try { + this.fsOps.rmSync(this.signalFilePath, { force: true }); + } catch { + // ignore + } + } +} diff --git a/src/main/services/sideband-detector.test.ts b/src/main/services/sideband-detector.test.ts index 3523567..d26e943 100644 --- a/src/main/services/sideband-detector.test.ts +++ b/src/main/services/sideband-detector.test.ts @@ -19,26 +19,26 @@ describe("SidebandDetector", () => { const detector = new SidebandDetector("claude"); detector.feed("some output"); expect(detector.status).toBe("running"); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(10_000); expect(detector.status).toBe("waiting_for_input"); }); it("resets idle timer on new data", () => { const detector = new SidebandDetector("claude"); detector.feed("chunk 1"); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(6_000); expect(detector.status).toBe("running"); detector.feed("chunk 2"); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(6_000); expect(detector.status).toBe("running"); - vi.advanceTimersByTime(200); + vi.advanceTimersByTime(4_001); expect(detector.status).toBe("waiting_for_input"); }); it("goes back to running when new data arrives after idle", () => { const detector = new SidebandDetector("claude"); detector.feed("initial"); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(10_000); expect(detector.status).toBe("waiting_for_input"); detector.feed("new data"); expect(detector.status).toBe("running"); @@ -50,9 +50,9 @@ describe("SidebandDetector", () => { changes.push(status); }); detector.feed("output"); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(10_000); detector.feed("more output"); - vi.advanceTimersByTime(500); + vi.advanceTimersByTime(10_000); expect(changes).toEqual(["waiting_for_input", "running", "waiting_for_input"]); }); diff --git a/src/main/services/sideband-detector.ts b/src/main/services/sideband-detector.ts index 946d07d..86749f5 100644 --- a/src/main/services/sideband-detector.ts +++ b/src/main/services/sideband-detector.ts @@ -3,7 +3,7 @@ import type { AgentType } from "../../shared/agent-types.js"; type SidebandStatus = "running" | "waiting_for_input"; type StatusCallback = (status: SidebandStatus) => void; -const DEFAULT_IDLE_TIMEOUT_MS = 500; +const DEFAULT_IDLE_TIMEOUT_MS = 10_000; /** * Detects whether a PTY-based agent is running or waiting for input.