From c0ce6cb1a9de0258cd0aac8c51ba8d6b855c561c Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Tue, 27 Oct 2026 14:53:48 -0700 Subject: [PATCH] =?UTF-8?q?1.48.0:=20lighter=20at=20rest=20=E2=80=94=20sca?= =?UTF-8?q?n,=20probe,=20git=20and=20the=20background=20listing=20only=20w?= =?UTF-8?q?hen=20they=20can=20matter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on Windows with two idle sessions (1.47.0, 15 min): ~5% of a core, mostly periodic work re-reading what the window already showed. - The activity tick reads a terminal's screen tail only after the terminal was written to (output, a replayed screen, a repaint), and for two more seconds while xterm catches up; a quiet terminal keeps its last reading. - The agent probe (a listing of every process: PowerShell on Windows, ~500 ms) runs for a tile only after it printed since the last probe. Selecting a tile and the update restart still probe outright. - Git (rev-parse + status, more for a workspace) runs every 30 s for the selected tile, every fourth tick for the others, not at all while the window is hidden. - The background-session listing (a claude process, ~340 ms, here and on every connected machine) runs every 30 s while focused with rows, every minute when the group is empty, every two minutes unfocused. The rules are pure functions in src/shared/idleCadence.ts, with tests. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LdbKDr2tTfCLAZv7WbV8kX --- README.md | 2 +- docs/releases/1.48.0.md | 20 ++++++++++ package-lock.json | 4 +- package.json | 2 +- src/renderer/cockpitView.ts | 51 +++++++++++++++++++++--- src/shared/idleCadence.test.ts | 43 ++++++++++++++++++++ src/shared/idleCadence.ts | 73 ++++++++++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 docs/releases/1.48.0.md create mode 100644 src/shared/idleCadence.test.ts create mode 100644 src/shared/idleCadence.ts diff --git a/README.md b/README.md index 7afe853..b8465b0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ See what needs attention, resume the right conversation, and work on a paired PC -> **1.47.1** makes closing a Claude Code background session from the sidebar work when Claude Code's background service is not running — DevDeck starts it as Claude Code does and tries again, and a failure names the PC the command has to run on ([release notes](docs/releases/1.47.1.md)). 1.47.0 moves to Electron 44 (Chromium 152, Node.js 24); macOS 13 or later is now required, and screenshot paste / image copy use Electron's new clipboard API ([release notes](docs/releases/1.47.0.md)). 1.46.0 lets you close Claude Code's background sessions from the sidebar — the row's ⋯ menu or the group's bulk close runs `claude stop` and `claude rm` for you, so a finished or failed session no longer sits in the Background group forever; its conversation is kept ([release notes](docs/releases/1.46.0.md)). 1.45.2 stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md). +> **1.48.0** makes DevDeck lighter at rest — a quiet terminal is no longer re-scanned every second, the process listing that detects a tile's agent runs only after that tile printed something, git is re-read every two minutes for tiles you are not looking at, and the background-session listing slows to a minute when the group is empty and two when the window is not focused ([release notes](docs/releases/1.48.0.md)). 1.47.1 makes closing a Claude Code background session from the sidebar work when Claude Code's background service is not running — DevDeck starts it as Claude Code does and tries again, and a failure names the PC the command has to run on ([release notes](docs/releases/1.47.1.md)). 1.47.0 moves to Electron 44 (Chromium 152, Node.js 24); macOS 13 or later is now required, and screenshot paste / image copy use Electron's new clipboard API ([release notes](docs/releases/1.47.0.md)). 1.46.0 lets you close Claude Code's background sessions from the sidebar — the row's ⋯ menu or the group's bulk close runs `claude stop` and `claude rm` for you, so a finished or failed session no longer sits in the Background group forever; its conversation is kept ([release notes](docs/releases/1.46.0.md)). 1.45.2 stops an update from doubling the sessions on a paired PC — the relaunch no longer restores remote tiles itself, it adopts them from that PC, and no restore starts a second conversation for one that is already open here ([release notes](docs/releases/1.45.2.md)). 1.45.1 stops offering Claude background sessions whose folder was deleted, and says so plainly when a session's folder is gone ([release notes](docs/releases/1.45.1.md)). 1.45.0 sends any file to a session on another PC — any kind, up to 4 GB, in pieces with its progress shown ([release notes](docs/releases/1.45.0.md)). 1.44.1 fixes the "Assertion failed: remove_pty_baton" dialog (and the occasional hang or crash) when quitting or running Update all with several sessions open ([release notes](docs/releases/1.44.1.md)). 1.44.0 lists Claude Code's own background sessions (`claude --bg`, the agent view) in the sidebar, marks the ones waiting for you, and opens one in a tile by attaching to it ([release notes](docs/releases/1.44.0.md)). 1.43.0 runs parallel sessions in separate workspaces — each in its own git worktree and branch, so two agents never edit the same files — with its changes in the header and a clean-up that warns before unmerged work is lost ([release notes](docs/releases/1.43.0.md)). 1.42.0 shows what changed — a What's new window after each update, with Show me, and NEW dots on new buttons until you try them — and, as an experimental setting on Windows, lets Claude Code report its own state (working, needs approval, asking, done) instead of DevDeck reading the screen ([release notes](docs/releases/1.42.0.md)). 1.41.0 adds quick replies (one click or Ctrl+Shift+1…9 for the phrases you keep typing), a session timeline of your prompts with their times, replies and images, red boxes and arrows drawn on a screenshot and sent straight back to the session, and text, log and PDF attachments for sessions on another PC ([release notes](docs/releases/1.41.0.md)). 1.40.0 ties each screenshot to the terminal row it appeared on — hover a highlighted `[Image]` for a thumbnail, click for the full view, and jump from the viewer back to the row — says what led to each image, previews new screenshots, copies images, and adds Ctrl+↑/↓ between your prompts ([release notes](docs/releases/1.40.0.md)). 1.39.0 shows the screenshots and images a Claude session worked with — Claude Desktop shows them inline; DevDeck puts them in a strip beside the terminal, also for sessions on another PC — adds 📎 / drag-and-drop attachments (images travel to a remote PC), **Update all** for Claude Code on Windows, staggered session restores that no longer stall a PC, and ends the daily false "Login expired" ([release notes](docs/releases/1.39.0.md)). 1.38.0 made closing a session reliable across paired PCs ([notes](docs/releases/1.38.0.md)). See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md).
DevDeck project overview with repository and session navigation diff --git a/docs/releases/1.48.0.md b/docs/releases/1.48.0.md new file mode 100644 index 0000000..cf847a4 --- /dev/null +++ b/docs/releases/1.48.0.md @@ -0,0 +1,20 @@ +# DevDeck 1.48.0 + +## Lighter at rest + +Measured on Windows 11 with two sessions open and nothing happening (DevDeck 1.47.0, 15 minutes): about 5% of one core — main 1.7%, renderer 2.3%, GPU 1% — and some 590 MB of working set across the five processes. Most of the CPU went to periodic work that re-read what the window was already showing. This release keeps each of those for the moments it can matter; nothing shown changes. + +- **A quiet terminal is not re-scanned every second.** The activity tick reads the last 12 rows of every live terminal's buffer once a second to tell working from waiting. A terminal that was not written to since the last tick still shows what it showed then, so the scan is skipped — it runs after each write, and for two more seconds while xterm catches up with it. +- **The process listing that detects a tile's agent runs only after that tile printed something.** Every 30 s each tile asked main which agent runs under its shell; main answered off a listing of every process on the machine — a PowerShell process on Windows, about 500 ms and 80 MB each time, cached 15 s. A tile that printed nothing cannot have changed agent. The listing still runs on selecting a tile, on the update restart, and whenever a tile has output. +- **Git is re-read every two minutes for tiles you are not looking at.** Every 30 s each tile ran `git rev-parse` and `git status` (and more for a workspace) for the branch and dirty count in its row. The selected tile keeps its 30 s; the other rows refresh every fourth tick, and nothing is re-read while the window is hidden. Selecting a tile refreshes it at once, as before. +- **The background-session listing slows down when there is nothing to watch.** `claude agents --json --all` starts a Claude Code process (~340 ms) every 30 s, on this PC and on every connected one. It stays at 30 s while the window is focused and the group has rows; a minute when the group is empty everywhere (a new `claude --bg` still shows within a minute); two minutes while the window is not focused. + +With two idle sessions this removes two git processes and one PowerShell process per 30 s, two Claude Code processes per minute, and about two terminal-buffer scans per second — the scheduled process starts at rest drop from roughly 9 per minute to 1. + +## What was looked at and left alone + +- The 250 ms spinner tick already pauses when the window is unfocused or hidden, and does nothing when no row is working. +- The 5 s image poll reads only the selected Claude session, and costs one `stat` while it is idle. +- The 15 s projects re-scan runs only while the projects view is active and the window focused. +- The session-meta read (model, active time, context) folds only the lines appended since the last read; the usage footer polls every 5 minutes; the link heartbeat is one small frame per 15 s per machine. +- Memory: the renderer's share is mostly the terminals' 10,000-line scrollbacks and xterm's DOM rows; the GPU process (~135 MB) is Chromium's compositor and does not grow with sessions. diff --git a/package-lock.json b/package-lock.json index a33d9d2..bb72fe1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.47.1", + "version": "1.48.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.47.1", + "version": "1.48.0", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index 6d33778..20eebe5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.47.1", + "version": "1.48.0", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs", diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index df6d983..e972e81 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -22,6 +22,7 @@ import { setActiveUsageProvider } from './usageBar'; import { reportShutdownActivity } from './shutdown'; import { createIcon, type IconName } from './icons'; import { deckFor, machineName, machineState, onMachineConnected, onMachinesChanged, LOCAL_MACHINE_ID, HOST_TOO_OLD } from './machineDeck'; +import { agentProbeDue, backgroundPollDue, gitRefreshDue, screenScanDue } from '../shared/idleCadence'; import { parseRemoteId } from '../shared/link/machine'; import { ptyCompatFor } from './ptyCompat'; import { followPtySize, type TerminalDims } from '../shared/terminalSize'; @@ -52,6 +53,12 @@ interface Live { */ native: { status: ProgramStatus; since: number } | null; openedSessionId: string | null; openedAt: number; idCheckAt: number; + /** When the tile's agent was last probed off the process tree — a quiet tile is not probed again. */ + providerCheckAt: number; + /** When the terminal was last written to (output, a replayed screen, a repaint) — unlike lastDataAt, never withheld. */ + screenDirtyAt: number; + /** The screen tail the activity tick last read, and when — reused while nothing is written. */ + screenText: string; screenScanAt: number; labelVersion: SessionLabelVersion | null; customLabel: string | null; meta: SessionMetaView | null; pinned: boolean; lastSelectedAt: number; /** @@ -181,7 +188,14 @@ export function mountCockpit(): void { initTimeline(termsEl, () => { if (!editingId) renderHeader(); }); // Not at start-up: restoring sessions comes first, and the list is not urgent. setTimeout(() => void refreshBackgroundAgents(), BACKGROUND_FIRST_POLL_MS); - setInterval(() => { if (!document.hidden) void refreshBackgroundAgents(); }, BACKGROUND_POLL_MS); + // Each listing starts a claude process (~340 ms) here and on every connected machine — so 30 s only + // while someone is looking at a group that has rows; a minute when it is empty, two unfocused. + setInterval(() => { + if (document.hidden) return; + // A background session opened in a tile is still in the listing, so the listing alone tells. + const hasRows = [...backgroundAgents.values()].some((list) => list.length > 0); + if (backgroundPollDue({ now: Date.now(), lastPollMs: backgroundPolledAt, windowFocused: document.hasFocus(), hasRows })) void refreshBackgroundAgents(); + }, BACKGROUND_TICK_MS); // Mid-turn, new screenshots show up as they are taken rather than at the end of the turn; between // turns a slow re-read still catches images written some other way (another process, a resume). // Only the selected session is ever read, and an unchanged transcript costs one stat. @@ -223,6 +237,7 @@ export function mountCockpit(): void { // Held back only while a repaint is in flight (see Live.replayPending) so the screen underneath // cannot land on top of newer output. Activity tracking is not gated: the session IS producing. if (l.replayPending) holdForReplay(l, chunk); else l.term.write(chunk); + l.screenDirtyAt = Date.now(); // the screen changes either way — a repaint too, which is not counted as output below // ConPTY answers every size change with a full repaint of the screen. Counted as output, one window // resize (or the usage bar appearing) turned every idle session "working" and reshuffled the // recency order; and the repainted screen's OLD text could read as a fresh prompt. @@ -615,7 +630,9 @@ export function cockpitNavigationItems(): ShellSessionInput[] { // ---- Claude Code's own background sessions ---- /** Per machine, the background sessions its `claude agents` lists (see main/backgroundAgents). */ const backgroundAgents = new Map(); -const BACKGROUND_POLL_MS = 30_000; +/** The poll's own tick; whether a listing is due on it is `backgroundPollDue`'s call. */ +const BACKGROUND_TICK_MS = 30_000; +let backgroundPolledAt = 0; const BACKGROUND_FIRST_POLL_MS = 15_000; function backgroundNavigationId(machineId: string, id: string): string { return `bg:${machineId}:${id}`; } @@ -624,6 +641,7 @@ function backgroundNavigationId(machineId: string, id: string): string { return async function refreshBackgroundAgents(): Promise { // "Update all" stops every claude before replacing it; a listing would start one again. if (claudeUpdateRunning) return; + backgroundPolledAt = Date.now(); const machines = [LOCAL_MACHINE_ID, ...[...live.values()].map((l) => l.machineId).filter((m) => m !== LOCAL_MACHINE_ID && machineState(m) === 'connected')]; let changed = false; for (const machineId of new Set(machines)) { @@ -1261,6 +1279,7 @@ async function buildTile(p: OpenReq): Promise { machineId, tileId: adopted.tileId ?? createCockpitTileId(), session, term, fit, search, el, lastDataAt: Date.now(), lastInputAt: 0, recentOutput: '', native: null, openedSessionId: res.sessionId ?? null, openedAt: Date.now(), idCheckAt: Date.now(), + providerCheckAt: 0, screenDirtyAt: Date.now(), screenText: '', screenScanAt: 0, labelVersion: hasLabelVersion(res) ? { instanceId: res.instanceId, labelRevision: res.labelRevision } : null, customLabel: adopting ? adopted.label : null, meta: null, pinned: adopted.pinned, lastSelectedAt: Date.now(), offline: false, detachedAt: null, offlineToldAt: 0, @@ -1543,6 +1562,7 @@ function flushReplayHold(l: Live): void { const held = l.replayPending; l.replayPending = null; if (held?.length) l.term.write(held.join('')); + l.screenDirtyAt = Date.now(); // the replayed screen (and anything held behind it) is on the terminal now } /** @@ -1831,8 +1851,12 @@ async function refreshSessionId(id: string): Promise { * store. Main answers from the pty's process tree; null (bare prompt / probe failed) keeps what we have. * On a change the tile's session id is dropped: it named a conversation in the OLD provider's store, * and the id probe re-adopts the new provider's live one on the next tick. */ -async function refreshProvider(id: string): Promise { +async function refreshProvider(id: string, opts: { onlyIfOutput?: boolean } = {}): Promise { const l = live.get(id); if (!l || l.session.status === 'exited') return; + // Main answers off a listing of every process (PowerShell on Windows, ~500 ms); only a tile that + // printed since the last probe can be running something else. + if (opts.onlyIfOutput && !agentProbeDue({ lastDataAt: l.lastDataAt, probedAt: l.providerCheckAt })) return; + l.providerCheckAt = Date.now(); // before the hop: output during the probe is seen next tick, not lost let actual: AgentId | null = null; try { actual = await deckFor(l.machineId).cockpit.liveAgent(id); } catch { return; } if (!actual || actual === l.session.agentId || !live.has(id)) return; // tile may have closed mid-await @@ -1873,7 +1897,21 @@ async function refreshGit(id: string): Promise { // re-spawning git every tick is pure waste (matters most with many concurrent sessions). // The provider check runs BEFORE the id check so a re-attributed tile resolves its new id against the // right store on the same tick (one process listing in main is shared by every tile). -function refreshAllMeta(): void { if (editingId) return; for (const [id, l] of live) { if (l.session.status === 'exited') continue; void refreshProvider(id).then(() => refreshSessionId(id)); void refreshMeta(id); void refreshGit(id); void refreshWorktree(id); } } +let metaTick = 0; +function refreshAllMeta(): void { + if (editingId) return; + const tick = metaTick++; + for (const [id, l] of live) { + if (l.session.status === 'exited') continue; + // The agent probe lists every process on the machine; a tile that printed nothing cannot have + // changed agent, so it keeps what it has (the update-restart path still probes every tile). + void refreshProvider(id, { onlyIfOutput: true }).then(() => refreshSessionId(id)); + void refreshMeta(id); + // Git is two processes per tile, more for a workspace: the selected tile every tick, the + // others' rows every fourth, none while the window is hidden. A select refreshes at once. + if (gitRefreshDue({ tick, selected: id === selectedId, windowHidden: document.hidden })) { void refreshGit(id); void refreshWorktree(id); } + } +} /** * Hide every terminal in the pane, by DOM rather than by walking `live`. @@ -2014,7 +2052,10 @@ function tickActivity(): void { let changed = false; for (const l of live.values()) { const prev = l.session.activity; - const screenText = liveScreenTail(l); + // Reading the screen walks up to 12 rows of the terminal's buffer; a tile whose terminal was not + // written to since the last tick (and a moment for xterm to draw it) still shows what it showed then. + if (screenScanDue({ writtenAt: l.screenDirtyAt, scannedAt: l.screenScanAt })) { l.screenScanAt = now; l.screenText = liveScreenTail(l); } + const screenText = l.screenText; // spinnerReliable: only Claude's spinner glyph is one we match, so only there can we trust "spinner // gone ⇒ turn" and skip the timing hysteresis (avoids 작업중 lingering ~10s after each Claude turn). const next = l.offline && l.session.status !== 'exited' ? 'offline' diff --git a/src/shared/idleCadence.test.ts b/src/shared/idleCadence.test.ts new file mode 100644 index 0000000..db45695 --- /dev/null +++ b/src/shared/idleCadence.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { agentProbeDue, backgroundPollDue, gitRefreshDue, screenScanDue } from './idleCadence'; + +describe('backgroundPollDue — listing background sessions costs a claude process (~340 ms)', () => { + const base = { now: 100_000, lastPollMs: 0, windowFocused: true, hasRows: true }; + it('every 30 s while the window is focused and there are rows to keep current', () => { + expect(backgroundPollDue({ ...base, now: 29_999 })).toBe(false); + expect(backgroundPollDue({ ...base, now: 30_000 })).toBe(true); + }); + it('every minute when the group is empty everywhere — a new claude --bg still shows within a minute', () => { + expect(backgroundPollDue({ ...base, hasRows: false, now: 59_999 })).toBe(false); + expect(backgroundPollDue({ ...base, hasRows: false, now: 60_000 })).toBe(true); + }); + it('every two minutes while the window is not focused, rows or not', () => { + expect(backgroundPollDue({ ...base, windowFocused: false, now: 119_999 })).toBe(false); + expect(backgroundPollDue({ ...base, windowFocused: false, hasRows: false, now: 120_000 })).toBe(true); + }); +}); + +describe('gitRefreshDue — two git processes per tile, more for a workspace', () => { + it('re-reads the selected tile on every tick', () => { + for (const tick of [0, 1, 2, 3, 4, 5]) expect(gitRefreshDue({ tick, selected: true, windowHidden: false })).toBe(true); + }); + it('re-reads the other tiles every fourth tick (two minutes): their row shows only branch and dirty count', () => { + expect([0, 1, 2, 3, 4, 5, 8].map((tick) => gitRefreshDue({ tick, selected: false, windowHidden: false }))).toEqual([true, false, false, false, true, false, true]); + }); + it('re-reads nothing while the window is hidden — no row is on screen', () => { + expect(gitRefreshDue({ tick: 0, selected: true, windowHidden: true })).toBe(false); + expect(gitRefreshDue({ tick: 4, selected: false, windowHidden: true })).toBe(false); + }); +}); + +describe('a quiet terminal asks for nothing', () => { + it('the agent probe (a listing of every process) only after the tile printed something', () => { + expect(agentProbeDue({ lastDataAt: 1_000, probedAt: 1_000 })).toBe(false); + expect(agentProbeDue({ lastDataAt: 1_001, probedAt: 1_000 })).toBe(true); + }); + it('the screen scan after the terminal was written to, and for two more seconds while xterm catches up', () => { + expect(screenScanDue({ writtenAt: 5_001, scannedAt: 5_000 })).toBe(true); // written since the last scan + expect(screenScanDue({ writtenAt: 5_000, scannedAt: 6_000 })).toBe(true); // scanned 1 s after the write: xterm may not have drawn it yet + expect(screenScanDue({ writtenAt: 5_000, scannedAt: 7_000 })).toBe(false); // settled: nothing new to read + }); +}); diff --git a/src/shared/idleCadence.ts b/src/shared/idleCadence.ts new file mode 100644 index 0000000..8bbe4ac --- /dev/null +++ b/src/shared/idleCadence.ts @@ -0,0 +1,73 @@ +// How often the cockpit's periodic work runs while nobody is doing anything. +// +// Measured on Windows with two sessions open (DevDeck 1.47.0): the cockpit spent about 5% of a core +// at rest, most of it in work that could not have changed anything — a PowerShell process listing +// (~500 ms) and two git processes per tile every 30 s to re-read what the tiles were already showing, +// a `claude agents` process (~340 ms) every 30 s to list background sessions for a group that was +// empty, and a scan of every terminal's screen once a second whether or not it had printed anything. +// These rules keep each of those for the moments they can matter. + +/** The sidebar's background-session poll: 30 s while someone is looking and there is something to watch. */ +export const BACKGROUND_POLL_MS = 30_000; +/** The group is empty: a new `claude --bg` shows within a minute, which is soon enough for a list that is usually empty. */ +export const BACKGROUND_POLL_EMPTY_MS = 60_000; +/** The window is not focused: a list nobody is looking at can lag two minutes. */ +export const BACKGROUND_POLL_UNFOCUSED_MS = 120_000; + +export interface BackgroundPollCtx { + now: number; + lastPollMs: number; + windowFocused: boolean; + /** Any background session listed on any machine, or a background tile open. */ + hasRows: boolean; +} + +/** Whether the background-session listing is due on this tick. */ +export function backgroundPollDue(c: BackgroundPollCtx): boolean { + const interval = !c.windowFocused ? BACKGROUND_POLL_UNFOCUSED_MS : c.hasRows ? BACKGROUND_POLL_MS : BACKGROUND_POLL_EMPTY_MS; + return c.now - c.lastPollMs >= interval; +} + +/** The 30 s meta tick: git branch / dirty count and workspace changes for the selected tile every tick … */ +export const GIT_REFRESH_EVERY_TICKS_SELECTED = 1; +/** … and for the other tiles every fourth (two minutes): their row shows branch and dirty count, nothing more. */ +export const GIT_REFRESH_EVERY_TICKS_OTHERS = 4; + +export interface GitRefreshCtx { + /** The meta tick's running count. */ + tick: number; + selected: boolean; + /** The window is hidden (tray, minimised): no row is on screen. */ + windowHidden: boolean; +} + +/** Whether this tick re-reads a tile's git state (two git processes; more for a workspace). */ +export function gitRefreshDue(c: GitRefreshCtx): boolean { + if (c.windowHidden) return false; + const every = c.selected ? GIT_REFRESH_EVERY_TICKS_SELECTED : GIT_REFRESH_EVERY_TICKS_OTHERS; + return c.tick % every === 0; +} + +/** + * Whether a tile's agent can have changed since it was last probed: only if its terminal printed + * anything. The probe lists every process on the machine (PowerShell on Windows, ~500 ms), so a + * quiet tile must not ask for it. + */ +export function agentProbeDue(c: { lastDataAt: number; probedAt: number }): boolean { + return c.lastDataAt > c.probedAt; +} + +/** + * xterm parses what it is given on a later frame, so a scan right after output can still see the + * screen before it. A scan within this long of the last output is repeated once more. + */ +export const SCREEN_SETTLE_MS = 2_000; + +/** + * Whether a terminal's screen can differ from the last scan: only if it was written to since (output, + * a replayed screen, a repaint), or the last scan came so soon after the write that the screen may + * not have shown it yet. + */ +export function screenScanDue(c: { writtenAt: number; scannedAt: number }): boolean { + return c.writtenAt + SCREEN_SETTLE_MS > c.scannedAt; +}