From 911f72d8ba586b985c2e6bb45278eb180c664e55 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 13:06:05 -0700 Subject: [PATCH 1/8] fix(link): close the gaps a paired or unpaired peer could walk through - removeMachine no longer resurrects the machine: closing the link fired onClose -> scheduleRetry before the entry was deleted, so it redialed, re-saved the known host and kept delivering sessions. Entries are now marked removed and every callback checks it is still current. - A notify may only run `send` methods (it was running invoke methods such as cockpit:open with no rate limit); non-input notifies share a token bucket, async rejections are caught. - Only the session channels a host is allowed to push reach the renderer; a host can no longer forge `link:sessions` for another machine (or for 'local'). - Pre-auth limits: at most 8 unauthenticated connections, 64 KiB frames until ready, TLS handshake timeout, attach/detach only from a paired device; the frame decoder no longer copies O(n^2). - Audit rows from refusals and strangers are throttled so they cannot evict the history. - A hello->ready deadline so a host that stalls after TLS cannot wedge the dial forever. - An exited session is dropped from every connection's attached set (it kept hasRemoteViewers true forever, blocking idle shutdown); attach announces only when something changed. - Port-mapping renewals stop when the server they were started for is gone. - An already-paired device sending `pair` keeps its permissions; evicting the oldest device past the cap is audited. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta (cherry picked from commit f924b93970bf80bd5b29c704714ede01d234778b) --- src/main/link/clientLink.ts | 34 ++- src/main/link/connection.ts | 13 +- src/main/link/devices.test.ts | 20 +- src/main/link/devices.ts | 15 +- src/main/link/hostServer.ts | 205 ++++++++++++++--- src/main/link/link.integration.test.ts | 265 ++++++++++++++++++++++ src/main/link/linkService.portmap.test.ts | 125 ++++++++++ src/main/link/linkService.test.ts | 132 +++++++++++ src/main/link/linkService.ts | 88 ++++++- src/main/link/protocol.test.ts | 47 +++- src/main/link/protocol.ts | 84 +++++-- 11 files changed, 960 insertions(+), 68 deletions(-) create mode 100644 src/main/link/linkService.portmap.test.ts diff --git a/src/main/link/clientLink.ts b/src/main/link/clientLink.ts index 7a36dc6..738098c 100644 --- a/src/main/link/clientLink.ts +++ b/src/main/link/clientLink.ts @@ -21,7 +21,7 @@ import { fingerprintsMatch } from './selfSignedCert'; import { dialOrder, type KnownHost } from './devices'; import { LINK_FEATURES, LINK_PROTOCOL, peerSupports, protocolMatches, - type LinkErrorCode, type LinkMessage, type ReadyMessage, + type ErrorMessage, type LinkErrorCode, type LinkMessage, type ReadyMessage, } from './protocol'; import type { LinkIdentity } from './identity'; @@ -70,14 +70,30 @@ export interface DialOptions { pairingToken?: string; /** Per-address connect timeout. Kept short: the point is to fall through to the next candidate. */ connectTimeoutMs?: number; + /** From the TLS handshake completing to the host's `ready`. See DEFAULT_HANDSHAKE_TIMEOUT_MS. */ + handshakeTimeoutMs?: number; requestTimeoutMs?: number; heartbeat?: HeartbeatOptions; onEvent?: (channel: string, payload: unknown) => void; + /** + * A refusal the host sent on an established link (an output-backlog reset, say). Kept apart from + * `onEvent` so the caller can tell the host's own error frames from event channels the host + * merely NAMED. Without it, the frame is handed to `onEvent` as `link:error`, as it always was. + */ + onHostError?: (message: ErrorMessage) => void; onPty?: (sessionId: string, bytes: Buffer) => void; onClose?: (reason: string | null) => void; } const DEFAULT_CONNECT_TIMEOUT_MS = 4_000; + +/** + * TLS done, `ready` not yet. The connect timeout stops at the TLS handshake and the heartbeat starts + * only after `ready`, so a host that completed TLS and then went silent — wedged main process, a + * proxy swallowing the stream — left the dial pending forever, and with it every `reconnect()`, which + * waits on the dial in flight. Generous: a host answers hello with ready in one round trip. + */ +const DEFAULT_HANDSHAKE_TIMEOUT_MS = 15_000; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; /** @@ -203,16 +219,28 @@ function handshake( pending.clear(); }; + let handshakeTimer: NodeJS.Timeout | undefined; + const connection: LinkConnection = attachConnection(socket, { onMessage: (message) => onMessage(message), onPty: (sessionId, bytes) => options.onPty?.(sessionId, bytes), onClose: (reason) => { + clearTimeout(handshakeTimer); settleAllPending(reason ?? 'disconnected'); if (!established) done({ ok: false, failure: { kind: 'unreachable', tried: [address], attempts: [{ address, reason: 'other' }], lastError: reason ?? 'closed' } }); else options.onClose?.(reason); }, }, options.heartbeat); + // Reported as this address timing out, which is what it is from here: something answered and then + // never finished saying who it is. Settled before closing, for the same reason as the refusals below. + handshakeTimer = setTimeout(() => { + if (established) return; + done({ ok: false, failure: { kind: 'unreachable', tried: [address], attempts: [{ address, reason: 'timed-out' }], lastError: 'handshake timed out' } }); + connection.close('handshake timeout'); + }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS); + handshakeTimer.unref?.(); + function onMessage(message: LinkMessage): void { switch (message.t) { case 'hello': { @@ -237,6 +265,7 @@ function handshake( } case 'ready': { established = true; + clearTimeout(handshakeTimer); // Enforced only against a host that said it answers pings. An older host is watched by TCP // keepalive alone — which is what watched every host before this existed, so nothing regresses. connection.startHeartbeat(peerSupports(message, 'ping')); @@ -253,7 +282,8 @@ function handshake( return; } // Established links surface mid-session refusals (e.g. an output backlog reset) as events. - options.onEvent?.('link:error', message); + if (options.onHostError) options.onHostError(message); + else options.onEvent?.('link:error', message); return; } case 'res': { diff --git a/src/main/link/connection.ts b/src/main/link/connection.ts index 064e153..11bfbf1 100644 --- a/src/main/link/connection.ts +++ b/src/main/link/connection.ts @@ -40,6 +40,8 @@ export interface LinkConnection { probe(deadlineMs: number): void; /** When something last arrived from the peer, by this connection's clock. */ readonly lastFrameAt: number; + /** The largest frame accepted from the peer. A host keeps it small until the peer is let in. */ + maxFrameBytes: number; } export interface HeartbeatOptions { @@ -54,8 +56,13 @@ export interface HeartbeatOptions { export const DEFAULT_HEARTBEAT: HeartbeatOptions = { idleMs: 15_000, timeoutMs: 45_000 }; -export function attachConnection(socket: Duplex, handlers: ConnectionHandlers, heartbeat: HeartbeatOptions = DEFAULT_HEARTBEAT): LinkConnection { - const decoder = makeFrameDecoder(); +export function attachConnection( + socket: Duplex, + handlers: ConnectionHandlers, + heartbeat: HeartbeatOptions = DEFAULT_HEARTBEAT, + limits: { maxFrameBytes?: number } = {}, +): LinkConnection { + const decoder = makeFrameDecoder({ maxFrameBytes: limits.maxFrameBytes }); let closed = false; const now = heartbeat.now ?? Date.now; let lastFrameAt = now(); @@ -183,6 +190,8 @@ export function attachConnection(socket: Duplex, handlers: ConnectionHandlers, h if (!probeTicker) { probeTicker = setInterval(() => tick(true), 50); probeTicker.unref?.(); } }, get lastFrameAt() { return lastFrameAt; }, + get maxFrameBytes() { return decoder.maxFrameBytes; }, + set maxFrameBytes(value: number) { decoder.maxFrameBytes = value; }, sendPty(sessionId, chunk) { try { write(encodePtyFrame(sessionId, chunk)); diff --git a/src/main/link/devices.test.ts b/src/main/link/devices.test.ts index 70ac1c4..fb787a3 100644 --- a/src/main/link/devices.test.ts +++ b/src/main/link/devices.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { sanitizePairedDevices, sanitizeKnownHosts, findPairedDevice, - upsertPairedDevice, removePairedDevice, upsertKnownHost, removeKnownHost, + upsertPairedDevice, upsertPairedDeviceReporting, removePairedDevice, upsertKnownHost, removeKnownHost, dialOrder, noteHostReached, type PairedDevice, type KnownHost, } from './devices'; @@ -158,3 +158,21 @@ describe('noteHostReached', () => { expect(noteHostReached(host(), { address: 'x.example', machineName: '', nowMs: 1 }).machineName).toBe('SIHYEONG-MAIN'); }); }); + +describe('upsertPairedDeviceReporting', () => { + const fp = (i: number): string => (i.toString(16).toUpperCase().padStart(2, '0').repeat(32).match(/../g) ?? []).join(':'); + + it('names the device the cap pushed out, so the host can say so', () => { + const full = Array.from({ length: 32 }, (_, i) => device({ fingerprint: fp(i + 1), machineName: `m${i + 1}` })); + const { devices, evicted } = upsertPairedDeviceReporting(full, device({ fingerprint: fp(200), machineName: 'new' })); + expect(devices).toHaveLength(32); + expect(evicted.map((d) => d.machineName)).toEqual(['m1']); + expect(devices.at(-1)?.machineName).toBe('new'); + }); + + it('evicts nobody when re-pairing a device already in a full ledger', () => { + const full = Array.from({ length: 32 }, (_, i) => device({ fingerprint: fp(i + 1) })); + expect(upsertPairedDeviceReporting(full, device({ fingerprint: fp(5) })).evicted).toEqual([]); + expect(upsertPairedDevice(full, device({ fingerprint: fp(5) }))).toHaveLength(32); + }); +}); diff --git a/src/main/link/devices.ts b/src/main/link/devices.ts index 84a5c20..e5703e6 100644 --- a/src/main/link/devices.ts +++ b/src/main/link/devices.ts @@ -142,8 +142,21 @@ export function findPairedDevice(devices: readonly PairedDevice[], fingerprint: * longer on screen would grant access nobody agreed to. */ export function upsertPairedDevice(devices: readonly PairedDevice[], device: PairedDevice): PairedDevice[] { + return upsertPairedDeviceReporting(devices, device).devices; +} + +/** + * `upsertPairedDevice`, also naming whoever the cap pushed out. Pairing a 33rd device drops the + * oldest — which un-pairs a machine nobody chose to un-pair, so the host has to be able to say so. + */ +export function upsertPairedDeviceReporting( + devices: readonly PairedDevice[], + device: PairedDevice, +): { devices: PairedDevice[]; evicted: PairedDevice[] } { const rest = devices.filter((d) => !fingerprintsMatch(d.fingerprint, device.fingerprint)); - return [...rest, device].slice(-MAX_DEVICES); + const all = [...rest, device]; + const cut = Math.max(0, all.length - MAX_DEVICES); + return { devices: all.slice(cut), evicted: all.slice(0, cut) }; } export function removePairedDevice(devices: readonly PairedDevice[], fingerprint: unknown): PairedDevice[] { diff --git a/src/main/link/hostServer.ts b/src/main/link/hostServer.ts index 02ee00f..df70ff2 100644 --- a/src/main/link/hostServer.ts +++ b/src/main/link/hostServer.ts @@ -22,14 +22,17 @@ import * as tls from 'node:tls'; import { AsyncLocalStorage } from 'node:async_hooks'; import type { DeckApiBundle } from '../api/deckApi'; -import { mayCallRemotely, sessionIdArgOf } from '../api/methods'; +import { mayCallRemotely, sessionIdArgOf, type ApiMethod } from '../api/methods'; import { attachConnection, type HeartbeatOptions, type LinkConnection } from './connection'; import { makeTokenBucket, type TokenBucket } from './tokenBucket'; import { isRemoteId } from '../../shared/link/machine'; import { fingerprintsMatch } from './selfSignedCert'; -import { findPairedDevice, upsertPairedDevice, type PairedDevice } from './devices'; +import { findPairedDevice, upsertPairedDeviceReporting, type PairedDevice } from './devices'; import { tokensMatch } from './inviteCode'; -import { LINK_FEATURES, LINK_PROTOCOL, peerSupports, protocolMatches, type LinkErrorCode, type LinkMessage } from './protocol'; +import { + LINK_FEATURES, LINK_PROTOCOL, MAX_FRAME_BYTES, PRE_AUTH_MAX_FRAME_BYTES, peerSupports, protocolMatches, + type LinkErrorCode, type LinkMessage, +} from './protocol'; import { sanitizeMachineName, isValidMachineId } from '../../shared/link/machine'; import { sanitizePermissions, type LinkPermission } from '../../shared/link/permissions'; import type { LinkIdentity } from './identity'; @@ -93,6 +96,10 @@ export interface HostServerOptions { sessionIds: () => string[] | null; /** Requests a connection may make: `capacity` at once, refilling at `refillPerMs`. */ rateLimit?: { capacity: number; refillPerMs: number }; + /** The same, for fire-and-forget calls other than keystrokes (see the `notify` case). */ + notifyRateLimit?: { capacity: number; refillPerMs: number }; + /** Connections not yet let in that may be open at once (see MAX_UNAUTHENTICATED_CONNECTIONS). */ + maxUnauthenticated?: number; heartbeat?: HeartbeatOptions; } @@ -144,9 +151,40 @@ const MAX_ATTACHED_PER_CONNECTION = 64; /** Sixty requests in hand, six a second after that — a deck refresh is a handful, a runaway loop is not. */ const DEFAULT_RATE_LIMIT = { capacity: 60, refillPerMs: 6 / 1000 }; +/** + * Fire-and-forget calls other than keystrokes: resizes, closes. A window drag or a "close all" is a + * burst of dozens, which this must absorb; a loop sending thousands a second is not something any + * viewer does. + */ +const DEFAULT_NOTIFY_RATE_LIMIT = { capacity: 60, refillPerMs: 30 / 1000 }; + +/** + * Connections that have not been let in yet, open at once. Each is cheap but not free — a TLS + * session, a socket, a grace timer — and anyone who can reach the port can open them. A person + * pairing a machine needs one. + */ +const MAX_UNAUTHENTICATED_CONNECTIONS = 8; + +/** TLS handshakes that never finish are bounded too; the default would hold each for two minutes. */ +const TLS_HANDSHAKE_TIMEOUT_MS = 15_000; + /** A refusal is logged at most this often per connection, so the limiter cannot itself fill the log. */ const REFUSAL_LOG_INTERVAL_MS = 10_000; +/** + * Rows a device that was never let in may write to the audit log: a burst, then one a minute. The log + * keeps its newest 500 rows, so without this anyone who can reach the port could push a month of + * real history out of it by connecting in a loop. What is held back is counted on the next row. + */ +const PRE_AUTH_LOG_BURST = 20; +const PRE_AUTH_LOG_REFILL_PER_MS = 1 / 60_000; + +/** + * The only event channels a paired machine is sent. The hub carries whatever this process publishes, + * and a channel added for the local window later must not start leaking to every paired device. + */ +const REMOTE_EVENT_CHANNELS = new Set(['cockpit:data', 'cockpit:exit', 'cockpit:resized', 'cockpit:sessions']); + interface Session { connection: LinkConnection; unauthenticatedTimer?: NodeJS.Timeout; @@ -158,7 +196,10 @@ interface Session { /** Display metadata only: authentication always uses the pinned certificate and pairing token. */ peerIdentity?: { machineId: string; machineName: string }; requests: TokenBucket; + notifies: TokenBucket; lastRefusalLogMs: number; + /** Refusals the throttle held back since the last one it logged. */ + suppressedRefusals: number; /** What the client's hello announced it can do. */ peerPings: boolean; } @@ -184,6 +225,24 @@ export function startHostServer(options: HostServerOptions): Promise session.connection.close(code); }; + const preAuthLog = makeTokenBucket({ capacity: PRE_AUTH_LOG_BURST, refillPerMs: PRE_AUTH_LOG_REFILL_PER_MS, now: options.now }); + let preAuthSuppressed = 0; + + /** + * Every audit row goes through here. A paired device's rows are written as they come (its own + * refusals are throttled per connection, below); a peer that was never let in shares one budget + * across ALL such peers, because opening another connection is free. + */ + const record = (session: Session | null, entry: HostLogEntry): void => { + if (session?.device) { options.log(entry); return; } + if (!preAuthLog.take()) { preAuthSuppressed += 1; return; } + if (preAuthSuppressed > 0) { + entry = { ...entry, detail: `${entry.detail} (+${preAuthSuppressed} more from unpaired peers)` }; + preAuthSuppressed = 0; + } + options.log(entry); + }; + const server = tls.createServer({ key: options.identity.keyPem, cert: options.identity.certPem, @@ -193,6 +252,7 @@ export function startHostServer(options: HostServerOptions): Promise requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3', + handshakeTimeout: TLS_HANDSHAKE_TIMEOUT_MS, }); server.on('secureConnection', (socket) => { @@ -201,11 +261,23 @@ export function startHostServer(options: HostServerOptions): Promise // No certificate at all: nothing to identify, nothing to pair. Refuse before reading a byte. if (!fingerprint) { - options.log({ at: options.now(), kind: 'rejected', machineName: '', fingerprint: '', detail: 'no client certificate' }); + record(null, { at: options.now(), kind: 'rejected', machineName: '', fingerprint: '', detail: 'no client certificate' }); socket.destroy(); return; } + // A paired device is never turned away by this — only strangers count against it, and a stranger + // over the limit is dropped before it costs a timer or a frame. + if (!known) { + let waiting = 0; + for (const s of sessions) if (!s.device) waiting += 1; + if (waiting >= (options.maxUnauthenticated ?? MAX_UNAUTHENTICATED_CONNECTIONS)) { + record(null, { at: options.now(), kind: 'rejected', machineName: '', fingerprint, detail: 'too many unpaired connections' }); + socket.destroy(); + return; + } + } + const session: Session = { connection: undefined as unknown as LinkConnection, device: known, @@ -214,7 +286,9 @@ export function startHostServer(options: HostServerOptions): Promise pairAttempts: 0, helloSeen: false, requests: makeTokenBucket({ ...(options.rateLimit ?? DEFAULT_RATE_LIMIT), now: options.now }), + notifies: makeTokenBucket({ ...(options.notifyRateLimit ?? DEFAULT_NOTIFY_RATE_LIMIT), now: options.now }), lastRefusalLogMs: 0, + suppressedRefusals: 0, peerPings: false, }; @@ -230,13 +304,15 @@ export function startHostServer(options: HostServerOptions): Promise onClose: () => { if (session.unauthenticatedTimer) clearTimeout(session.unauthenticatedTimer); sessions.delete(session); - options.log({ + record(session, { at: options.now(), kind: 'disconnected', machineName: session.device?.machineName ?? '', fingerprint, detail: '', }); announce(); }, - }, options.heartbeat); + // Until `ready`, nothing this peer may say is larger than a hello; the full ceiling is granted + // in sendReady. + }, options.heartbeat, { maxFrameBytes: PRE_AUTH_MAX_FRAME_BYTES }); sessions.add(session); // An unknown device that connects and then says nothing must not sit here holding a socket. This @@ -287,26 +363,30 @@ export function startHostServer(options: HostServerOptions): Promise // expired code ends up reported as "not paired" and sends someone to check their firewall. if (message.wantsPairing !== true) { fail(session, 'unpaired', 'This machine is not paired. Generate a connection code on the host.'); - options.log({ at: options.now(), kind: 'rejected', machineName: '', fingerprint, detail: 'unpaired device' }); + record(session, { at: options.now(), kind: 'rejected', machineName: '', fingerprint, detail: 'unpaired device' }); } return; } case 'pair': { if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'pair before hello'); return; } + // Already paired: it was let in on its certificate the moment it said hello. Redeeming a code + // now would spend an invite the person made for some OTHER machine, and swap this device's + // permissions for that invite's — a change nobody approved for it. + if (session.device) return; pair(session, message.token, fingerprint); return; } case 'req': { if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'request before hello'); return; } - const method = options.api.methods[message.method]; + const method = methodNamed(message.method); const args = Array.isArray(message.args) ? message.args : []; - if (!authorized(session, message.method)) { + if (!method || !authorized(session, message.method)) { session.connection.send({ t: 'res', id: message.id, ok: false, code: method ? 'permission-denied' : 'method-not-available', error: method ? `not permitted: ${message.method}` : `no such method: ${message.method}`, }); - denied(session, fingerprint, message.method); + deniedThrottled(session, fingerprint, String(message.method).slice(0, 80)); return; } if (!sessionArgAllowed(session, fingerprint, message.method, args)) { @@ -335,12 +415,29 @@ export function startHostServer(options: HostServerOptions): Promise case 'notify': { if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'request before hello'); return; } const args = Array.isArray(message.args) ? message.args : []; - if (!authorized(session, message.method)) { denied(session, fingerprint, message.method); return; } + const method = methodNamed(message.method); + if (!method || !authorized(session, message.method)) { deniedThrottled(session, fingerprint, String(message.method).slice(0, 80)); return; } + // A notify is a `send` method's transport and nothing else. Without this it was a way to run + // any `invoke` method — a project scan, a session spawn — with no reply and outside the + // request budget, because notifies are deliberately not counted against that budget. + if (method.channel !== 'send') { deniedThrottled(session, fingerprint, `notify:${message.method}`); return; } if (!sessionArgAllowed(session, fingerprint, message.method, args)) return; - // Not rate-limited: these are keystrokes and resizes, bursty by nature, and a dropped keystroke - // is worse than a slow scan. The output backlog guard below bounds what they can cost. + // Keystrokes are never limited: they are bursty by nature (a paste is hundreds at once), and a + // dropped keystroke is worse than a slow scan. Everything else — resizes, closes — gets a + // budget generous enough for a window drag, so a runaway loop cannot spin a handler forever. + if (message.method !== 'cockpit:input' && !session.notifies.take()) { + deniedThrottled(session, fingerprint, `rate-limited:${message.method}`); + return; + } try { - remoteCallContext.run('remote', () => { options.api.methods[message.method].handler(...args); }); + remoteCallContext.run('remote', () => { + const result = method.handler(...args) as unknown; + // An async `send` handler that rejects has nobody to reject to. Left alone it becomes an + // unhandled rejection in the main process, on a call another machine can repeat at will. + if (result && typeof (result as PromiseLike).then === 'function') { + Promise.resolve(result).catch(() => { /* fire-and-forget: nowhere to report it */ }); + } + }); } catch { /* a fire-and-forget call has nowhere to report to; the global trap logs it */ } return; } @@ -348,10 +445,13 @@ export function startHostServer(options: HostServerOptions): Promise if (!session.helloSeen) { fail(session, 'protocol-mismatch', 'request before hello'); return; } // Watching a session's output is reading it, and resizing it is driving it — so attach needs // both, and asks through the same methods a local viewer would. - if (!authorized(session, 'cockpit:resize')) { denied(session, fingerprint, 'attach'); return; } + if (!authorized(session, 'cockpit:resize')) { deniedThrottled(session, fingerprint, 'attach'); return; } const sessionId = message.sessionId; if (!isLocalSessionId(sessionId)) { deniedThrottled(session, fingerprint, `attach:${String(sessionId).slice(0, 80)}`); return; } - if (!session.attached.has(sessionId) && session.attached.size >= MAX_ATTACHED_PER_CONNECTION) { + // A viewer re-sends attach on every resize. Only a NEW one changes who is watching, and + // announcing each repeat sent the whole connection list to the window per resize frame. + if (session.attached.has(sessionId)) return; + if (session.attached.size >= MAX_ATTACHED_PER_CONNECTION) { deniedThrottled(session, fingerprint, 'attach:too-many'); return; } @@ -360,8 +460,10 @@ export function startHostServer(options: HostServerOptions): Promise return; } case 'detach': { - session.attached.delete(String(message.sessionId)); - announce(); + // Same gate as attach: a peer that was never let in has nothing to detach, and must not be + // able to make this machine re-announce its connections by asking. + if (!session.helloSeen || !session.device) return; + if (session.attached.delete(String(message.sessionId))) announce(); return; } default: @@ -369,9 +471,20 @@ export function startHostServer(options: HostServerOptions): Promise } } + /** + * The table entry for a name a peer sent, own properties only. A bare index would hand back + * `Object.prototype` members for names like `constructor`, and the policy check reading `.remote` + * off a function throws — inside the socket's data callback. + */ + function methodNamed(name: unknown): ApiMethod | undefined { + return typeof name === 'string' && Object.prototype.hasOwnProperty.call(options.api.methods, name) + ? options.api.methods[name] + : undefined; + } + function authorized(session: Session, methodName: string): boolean { if (!session.device) return false; - return mayCallRemotely(options.api.methods[methodName], session.device.permissions); + return mayCallRemotely(methodNamed(methodName), session.device.permissions); } /** @@ -388,29 +501,30 @@ export function startHostServer(options: HostServerOptions): Promise /** The session-id argument check for a method that declares one; refusals are audited. */ function sessionArgAllowed(session: Session, fingerprint: string, methodName: string, args: unknown[]): boolean { - const index = sessionIdArgOf(options.api.methods[methodName]); + const index = sessionIdArgOf(methodNamed(methodName)); if (index === null) return true; if (isLocalSessionId(args[index])) return true; deniedThrottled(session, fingerprint, `${methodName}:${String(args[index]).slice(0, 80)}`); return false; } - function denied(session: Session, fingerprint: string, methodName: string): void { - options.log({ - at: options.now(), kind: 'denied', - machineName: session.device?.machineName ?? '', fingerprint, detail: methodName, - }); - } - /** - * `denied`, at most once per interval per connection. A keystroke aimed at a session that just - * exited, or a viewer over its request budget, would otherwise write a log row per attempt. + * A refusal, logged at most once per interval per connection, with a count of what was held back. + * Every refusal goes through here: a keystroke aimed at a session that just exited, a viewer over + * its budget, or a peer spraying method names would otherwise write a row per attempt — and the log + * keeps only its newest 500, so a flood of junk is a way to erase the rows that mattered. */ function deniedThrottled(session: Session, fingerprint: string, detail: string): void { const t = options.now(); - if (t - session.lastRefusalLogMs < REFUSAL_LOG_INTERVAL_MS) return; + if (t - session.lastRefusalLogMs < REFUSAL_LOG_INTERVAL_MS) { session.suppressedRefusals += 1; return; } session.lastRefusalLogMs = t; - denied(session, fingerprint, detail); + const held = session.suppressedRefusals; + session.suppressedRefusals = 0; + record(session, { + at: t, kind: 'denied', + machineName: session.device?.machineName ?? '', fingerprint, + detail: held > 0 ? `${detail} (+${held} more)` : detail, + }); } function pair(session: Session, token: unknown, fingerprint: string): void { @@ -418,12 +532,12 @@ export function startHostServer(options: HostServerOptions): Promise const now = options.now(); if (!invite || invite.expiresAtMs <= now) { fail(session, 'token-expired', 'That connection code has expired. Generate a new one on the host.'); - options.log({ at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'expired invite' }); + record(session, { at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'expired invite' }); return; } if (typeof token !== 'string' || !tokensMatch(invite.token, token)) { session.pairAttempts += 1; - options.log({ at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'bad token' }); + record(session, { at: now, kind: 'rejected', machineName: '', fingerprint, detail: 'bad token' }); if (session.pairAttempts >= MAX_PAIR_ATTEMPTS) { fail(session, 'rate-limited', 'Too many attempts.'); return; @@ -440,12 +554,22 @@ export function startHostServer(options: HostServerOptions): Promise pairedAtMs: now, lastSeenMs: now, }; - options.savePairedDevices(upsertPairedDevice(options.pairedDevices(), device)); + const { devices, evicted } = upsertPairedDeviceReporting(options.pairedDevices(), device); + options.savePairedDevices(devices); session.device = device; // Single use. A code that stays valid after it worked is a code that is still valid when it is // pasted into the wrong window later. options.consumeInvite(); options.log({ at: now, kind: 'paired', machineName: device.machineName, fingerprint, detail: device.permissions.join(',') }); + // The ledger is capped, so pairing past it un-pairs the oldest device. That is a machine losing + // access without anyone pressing "revoke" — it is said in the log, and it takes effect now, the + // same as a revoke would. + for (const gone of evicted) { + options.log({ at: now, kind: 'rejected', machineName: gone.machineName, fingerprint: gone.fingerprint, detail: 'unpaired: paired-device limit reached' }); + for (const s of [...sessions]) { + if (s !== session && fingerprintsMatch(s.device?.fingerprint, gone.fingerprint)) s.connection.close('unpaired'); + } + } sendReady(session, fingerprint); } @@ -453,6 +577,7 @@ export function startHostServer(options: HostServerOptions): Promise const device = session.device; if (!device) return; if (session.unauthenticatedTimer) { clearTimeout(session.unauthenticatedTimer); session.unauthenticatedTimer = undefined; } + session.connection.maxFrameBytes = MAX_FRAME_BYTES; session.connection.send({ t: 'ready', machineId: options.machineId, @@ -478,10 +603,19 @@ export function startHostServer(options: HostServerOptions): Promise * binary frame; everything else goes to any connection allowed to observe. */ const unsubscribe = options.api.events.subscribe((channel, payload) => { - if (sessions.size === 0) return; + if (sessions.size === 0 || !REMOTE_EVENT_CHANNELS.has(channel)) return; const sessionId = channel === 'cockpit:data' || channel === 'cockpit:exit' || channel === 'cockpit:resized' ? String((payload as { id?: unknown })?.id ?? '') : null; + // An exited session is not being watched any more, whoever was attached to it. Kept in the set, + // its id held `hasRemoteViewers()` true for good — vetoing idle shutdown and sleep for a viewer + // looking at a terminal that no longer exists. Released after the exit itself is delivered. + const releaseExited = (): void => { + if (channel !== 'cockpit:exit' || !sessionId) return; + let released = false; + for (const s of sessions) if (s.attached.delete(sessionId)) released = true; + if (released) announce(); + }; for (const session of sessions) { if (!session.device || session.connection.closed) continue; if (sessionId !== null) { @@ -502,6 +636,7 @@ export function startHostServer(options: HostServerOptions): Promise } session.connection.send({ t: 'evt', ch: channel, payload }); } + releaseExited(); }); return new Promise((resolve, reject) => { diff --git a/src/main/link/link.integration.test.ts b/src/main/link/link.integration.test.ts index 75e6c59..71f088d 100644 --- a/src/main/link/link.integration.test.ts +++ b/src/main/link/link.integration.test.ts @@ -646,3 +646,268 @@ describe('task concurrency over the actual TLS transport', () => { } }); }); + +describe('hardening', () => { + const pairedAs = (permissions: LinkPermission[]): void => { + paired = [{ machineId: CLIENT_ID, machineName: 'laptop', fingerprint: clientIdentity.fingerprint, permissions, pairedAtMs: now, lastSeenMs: null }]; + }; + const settle = (ms = 60) => new Promise((r) => setTimeout(r, ms)); + type RawConn = ReturnType; + + /** A client driven at the frame level, for what a well-behaved ConnectedLink would never send. */ + const raw = (port: number, identity: LinkIdentity, script: { onHello?: (conn: RawConn) => void } = {}) => + new Promise<{ conn: RawConn; closed: Promise }>((resolve) => { + let markClosed: () => void = () => undefined; + const closed = new Promise((r) => { markClosed = r; }); + const socket = tls.connect({ + host: '127.0.0.1', port, + key: identity.keyPem, cert: identity.certPem, + rejectUnauthorized: false, checkServerIdentity: () => undefined, minVersion: 'TLSv1.3', + }, () => { + const conn = attachConnection(socket, { + onMessage: (message) => { if (message.t === 'hello') script.onHello?.(conn); }, + onPty: () => undefined, + onClose: () => markClosed(), + }); + resolve({ conn, closed }); + }); + socket.on('error', () => markClosed()); + }); + const hello = (conn: RawConn, over: Record = {}) => + conn.send({ t: 'hello', protocol: LINK_PROTOCOL, appVersion: '1.34.1', machineId: CLIENT_ID, machineName: 'laptop', ...over } as never); + const closedWithin = (closed: Promise, ms: number) => + Promise.race([closed.then(() => true), new Promise((r) => setTimeout(() => r(false), ms))]); + + it('runs only `send` methods as a notify — never an invoke method, with no reply and no budget', async () => { + pairedAs(['observe', 'control', 'spawn']); + const host = await startHost(); + const dialed = await connect(host.port); + if (!dialed.ok) throw new Error('expected a connection'); + dialed.link.notify('cockpit:open', [{ projectPath: 'C:\\repo' }]); + dialed.link.notify('projects:list', []); + await settle(); + expect(openedProjects).toEqual([]); + expect(log.some((l) => l.kind === 'denied' && l.detail === 'notify:cockpit:open')).toBe(true); + // And the same method still works the way it is meant to be called. + await expect(dialed.link.request('cockpit:open', [{ projectPath: 'C:\\repo' }])).resolves.toMatchObject({ id: 'sess-1' }); + dialed.link.close(); + }); + + it('budgets resizes and closes, but never keystrokes', async () => { + pairedAs(['observe', 'control']); + const host = await startHost({ notifyRateLimit: { capacity: 3, refillPerMs: 0 } }); + let resized = 0; + methods['cockpit:resize'] = { channel: 'send', remote: allowSession('control'), handler: () => { resized += 1; } }; + const dialed = await connect(host.port); + if (!dialed.ok) throw new Error('expected a connection'); + for (let i = 0; i < 10; i++) dialed.link.notify('cockpit:resize', ['sess-1', 80, 24]); + for (let i = 0; i < 200; i++) dialed.link.notify('cockpit:input', ['sess-1', 'x']); + await settle(120); + expect(resized).toBe(3); + expect(typed).toHaveLength(200); + expect(dialed.link.closed).toBe(false); + dialed.link.close(); + }); + + it('swallows a rejecting fire-and-forget handler instead of leaving an unhandled rejection', async () => { + pairedAs(['observe', 'control']); + const host = await startHost(); + methods['cockpit:resize'] = { channel: 'send', remote: allowSession('control'), handler: async () => { throw new Error('async send failed'); } }; + const unhandled: unknown[] = []; + const trap = (reason: unknown): void => { unhandled.push(reason); }; + process.on('unhandledRejection', trap); + try { + const dialed = await connect(host.port); + if (!dialed.ok) throw new Error('expected a connection'); + dialed.link.notify('cockpit:resize', ['sess-1', 80, 24]); + await settle(100); + expect(unhandled).toEqual([]); + dialed.link.close(); + } finally { + process.off('unhandledRejection', trap); + } + }); + + it('does not trip over a method name that is an Object.prototype member', async () => { + pairedAs(['observe', 'control']); + const host = await startHost(); + const dialed = await connect(host.port); + if (!dialed.ok) throw new Error('expected a connection'); + await expect(dialed.link.request('constructor', [])).rejects.toThrow(/no such method: constructor/); + dialed.link.notify('__proto__', []); + await expect(dialed.link.request('projects:list', [])).resolves.toBeTruthy(); + dialed.link.close(); + }); + + it('sends a paired machine only the session channels, whatever else the hub carries', async () => { + pairedAs(['observe', 'control']); + const host = await startHost(); + const seen: string[] = []; + const dialed = await connect(host.port, { onEvent: (ch) => { seen.push(ch); } }); + if (!dialed.ok) throw new Error('expected a connection'); + events.publish('settings:changed', { secret: true }); + events.publish('cockpit:sessions', []); + await settle(); + expect(seen).toEqual(['cockpit:sessions']); + dialed.link.close(); + }); + + it('turns strangers away past a small number waiting, but never a paired device', async () => { + pairedAs(['observe']); + const host = await startHost({ maxUnauthenticated: 2 }); + const stranger = () => raw(host.port, identityOf('stranger')); + const a = await stranger(); + const b = await stranger(); + await settle(30); + const c = await stranger(); + expect(await closedWithin(c.closed, 1_000)).toBe(true); + expect(await closedWithin(a.closed, 50)).toBe(false); + const dialed = await connect(host.port); + expect(dialed.ok).toBe(true); + if (dialed.ok) dialed.link.close(); + a.conn.close(); b.conn.close(); + }); + + it('drops a peer that sends a large frame before it is let in, and serves large ones after', async () => { + const host = await startHost(); + const big = 'x'.repeat(200 * 1024); + const unpaired = await raw(host.port, identityOf('stranger'), { + onHello: (conn) => { hello(conn, { wantsPairing: true, machineName: big }); }, + }); + expect(await closedWithin(unpaired.closed, 1_000)).toBe(true); + + pairedAs(['observe']); + const dialed = await connect(host.port); + if (!dialed.ok) throw new Error('expected a connection'); + await expect(dialed.link.request('projects:list', [big])).resolves.toBeTruthy(); + dialed.link.close(); + }); + + it('ignores a detach from a peer that was never let in', async () => { + let announced = 0; + await startHost({ onConnectionsChanged: () => { announced += 1; } }); + const stranger = await raw(server!.port, identityOf('stranger'), { + onHello: (conn) => { + hello(conn, { wantsPairing: true }); + for (let i = 0; i < 20; i++) conn.send({ t: 'detach', sessionId: 'sess-1' }); + }, + }); + await settle(100); + expect(announced).toBe(0); + stranger.conn.close(); + }); + + it('keeps strangers from pushing the audit history out with refusals', async () => { + const host = await startHost(); + // One connection spraying calls it may not make... + const sprayer = await raw(host.port, identityOf('sprayer'), { + onHello: (conn) => { + hello(conn, { wantsPairing: true }); + for (let i = 0; i < 100; i++) conn.send({ t: 'req', id: i, method: `junk:${i}`, args: [] }); + for (let i = 0; i < 100; i++) conn.send({ t: 'notify', method: `junk:${i}`, args: [] }); + }, + }); + await settle(150); + sprayer.conn.close(); + // ...and a loop of connections that are each turned away. + for (let i = 0; i < 30; i++) await connect(host.port); + await settle(100); + const fromStrangers = log.length; + expect(fromStrangers).toBeLessThanOrEqual(20); + // A paired device's history is still written in full. + pairedAs(['observe']); + const dialed = await connect(host.port); + if (!dialed.ok) throw new Error('expected a connection'); + expect(log.slice(fromStrangers).some((l) => l.kind === 'connected')).toBe(true); + dialed.link.close(); + }); + + it("throttles a paired device's own refusals and counts what it held back", async () => { + pairedAs(['observe']); + const host = await startHost(); + const dialed = await connect(host.port); + if (!dialed.ok) throw new Error('expected a connection'); + for (let i = 0; i < 50; i++) dialed.link.notify('cockpit:input', ['sess-1', 'x']); + await settle(80); + expect(log.filter((l) => l.kind === 'denied')).toHaveLength(1); + now += 11_000; + dialed.link.notify('cockpit:input', ['sess-1', 'x']); + await settle(80); + const denials = log.filter((l) => l.kind === 'denied'); + expect(denials).toHaveLength(2); + expect(denials[1].detail).toBe('cockpit:input (+49 more)'); + expect(host.connections).toHaveLength(1); + dialed.link.close(); + }); + + it('gives up on a host that finishes TLS and then says nothing', async () => { + // TLS completes, so the connect timeout is already cleared; the heartbeat only starts at `ready`. + const silent = tls.createServer({ key: hostIdentity.keyPem, cert: hostIdentity.certPem, requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3' }, () => undefined); + await new Promise((r) => silent.listen(0, '127.0.0.1', () => r())); + try { + const port = (silent.address() as { port: number }).port; + const started = Date.now(); + const dialed = await connect(port, { handshakeTimeoutMs: 200 }); + expect(Date.now() - started).toBeLessThan(2_000); + expect(dialed).toEqual({ ok: false, failure: expect.objectContaining({ kind: 'unreachable', attempts: [{ address: '127.0.0.1', reason: 'timed-out' }] }) }); + } finally { + await new Promise((r) => silent.close(() => r())); + } + }); + + it('releases an exited session from everyone attached to it, after telling them it exited', async () => { + pairedAs(['observe', 'control']); + let announced = 0; + const host = await startHost({ onConnectionsChanged: () => { announced += 1; } }); + const exits: unknown[] = []; + const dialed = await connect(host.port, { onEvent: (ch, payload) => { if (ch === 'cockpit:exit') exits.push(payload); } }); + if (!dialed.ok) throw new Error('expected a connection'); + dialed.link.attach('sess-1', 80, 24); + await settle(40); + const afterFirst = announced; + // Re-sent on every resize; only the first one changes who is watching. + for (let i = 0; i < 10; i++) dialed.link.attach('sess-1', 100 + i, 30); + await settle(40); + expect(announced).toBe(afterFirst); + expect(host.connections[0].attachedSessions).toEqual(['sess-1']); + + events.publish('cockpit:exit', { id: 'sess-1', exitCode: 0 }); + await settle(60); + expect(exits).toEqual([{ id: 'sess-1', exitCode: 0 }]); + expect(host.connections[0].attachedSessions).toEqual([]); + expect(announced).toBe(afterFirst + 1); + dialed.link.close(); + }); + + it('lets an already-paired device keep its permissions and leaves the invite unspent', async () => { + pairedAs(['observe']); + const token = createPairingToken(); + invite = { token, expiresAtMs: now + 300_000, permissions: ['observe', 'control', 'spawn', 'write', 'power'] }; + const host = await startHost(); + const dialed = await connect(host.port, { pairingToken: token }); + if (!dialed.ok) throw new Error('expected a connection'); + await settle(80); + expect(invite).not.toBeNull(); + expect(paired).toHaveLength(1); + expect(paired[0].permissions).toEqual(['observe']); + await expect(dialed.link.request('cockpit:open', [{ projectPath: 'C:\\repo' }])).rejects.toThrow(/not permitted/); + dialed.link.close(); + }); + + it('says so in the log when pairing past the cap un-pairs the oldest device', async () => { + const fp = (i: number): string => (i.toString(16).toUpperCase().padStart(2, '0').repeat(32).match(/../g) ?? []).join(':'); + paired = Array.from({ length: 32 }, (_, i) => ({ + machineId: `m-${i}`, machineName: `old-${i}`, fingerprint: fp(i + 1), + permissions: ['observe'] as LinkPermission[], pairedAtMs: now - 1000 + i, lastSeenMs: null, + })); + const token = createPairingToken(); + invite = { token, expiresAtMs: now + 300_000, permissions: ['observe'] }; + const host = await startHost(); + const dialed = await connect(host.port, { pairingToken: token }); + if (!dialed.ok) throw new Error('expected a connection'); + expect(paired).toHaveLength(32); + expect(paired.some((d) => d.machineName === 'old-0')).toBe(false); + expect(log.some((l) => l.kind === 'rejected' && l.machineName === 'old-0' && l.detail.includes('limit'))).toBe(true); + dialed.link.close(); + }); +}); diff --git a/src/main/link/linkService.portmap.test.ts b/src/main/link/linkService.portmap.test.ts new file mode 100644 index 0000000..7ef0e00 --- /dev/null +++ b/src/main/link/linkService.portmap.test.ts @@ -0,0 +1,125 @@ +/** + * The router port mapping's lifecycle against the server it was asked for. The router itself is + * stubbed: what is under test is that an answer arriving for a server that has since been replaced + * (a port change, host mode off) is thrown away rather than adopted and renewed. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { PortMapResult, MapPortOptions } from './portMap'; + +vi.mock('electron', () => ({ clipboard: { readText: () => '' } })); + +const asked: { options: MapPortOptions; answer: (r: PortMapResult) => void }[] = []; +vi.mock('./portMap', () => ({ + PORT_MAP_LIFETIME_S: 3600, + mapPort: (options: MapPortOptions) => new Promise((resolve) => { asked.push({ options, answer: resolve }); }), +})); +vi.mock('./defaultGateway', () => ({ + defaultGateway: async () => '192.168.1.1', + localAddressFor: async () => '192.168.1.50', +})); + +import { createLinkService, type LinkPersistence, type LinkService } from './linkService'; +import { makeMethodTable } from '../api/methods'; +import { makeEventHub } from '../api/events'; +import type { KnownHost, PairedDevice } from './devices'; + +const dirs: string[] = []; +const services: LinkService[] = []; + +function memoryStore(): LinkPersistence { + let hostMode = false; + let devices: PairedDevice[] = []; + let hosts: KnownHost[] = []; + let port = 0; + return { + getHostMode: () => hostMode, setHostMode: (on) => { hostMode = on; }, + getPort: () => port, setPort: (p) => { port = p; }, + getPairedDevices: () => devices, setPairedDevices: (d) => { devices = d; }, + getKnownHosts: () => hosts, setKnownHosts: (h) => { hosts = h; }, + }; +} + +/** setPort refuses 0, and the default port may well be taken by a real DevDeck on this machine. */ +async function freePort(): Promise { + const { createServer } = await import('node:net'); + return new Promise((resolve) => { + const s = createServer(); + s.listen(0, '127.0.0.1', () => { const port = (s.address() as { port: number }).port; s.close(() => resolve(port)); }); + }); +} + +async function waitFor(cond: () => boolean, timeoutMs = 3_000): Promise { + const until = Date.now() + timeoutMs; + while (!cond()) { + if (Date.now() > until) throw new Error('condition never held'); + await new Promise((r) => setTimeout(r, 10)); + } +} + +afterEach(async () => { + for (const s of services.splice(0)) await s.dispose(); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + asked.length = 0; +}); + +describe('router port mapping', () => { + it('discards an answer for a server that a port change has already replaced', async () => { + const dir = mkdtempSync(join(tmpdir(), 'devdeck-portmap-')); + dirs.push(dir); + const service = createLinkService({ + userDataDir: dir, + safeStorage: { isEncryptionAvailable: () => true, encryptString: (t) => Buffer.from(t), decryptString: (b) => b.toString() }, + api: { methods: makeMethodTable().table, events: makeEventHub() }, + machineId: '3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b', + machineName: () => 'host', + appVersion: '1.34.1', + store: memoryStore(), + onError: () => undefined, + }); + services.push(service); + + await service.setHostMode(true); + await waitFor(() => asked.length === 1); + const firstPort = asked[0].options.port; + + // Changed before the router answered for the first server. + await service.setPort(await freePort()); + await waitFor(() => asked.length === 2); + const secondPort = service.hostStatus().port; + expect(asked[1].options.port).toBe(secondPort); + + asked[0].answer({ state: 'mapped', via: 'nat-pmp', externalAddress: '203.0.113.7', externalPort: firstPort, lifetimeS: 120 }); + await new Promise((r) => setTimeout(r, 20)); + // Neither published as this host's mapping nor (same path) renewed. + expect(service.hostStatus().portMap).toBeNull(); + + asked[1].answer({ state: 'mapped', via: 'nat-pmp', externalAddress: '203.0.113.7', externalPort: secondPort, lifetimeS: 120 }); + await waitFor(() => service.hostStatus().portMap !== null); + expect(service.hostStatus().portMap?.externalPort).toBe(secondPort); + }); + + it('discards an answer that arrives after host mode was turned off', async () => { + const dir = mkdtempSync(join(tmpdir(), 'devdeck-portmap-')); + dirs.push(dir); + const service = createLinkService({ + userDataDir: dir, + safeStorage: { isEncryptionAvailable: () => true, encryptString: (t) => Buffer.from(t), decryptString: (b) => b.toString() }, + api: { methods: makeMethodTable().table, events: makeEventHub() }, + machineId: '3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b', + machineName: () => 'host', + appVersion: '1.34.1', + store: memoryStore(), + onError: () => undefined, + }); + services.push(service); + await service.setHostMode(true); + await waitFor(() => asked.length === 1); + await service.setHostMode(false); + asked[0].answer({ state: 'mapped', via: 'upnp', externalAddress: '203.0.113.7', externalPort: 1, lifetimeS: 120 }); + await new Promise((r) => setTimeout(r, 20)); + expect(service.hostStatus().portMap).toBeNull(); + }); +}); diff --git a/src/main/link/linkService.test.ts b/src/main/link/linkService.test.ts index 428bbe4..1cf7462 100644 --- a/src/main/link/linkService.test.ts +++ b/src/main/link/linkService.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import * as tls from 'node:tls'; const clipboardText = { value: '' }; vi.mock('electron', () => ({ @@ -20,6 +21,9 @@ import { makeEventHub, type EventHub } from '../api/events'; import type { DeckApiBundle } from '../api/deckApi'; import type { SafeStorageLike } from './identity'; import type { KnownHost, PairedDevice } from './devices'; +import { attachConnection } from './connection'; +import { generateMachineCertificate } from './selfSignedCert'; +import { LINK_PROTOCOL } from './protocol'; const safeStorage: SafeStorageLike = { isEncryptionAvailable: () => true, @@ -53,6 +57,7 @@ interface Fixture { const dirs: string[] = []; const services: LinkService[] = []; +const fakes: tls.Server[] = []; function makeApi(opened: string[]): { bundle: DeckApiBundle; events: EventHub } { const table = makeMethodTable(); @@ -109,6 +114,7 @@ async function waitFor(cond: () => boolean, timeoutMs = 4_000): Promise { beforeEach(() => { clipboardText.value = ''; }); afterEach(async () => { for (const s of services.splice(0)) await s.dispose(); + for (const f of fakes.splice(0)) await new Promise((r) => f.close(() => r())); for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); }); @@ -375,3 +381,129 @@ describe('the audit log', () => { expect(entries.map((e) => e.kind)).toContain('connected'); }); }); + +describe('removing a machine', () => { + it('stays removed — its closing link does not redial and save it back', async () => { + const host = makeService('host'); + const viewer = makeService('viewer', { machineId: '11111111-2222-4333-8444-555555555555', reconnectStepsMs: [50] }); + const code = (await host.service.createInvite()).invite!.code; + const added = await viewer.service.addMachine(code); + if (!added.ok) throw new Error('expected pairing'); + viewer.service.removeMachine(added.machine.machineId); + expect(viewer.service.machines()).toEqual([]); + + await new Promise((r) => setTimeout(r, 400)); // several retry steps' worth + expect(viewer.service.machines()).toEqual([]); + expect(host.service.hostStatus().connections).toEqual([]); + }); + + it('forgets the known host for good, across the retry the close used to arm', async () => { + const store = memoryStore(0); + const host = makeService('host'); + const remote: string[] = []; + const viewer = makeService('viewer', { + machineId: '11111111-2222-4333-8444-555555555555', reconnectStepsMs: [50], store, + onRemoteEvent: (ch) => { remote.push(ch); }, + }); + const code = (await host.service.createInvite()).invite!.code; + const added = await viewer.service.addMachine(code); + if (!added.ok) throw new Error('expected pairing'); + viewer.service.removeMachine(added.machine.machineId); + expect(store.getKnownHosts()).toEqual([]); + await new Promise((r) => setTimeout(r, 400)); + host.events.publish('cockpit:sessions', [{ id: 'after-removal' }]); + await new Promise((r) => setTimeout(r, 80)); + expect(store.getKnownHosts()).toEqual([]); + expect(remote).toEqual([]); + }); + + it('drops a dial that was still in flight when the machine was removed', async () => { + const store = memoryStore(0); + const host = makeService('host'); + await host.service.setPort(await freePort()); + const viewer = makeService('viewer', { machineId: '11111111-2222-4333-8444-555555555555', reconnectStepsMs: [50], store }); + const code = (await host.service.createInvite()).invite!.code; + const added = await viewer.service.addMachine(code); + if (!added.ok) throw new Error('expected pairing'); + await host.service.setHostMode(false); + await waitFor(() => viewer.service.machines()[0]?.state !== 'connected'); + await host.service.setHostMode(true); + const redial = viewer.service.reconnect(added.machine.machineId); // in flight + viewer.service.removeMachine(added.machine.machineId); + await redial; + await new Promise((r) => setTimeout(r, 200)); + expect(store.getKnownHosts()).toEqual([]); + expect(viewer.service.machines()).toEqual([]); + expect(host.service.hostStatus().connections).toEqual([]); + }); +}); + +describe('what a host may push', () => { + /** A host speaking the protocol by hand, so it can send what a real host never would. */ + async function fakeHost(push: (conn: ReturnType) => void) { + const identity = generateMachineCertificate({ commonName: 'fake-host' }); + const machineId = '99999999-8888-4777-8666-555555555555'; + const server = tls.createServer({ key: identity.keyPem, cert: identity.certPem, requestCert: true, rejectUnauthorized: false, minVersion: 'TLSv1.3' }, (socket) => { + const conn = attachConnection(socket, { + onMessage: (message) => { + if (message.t !== 'hello') return; + conn.send({ t: 'ready', machineId, machineName: 'fake', appVersion: '1.34.1', permissions: ['observe'], addresses: ['127.0.0.1'], port, features: ['ping'] }); + setTimeout(() => push(conn), 30); + }, + onPty: () => undefined, + onClose: () => undefined, + }); + conn.send({ t: 'hello', protocol: LINK_PROTOCOL, appVersion: '1.34.1', machineId, machineName: 'fake', features: ['ping'] }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); + const port = (server.address() as { port: number }).port; + fakes.push(server); + const known: KnownHost = { machineId, machineName: 'fake', fingerprint: identity.fingerprint, addresses: ['127.0.0.1'], port, lastAddress: null, lastSeenMs: null }; + return { machineId, known }; + } + + it('forwards only session channels, and never a channel this process writes itself', async () => { + const { machineId, known } = await fakeHost((conn) => { + conn.send({ t: 'evt', ch: 'link:sessions', payload: { machineId: 'local', sessions: [{ id: 'forged' }] } }); + conn.send({ t: 'evt', ch: 'link:error', payload: { t: 'error', code: 'unpaired', message: 'forged' } }); + conn.send({ t: 'evt', ch: 'settings:changed', payload: {} }); + conn.send({ t: 'evt', ch: 'cockpit:exit', payload: { id: 's1', exitCode: 0 } }); + conn.send({ t: 'evt', ch: 'cockpit:sessions', payload: [{ id: 's1' }] }); + // A real refusal on an established link still reaches the window, as it always did. + conn.send({ t: 'error', code: 'rate-limited', message: 'output backlog exceeded' }); + }); + const store = memoryStore(0); + store.setKnownHosts([known]); + const remote: { ch: string; payload: unknown }[] = []; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + makeService('viewer', { + machineId: '11111111-2222-4333-8444-555555555555', store, + onRemoteEvent: (ch, payload) => { remote.push({ ch, payload }); }, + }); + await waitFor(() => remote.length >= 3); + await new Promise((r) => setTimeout(r, 80)); + } finally { + warn.mockRestore(); + } + expect(remote).toEqual([ + { ch: 'cockpit:exit', payload: { id: `link:${machineId}:s1`, exitCode: 0 } }, + { ch: 'link:sessions', payload: { machineId, sessions: [{ id: `link:${machineId}:s1` }] } }, + { ch: 'link:error', payload: { t: 'error', code: 'rate-limited', message: 'output backlog exceeded' } }, + ]); + }); +}); + +describe('remote viewers', () => { + it('counts only viewers of sessions that are still running', async () => { + let live = ['C:\\remote-repo#1']; + const host = makeService('host', { liveSessionIds: () => live }); + const viewer = makeService('viewer', { machineId: '11111111-2222-4333-8444-555555555555' }); + const code = (await host.service.createInvite()).invite!.code; + await viewer.service.addMachine(code); + viewer.service.attach(`link:${host.service.hostStatus().machineId}:C:\\remote-repo#1`, 80, 24); + await waitFor(() => host.service.hasRemoteViewers()); + live = []; + expect(host.service.hasRemoteViewers()).toBe(false); + }); +}); diff --git a/src/main/link/linkService.ts b/src/main/link/linkService.ts index 0d427e1..43f73a3 100644 --- a/src/main/link/linkService.ts +++ b/src/main/link/linkService.ts @@ -159,8 +159,20 @@ interface Outbound { inflight: Promise | null; /** Sessions this viewer has asked to watch; re-sent after a reconnect so the stream resumes. */ attached: Map; + /** Set by removeMachine. A removed entry may still have a dial in flight or a socket closing. */ + removed: boolean; + /** A host sending event channels it has no business sending is noted once, not per frame. */ + strayEventNoted: boolean; } +/** + * The event channels a host's pushes may arrive on. Anything else is dropped: this process forwards + * what it accepts to the window, and the window trusts channels like `link:sessions` to have been + * written HERE — a host that could send one could name any machine, this one included, as the owner + * of the sessions it lists. + */ +const HOST_EVENT_CHANNELS = new Set(['cockpit:data', 'cockpit:exit', 'cockpit:resized']); + export interface LinkService { hostStatus(): HostStatus; machines(): MachineStatus[]; @@ -245,15 +257,24 @@ export function createLinkService(options: LinkServiceOptions): LinkService { * lapsed mapping closes the port silently. */ async function refreshPortMapping(): Promise { - if (disposed || !server) return; + // Bound to the server it was started for. Every await below can outlive that server — host mode + // switched off, or a port change, which stops one server and starts another — and a refresh that + // only asked "is there A server" resumed against the new one: it published the old port's mapping + // as current and armed a renewal chain for a port nothing listens on, forever. + const owner = server; + const current = (): boolean => !disposed && owner !== null && server === owner; + if (!current() || !owner) return; const gateway = await defaultGateway(); const localAddress = gateway ? await localAddressFor(gateway) : null; - if (disposed || !server) return; - portMap = await mapPort({ port: server.port, gateway, localAddress }); + if (!current()) return; + const result = await mapPort({ port: owner.port, gateway, localAddress }); + if (!current()) return; + portMap = result; changed(); - if (portMap.state !== 'mapped') return; // nothing to keep alive - const lifetime = portMap.lifetimeS ?? PORT_MAP_LIFETIME_S; - portMapTimer = setTimeout(() => { void refreshPortMapping(); }, Math.max(60, lifetime / 2) * 1000); + if (result.state !== 'mapped') return; // nothing to keep alive + const lifetime = result.lifetimeS ?? PORT_MAP_LIFETIME_S; + if (portMapTimer) clearTimeout(portMapTimer); + portMapTimer = setTimeout(() => { portMapTimer = null; void refreshPortMapping(); }, Math.max(60, lifetime / 2) * 1000); portMapTimer.unref?.(); } @@ -272,15 +293,26 @@ export function createLinkService(options: LinkServiceOptions): LinkService { const entry: Outbound = { host, link: null, state: 'offline', problem: null, attempt: 0, timer: null, inflight: null, attached: new Map(), + removed: false, strayEventNoted: false, }; outbound.set(host.machineId, entry); void connect(entry); return entry; } + /** + * Whether `entry` is still the one this service keeps for its machine. Everything that can fire + * after removeMachine — a socket's close, a dial resolving, a retry timer — checks this first. + * Closing a removed machine's link used to run its onClose, which armed a retry, which redialed + * and saved the machine straight back into the known-host list it had just been taken out of. + */ + function isCurrent(entry: Outbound): boolean { + return !disposed && !entry.removed && outbound.get(entry.host.machineId) === entry; + } + /** Idempotent: concurrent callers share one dial and all wait for the same outcome. */ function connect(entry: Outbound): Promise { - if (disposed || entry.link) return Promise.resolve(); + if (!isCurrent(entry) || entry.link) return Promise.resolve(); if (entry.inflight) return entry.inflight; entry.inflight = dial(entry).finally(() => { entry.inflight = null; }); return entry.inflight; @@ -298,6 +330,7 @@ export function createLinkService(options: LinkServiceOptions): LinkService { appVersion: options.appVersion, heartbeat: options.heartbeat, onEvent: (channel, payload) => { + if (!isCurrent(entry)) return; // "What is running there" is re-addressed to a channel that NAMES the machine. Deriving it // from the ids would work right up until the interesting case — an empty list, which is how a // machine says its last session ended and has no id to read the machine from. @@ -308,16 +341,36 @@ export function createLinkService(options: LinkServiceOptions): LinkService { }); return; } + if (!HOST_EVENT_CHANNELS.has(channel)) { + if (!entry.strayEventNoted) { + entry.strayEventNoted = true; + console.warn(`[link] dropped event channel ${JSON.stringify(String(channel).slice(0, 80))} from ${entry.host.machineName}`); + } + return; + } options.onRemoteEvent?.(channel, qualifyPayload(entry.host.machineId, channel, payload)); }, - onPty: (sessionId, bytes) => options.onRemotePty?.(qualifyRemoteId(entry.host.machineId, sessionId), bytes), + // The host's own error frames, which are this file's to forward — unlike a `link:error` channel + // the host might name in an event, which onEvent above drops. + onHostError: (message) => { + if (!isCurrent(entry)) return; + options.onRemoteEvent?.('link:error', { t: 'error', code: message.code, message: message.message }); + }, + onPty: (sessionId, bytes) => { if (isCurrent(entry)) options.onRemotePty?.(qualifyRemoteId(entry.host.machineId, sessionId), bytes); }, onClose: () => { entry.link = null; + if (!isCurrent(entry)) return; entry.state = 'offline'; changed(); scheduleRetry(entry); }, }); + if (!isCurrent(entry)) { + // Removed (or the service disposed) while this dial was in flight. Whatever it reached is + // hung up on, and nothing is written back — not the host, not a retry. + if (result.ok) result.link.close('removed'); + return; + } if (!result.ok) { entry.problem = result.failure; // An impostor is called out as its own state and never retried on a timer: quietly reconnecting @@ -351,7 +404,7 @@ export function createLinkService(options: LinkServiceOptions): LinkService { } function scheduleRetry(entry: Outbound, delayMs?: number): void { - if (disposed || entry.timer || entry.state === 'impostor') return; + if (!isCurrent(entry) || entry.timer || entry.state === 'impostor') return; const steps = options.reconnectStepsMs ?? RECONNECT_STEPS_MS; const delay = delayMs ?? steps[Math.min(entry.attempt, steps.length - 1)]; entry.attempt += 1; @@ -553,9 +606,15 @@ export function createLinkService(options: LinkServiceOptions): LinkService { removeMachine(machineId) { const entry = outbound.get(machineId); if (entry) { - if (entry.timer) clearTimeout(entry.timer); - entry.link?.close('removed'); + // In this order: once it is marked and out of the map, nothing its close sets off can bring + // it back (see isCurrent). + entry.removed = true; outbound.delete(machineId); + if (entry.timer) { clearTimeout(entry.timer); entry.timer = null; } + entry.attached.clear(); + const link = entry.link; + entry.link = null; + link?.close('removed'); } options.store.setKnownHosts(removeKnownHost(options.store.getKnownHosts(), machineId)); changed(); @@ -652,7 +711,12 @@ export function createLinkService(options: LinkServiceOptions): LinkService { auditLog(limit) { return log.recent(limit); }, clearAuditLog() { log.clear(); }, - hasRemoteViewers() { return connections.some((c) => c.attachedSessions.length > 0); }, + hasRemoteViewers() { + // Only sessions that still exist count. The host drops an exited session from what viewers are + // attached to; this is the second check, for an id that left the running set some other way. + const live = options.liveSessionIds?.(); + return connections.some((c) => c.attachedSessions.some((id) => !live || live.includes(id))); + }, async dispose() { disposed = true; diff --git a/src/main/link/protocol.test.ts b/src/main/link/protocol.test.ts index 06230d5..ea8e041 100644 --- a/src/main/link/protocol.test.ts +++ b/src/main/link/protocol.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - FrameKind, FRAME_HEADER_BYTES, LINK_PROTOCOL, MAX_FRAME_BYTES, + FrameKind, FRAME_HEADER_BYTES, LINK_PROTOCOL, MAX_FRAME_BYTES, PRE_AUTH_MAX_FRAME_BYTES, encodeJsonFrame, encodePtyFrame, decodePtyPayload, makeFrameDecoder, parseLinkMessage, protocolMatches, } from './protocol'; @@ -124,3 +124,48 @@ describe('protocolMatches', () => { expect(protocolMatches(undefined)).toBe(false); }); }); + +describe('makeFrameDecoder limits and large frames', () => { + it('reassembles a large frame fed in small chunks, byte-identical', () => { + // A scrollback replay is the largest real frame and arrives in socket-sized reads. Gluing every + // read onto the buffer as it came copied the frame once per read — quadratic in its size. + const body = Buffer.alloc(4 * 1024 * 1024); + for (let i = 0; i < body.length; i++) body[i] = (i * 31) & 0xff; + const frame = encodePtyFrame('big', body); + const decoder = makeFrameDecoder(); + const out = []; + const started = Date.now(); + for (let i = 0; i < frame.length; i += 16 * 1024) out.push(...decoder.push(frame.subarray(i, i + 16 * 1024))); + expect(Date.now() - started).toBeLessThan(2_000); + expect(out).toHaveLength(1); + expect(decodePtyPayload(out[0].payload)!.bytes.equals(body)).toBe(true); + expect(decoder.pending).toBe(0); + }); + + it('keeps frames that share a chunk with the tail of a previous one intact', () => { + const a = encodeJsonFrame({ t: 'detach', sessionId: 'a' }); + const b = encodeJsonFrame({ t: 'detach', sessionId: 'b' }); + const c = encodeJsonFrame({ t: 'detach', sessionId: 'c' }); + const all = Buffer.concat([a, b, c]); + const decoder = makeFrameDecoder(); + const split = a.length + 2; // the second header straddles the boundary + const frames = [...decoder.push(all.subarray(0, split)), ...decoder.push(all.subarray(split))]; + expect(frames.map((f) => (decodeJson(f.payload) as { sessionId: string }).sessionId)).toEqual(['a', 'b', 'c']); + }); + + it('refuses a frame over a lowered ceiling, and accepts it once the ceiling is raised', () => { + // Before a peer is let in, nothing it may say is bigger than a hello. + const frame = encodePtyFrame('s', Buffer.alloc(PRE_AUTH_MAX_FRAME_BYTES + 10)); + expect(() => makeFrameDecoder({ maxFrameBytes: PRE_AUTH_MAX_FRAME_BYTES }).push(frame)).toThrow(/refusing a frame/); + const decoder = makeFrameDecoder({ maxFrameBytes: PRE_AUTH_MAX_FRAME_BYTES }); + decoder.maxFrameBytes = MAX_FRAME_BYTES; + expect(decoder.push(frame)).toHaveLength(1); + }); + + it('never lets a ceiling be raised past the protocol maximum', () => { + const decoder = makeFrameDecoder({ maxFrameBytes: MAX_FRAME_BYTES * 4 }); + expect(decoder.maxFrameBytes).toBe(MAX_FRAME_BYTES); + decoder.maxFrameBytes = Number.NaN; + expect(decoder.maxFrameBytes).toBe(MAX_FRAME_BYTES); + }); +}); diff --git a/src/main/link/protocol.ts b/src/main/link/protocol.ts index 2fdc094..6c71aad 100644 --- a/src/main/link/protocol.ts +++ b/src/main/link/protocol.ts @@ -174,6 +174,13 @@ export interface DecodedFrame { payload: Buffer; } +/** + * The ceiling for a connection that has not been let in yet. Before `ready`, a peer may only say + * `hello` and `pair` — a few hundred bytes — and anyone who can reach the port can open one, so the + * full ceiling would let each of them park megabytes in this process for the grace period. + */ +export const PRE_AUTH_MAX_FRAME_BYTES = 64 * 1024; + export interface FrameDecoder { /** * Feed one socket chunk; returns whatever complete frames it completed. @@ -183,45 +190,94 @@ export interface FrameDecoder { push(chunk: Buffer): DecodedFrame[]; /** Bytes held back waiting for the rest of a frame — a stall diagnostic, not a protocol feature. */ readonly pending: number; + /** The largest frame this decoder accepts; never above MAX_FRAME_BYTES. Checked as each header is read. */ + maxFrameBytes: number; } /** * TCP gives a byte stream, not messages: a frame arrives split across chunks, several frames arrive in * one chunk, and a header itself can straddle the boundary. That is where framing bugs live, so this - * is a plain buffer-and-slice loop with no fast paths. + * stays a plain loop with no fast paths. + * + * Chunks are held as a LIST and only joined once a whole frame is there. Gluing every chunk onto + * the buffer as it arrived (and re-copying the tail after each frame) copied a large frame once per + * chunk — an 8 MB replay arriving in 64 KB reads was a quadratic number of bytes moved on the + * thread that serves every live terminal. */ -export function makeFrameDecoder(): FrameDecoder { - let buffer = Buffer.alloc(0); +export function makeFrameDecoder(options: { maxFrameBytes?: number } = {}): FrameDecoder { + const chunks: Buffer[] = []; + let length = 0; + let limit = clampLimit(options.maxFrameBytes); + + /** The first `n` bytes, without consuming them. Only ever asked for a header's worth. */ + const peek = (n: number): Buffer => { + if (chunks[0].length >= n) return chunks[0]; + const out = Buffer.allocUnsafe(n); + let filled = 0; + for (const c of chunks) { + filled += c.copy(out, filled, 0, Math.min(c.length, n - filled)); + if (filled >= n) break; + } + return out; + }; + + /** Remove exactly `n` bytes from the front, into a buffer of their own. */ + const take = (n: number): Buffer => { + // Its own allocation, never a slice of a chunk: a slice would pin that chunk, and through it the + // bytes of whatever it was read alongside, for as long as the payload lives. + const out = Buffer.allocUnsafe(n); + let filled = 0; + while (filled < n) { + const head = chunks[0]; + const want = n - filled; + if (head.length <= want) { + head.copy(out, filled); + filled += head.length; + chunks.shift(); + } else { + head.copy(out, filled, 0, want); + filled += want; + chunks[0] = head.subarray(want); + } + } + length -= n; + return out; + }; + return { push(chunk) { // Copy the incoming chunk rather than adopting it: the socket's buffer is reused, and holding a // reference to it across ticks is how a decoder starts reading bytes from a later read. - buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, chunk]); + if (chunk.length > 0) { chunks.push(Buffer.from(chunk)); length += chunk.length; } const frames: DecodedFrame[] = []; for (;;) { - if (buffer.length < FRAME_HEADER_BYTES) break; - const declared = buffer.readUInt32BE(0); + if (length < FRAME_HEADER_BYTES) break; + const header = peek(FRAME_HEADER_BYTES); + const declared = header.readUInt32BE(0); // A zero-length frame carries not even a kind byte; a huge one is a peer we cannot follow. - if (declared < 1 || declared > MAX_FRAME_BYTES) { + if (declared < 1 || declared > limit) { throw new Error(`link: refusing a frame of ${declared} bytes`); } const total = 4 + declared; - if (buffer.length < total) break; - const kind = buffer.readUInt8(4); + if (length < total) break; + const kind = header.readUInt8(4); if (kind !== FrameKind.Json && kind !== FrameKind.Pty) { throw new Error(`link: unknown frame kind ${kind}`); } - // Copy rather than subarray: the slice would pin the whole concatenated buffer alive, which for - // a long-lived terminal stream means holding every chunk it was ever glued to. - frames.push({ kind, payload: Buffer.from(buffer.subarray(FRAME_HEADER_BYTES, total)) }); - buffer = Buffer.from(buffer.subarray(total)); + frames.push({ kind, payload: take(total).subarray(FRAME_HEADER_BYTES) }); } return frames; }, - get pending() { return buffer.length; }, + get pending() { return length; }, + get maxFrameBytes() { return limit; }, + set maxFrameBytes(value: number) { limit = clampLimit(value); }, }; } +function clampLimit(value: number | undefined): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 1 ? Math.min(Math.floor(value), MAX_FRAME_BYTES) : MAX_FRAME_BYTES; +} + /** * Parse a JSON frame's payload into a message. * From e3d1aaac766712adbb662aff4aeb9f013d5e7832 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 13:06:05 -0700 Subject: [PATCH 2/8] fix(main): stop double-counting usage, leaking agent processes and killing work on a crash - Usage: two scans resuming the same grown transcript folded into the CACHED digest's objects, so concurrent scans (deck + usage view, or a remote viewer) added the new lines twice, for good (200,100 -> 400,100 tokens until restart). Folds go into copies and are serialized per file; the same fix for the per-session meta reader. - Windows: closing a session now kills its process tree (taskkill /T, then node-pty's kill). Measured: node-pty's console-only kill left a hidden child (the shape of an MCP server or a background dev server) running; the tree kill ends it. - A renderer crash no longer kills every terminal: the reload re-adopts the running sessions, so only DevDeck's own login shells are reaped. Measured: both sessions survive a forced renderer crash and come back as the same two tiles. - git runs with GIT_OPTIONAL_LOCKS=0 / GIT_TERMINAL_PROMPT=0, so the 30-45 s status polls stop taking index.lock out from under the agent's own git commands. - AI summaries: no shell:true (it corrupted `--mcp-config '{"mcpServers":{}}'` on Windows), a timed-out summarizer's whole tree is killed, failures are reported. - A resume id that fails validation is reported as null instead of naming a conversation the `-c` fallback never opened. - state.json rename retries on EPERM/EBUSY/EACCES; a failed shutdown.exe returns the scheduler to disarmed instead of counting down to nothing; at most 32 project terminals at once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta (cherry picked from commit d5c47a164d90ad42c62c42d07559c49124470ef6) --- src/main/agents.test.ts | 19 +++++- src/main/agents.ts | 7 ++- src/main/aiSummary.test.ts | 60 ++++++++++++++++++- src/main/aiSummary.ts | 85 +++++++++++++++++++++++---- src/main/codexUsage.test.ts | 12 ++++ src/main/codexUsage.ts | 17 ++++-- src/main/errorGuard.test.ts | 18 +++--- src/main/errorGuard.ts | 15 +++-- src/main/gitInfo.test.ts | 23 +++++++- src/main/gitInfo.ts | 42 +++++++++---- src/main/main.ts | 23 +++++--- src/main/ptyHost.test.ts | 94 +++++++++++++++++++++++++++++- src/main/ptyHost.ts | 71 ++++++++++++++++++++++ src/main/sessionMeta.test.ts | 18 +++++- src/main/sessionMeta.ts | 24 +++++++- src/main/shutdownScheduler.test.ts | 37 +++++++++++- src/main/shutdownScheduler.ts | 29 ++++++++- src/main/store.test.ts | 36 +++++++++++- src/main/store.ts | 32 +++++++++- src/main/usageScan.test.ts | 15 +++++ src/main/usageScan.ts | 54 ++++++++++++----- 21 files changed, 651 insertions(+), 80 deletions(-) diff --git a/src/main/agents.test.ts b/src/main/agents.test.ts index 5aa70fb..6cf0bee 100644 --- a/src/main/agents.test.ts +++ b/src/main/agents.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { join } from 'node:path'; -import { getProvider, availableAgents, resolveProjectOpenCommand, agentAvailableAtHome } from './agents'; +import { getProvider, availableAgents, resolveProjectOpenCommand, agentAvailableAtHome, resolveOpenSession } from './agents'; describe('agent providers', () => { it('claude buildCommand maps kinds correctly', () => { @@ -49,3 +49,20 @@ describe('agent providers', () => { .toBe('claude --resume abc12345'); }); }); + +describe('resolveOpenSession with an id the provider refuses', () => { + // `buildCommand('resume', bad)` falls back to `claude -c`, which opens the latest conversation — + // reporting the bad id would save the tile against a conversation nothing opened. + const gen = () => '00000000-0000-4000-8000-000000000000'; + it('reports no session id when the resume id fails validation', () => { + for (const id of ['../../etc/passwd', 'not an id; rm -rf', '']) { + const r = resolveOpenSession(getProvider('claude'), { fresh: false, sessionId: id, sessionCount: 3, latestId: 'abc12345', genId: gen }); + expect(r).toEqual({ command: 'claude -c', sessionId: null }); + } + }); + it('keeps a valid resume id', () => { + const id = 'a0b1c2d3-e4f5-6789-abcd-ef0123456789'; + expect(resolveOpenSession(getProvider('codex'), { fresh: false, sessionId: id, sessionCount: 1, latestId: null, genId: gen })) + .toEqual({ command: `codex resume ${id}`, sessionId: id }); + }); +}); diff --git a/src/main/agents.ts b/src/main/agents.ts index 6bdfd3f..6082991 100644 --- a/src/main/agents.ts +++ b/src/main/agents.ts @@ -138,6 +138,11 @@ export function resolveOpenSession( } return { command: a.buildCommand('new'), sessionId: null }; } - if (typeof opts.sessionId === 'string') return { command: a.buildCommand('resume', opts.sessionId), sessionId: opts.sessionId }; + if (typeof opts.sessionId === 'string') { + const command = a.buildCommand('resume', opts.sessionId); + // An id the provider refuses falls back to its "continue" command, which opens some OTHER + // conversation — so the tile must not be saved as holding the id it asked for. + return { command, sessionId: opts.sessionId && command.includes(opts.sessionId) ? opts.sessionId : null }; + } return { command: a.buildCommand('continue'), sessionId: opts.latestId }; } diff --git a/src/main/aiSummary.test.ts b/src/main/aiSummary.test.ts index 920815d..c7cdf31 100644 --- a/src/main/aiSummary.test.ts +++ b/src/main/aiSummary.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { makeAiSummarizer, buildSummaryPrompt, normalizeAiSummary } from './aiSummary'; +import { describe, it, expect, vi } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { makeAiSummarizer, buildSummaryPrompt, normalizeAiSummary, cliInvocation, killChildTree } from './aiSummary'; const SID = 'a0b1c2d3-e4f5-6789-abcd-ef0123456789'; const OTHER = 'b0b1c2d3-e4f5-6789-abcd-ef0123456789'; @@ -71,12 +75,14 @@ describe('makeAiSummarizer', () => { it('caches a failure so a missing CLI is not retried every tick', async () => { let calls = 0; - const s = makeAiSummarizer({ runners: { claude: async () => { calls++; throw new Error('claude exited 1'); } } }); + const failures: string[] = []; + const s = makeAiSummarizer({ runners: { claude: async () => { calls++; throw new Error('claude exited 1'); } }, onFailure: (m) => failures.push(m) }); s.setEnabled(true); s.get(SID, 1, 'work'); await s.idle(); expect(s.get(SID, 1, 'work')).toBeNull(); await s.idle(); expect(calls).toBe(1); + expect(failures).toEqual(['AI summary (claude) failed: claude exited 1']); // reported, once }); it('never runs two generations at once', async () => { @@ -162,3 +168,51 @@ describe('makeAiSummarizer', () => { expect(s.get(SID, 1, 'work', { provider: 'codex' })).toBe('codex만 있어도 동작'); }); }); + +describe('starting an agent CLI without shell: true', () => { + const CLAUDE_ARGS = ['-p', '--model', 'haiku', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}']; + + it('starts a real executable directly, arguments untouched', () => { + expect(cliInvocation('C:\\Users\\me\\.local\\bin\\claude.exe', CLAUDE_ARGS, 'win32')) + .toEqual({ file: 'C:\\Users\\me\\.local\\bin\\claude.exe', args: CLAUDE_ARGS }); + expect(cliInvocation('claude', CLAUDE_ARGS, 'darwin')).toEqual({ file: 'claude', args: CLAUDE_ARGS }); + }); + + it('routes a .cmd shim through cmd.exe with every argument quoted, verbatim', () => { + const how = cliInvocation('C:\\npm\\claude.cmd', CLAUDE_ARGS, 'win32', 'C:\\Windows\\system32\\cmd.exe'); + expect(how.file).toBe('C:\\Windows\\system32\\cmd.exe'); + expect(how.windowsVerbatimArguments).toBe(true); + expect(how.args.slice(0, 3)).toEqual(['/d', '/s', '/c']); + // The JSON's quotes survive as \" inside a quoted argument, every cmd metacharacter ^-escaped. + expect(how.args[3]).toContain('^^^"{\\^^^"mcpServers\\^^^":{}}^^^"'); + }); + + // The round trip that actually matters: through cmd.exe, through an npm-style shim that forwards + // %*, into a program — and out the other side byte for byte. `shell: true` turned the MCP config + // into `{mcpServers:{}}` on exactly this path. + it.runIf(process.platform === 'win32')('a shim receives the exact arguments (npm-style %* forwarding)', () => { + const dir = mkdtempSync(join(tmpdir(), 'devdeck cli ')); + try { + const bin = join(dir, 'with space'); + mkdirSync(bin); + writeFileSync(join(bin, 'echo.js'), 'process.stdout.write(JSON.stringify(process.argv.slice(2)))'); + writeFileSync(join(bin, 'fake.cmd'), [ + '@ECHO off', 'GOTO start', ':find_dp0', 'SET dp0=%~dp0', 'EXIT /b', ':start', 'SETLOCAL', 'CALL :find_dp0', + `"${process.execPath}" "%dp0%\\echo.js" %*`, + ].join('\r\n')); + const args = [...CLAUDE_ARGS, 'a b', 'x&y|z', '(paren)', 'say "hi"', 'trailing\\', '']; + const how = cliInvocation(join(bin, 'fake.cmd'), args); + const out = spawnSync(how.file, how.args, { windowsVerbatimArguments: how.windowsVerbatimArguments, windowsHide: true }).stdout.toString(); + expect(JSON.parse(out)).toEqual(args); + } finally { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); } + }); + + it('killChildTree: plain kill off Windows, and when there is no pid to walk from', () => { + const a = { pid: 10, kill: vi.fn() }; + killChildTree(a, 'linux'); + expect(a.kill).toHaveBeenCalledTimes(1); + const b = { pid: undefined, kill: vi.fn() }; + killChildTree(b, 'win32'); + expect(b.kill).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/main/aiSummary.ts b/src/main/aiSummary.ts index 43bc6cd..bee4afb 100644 --- a/src/main/aiSummary.ts +++ b/src/main/aiSummary.ts @@ -1,10 +1,12 @@ -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcess } from 'node:child_process'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { cleanSummaryText, SUMMARY_MAX } from '../shared/sessionSummary'; import { codexExecFinalMessage } from '../shared/codexParse'; import type { AgentId } from '../shared/types'; +import { resolveAgentCliPath } from './cliExecutable'; +import { taskkillTree } from './ptyHost'; /** * Optional AI layer for the cockpit's per-session summary line. @@ -72,22 +74,77 @@ export function normalizeAiSummary(raw: string): string { return line.length > SUMMARY_MAX ? line.slice(0, SUMMARY_MAX - 1) + '…' : line; } +/** A process start with nothing left for a shell to reinterpret. */ +export interface CliInvocation { file: string; args: string[]; windowsVerbatimArguments?: boolean } + +// Characters cmd.exe gives meaning to. Each one is ^-escaped so it reaches the program literally. +const CMD_META = /([()\][%!^"`<>&|;, *?])/g; + +/** One argument, quoted for the MSVCRT parser the program uses, then escaped for cmd.exe. */ +function cmdArgument(arg: string, viaBatch: boolean): string { + let quoted = arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1'); + quoted = `"${quoted}"`.replace(CMD_META, '^$1'); + // A batch file re-reads its arguments when it expands %* into its own command line, so cmd parses + // them twice and they need escaping twice (npm's .cmd shims all forward through %*). + return viaBatch ? quoted.replace(CMD_META, '^$1') : quoted; +} + +/** + * How to start an agent CLI without `shell: true`. + * + * `shell: true` on Windows joins the argv with plain spaces and hands it to cmd.exe unquoted, so an + * argument is whatever cmd and the program's own parser make of it: `--mcp-config '{"mcpServers":{}}'` + * arrived at Claude as `{mcpServers:{}}` and every summary failed. A real .exe is started directly. + * A .cmd/.bat shim cannot be (CreateProcess refuses them), so it goes through cmd.exe with each + * argument quoted and escaped here, verbatim, rather than left to the shell. + */ +export function cliInvocation( + command: string, args: string[], + platform: NodeJS.Platform = process.platform, comspec: string = process.env.ComSpec || 'cmd.exe', +): CliInvocation { + if (platform !== 'win32' || !/\.(cmd|bat)$/i.test(command)) return { file: command, args }; + const line = [command.replace(CMD_META, '^$1'), ...args.map((a) => cmdArgument(a, true))].join(' '); + return { file: comspec, args: ['/d', '/s', '/c', `"${line}"`], windowsVerbatimArguments: true }; +} + +/** + * Stop a CLI child and everything under it. On Windows `child.kill()` ends only the process it + * started — cmd.exe for a shim — and the real claude/codex node process kept running, spending quota + * on an answer nobody was waiting for. `taskkill /T` takes the tree; the plain kill follows. + */ +export function killChildTree(child: Pick, platform: NodeJS.Platform = process.platform): void { + const plainKill = (): void => { try { child.kill(); } catch { /* already gone */ } }; + if (platform !== 'win32' || typeof child.pid !== 'number') { plainKill(); return; } + void taskkillTree(child.pid).then(plainKill); +} + +/** Positive results only: a CLI installed after DevDeck started must still be found next time. */ +const _resolvedCli = new Map<'claude' | 'codex', string>(); +function cliPath(provider: 'claude' | 'codex'): string { + const hit = _resolvedCli.get(provider); + if (hit) return hit; + const found = resolveAgentCliPath(provider); + if (found !== provider) _resolvedCli.set(provider, found); + return found; +} + /** * Run an agent CLI headlessly and return its stdout. * - * The prompt goes in on STDIN, never as an argv element: on Windows these CLIs are `.cmd` shims, which - * need `shell: true`, and session text on a shell command line would be a command-injection hole. - * Every argv entry passed here is a fixed literal. + * The prompt goes in on STDIN, never as an argv element: session text on a command line — and on + * Windows these can still pass through cmd.exe, see cliInvocation — would be a command-injection + * hole. Every argv entry passed here is a fixed literal. */ function runCli(bin: string, args: string[], input: string, cwd: string, scrubEnv: (key: string) => boolean): Promise { return new Promise((resolve, reject) => { const env = { ...process.env }; for (const k of Object.keys(env)) if (scrubEnv(k)) delete env[k]; - const child = spawn(bin, args, { cwd, env, windowsHide: true, shell: process.platform === 'win32' }); + const how = cliInvocation(bin, args); + const child = spawn(how.file, how.args, { cwd, env, windowsHide: true, windowsVerbatimArguments: how.windowsVerbatimArguments }); let out = ''; let settled = false; const done = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); fn(); }; - const timer = setTimeout(() => done(() => { child.kill(); reject(new Error('summary timeout')); }), AI_TIMEOUT_MS); + const timer = setTimeout(() => done(() => { killChildTree(child); reject(new Error('summary timeout')); }), AI_TIMEOUT_MS); child.stdout.on('data', (b: Buffer) => { if (out.length < 32_000) out += b.toString('utf8'); }); child.stderr.on('data', () => { /* the CLIs chat on stderr; the exit code is what matters */ }); child.on('error', (err) => done(() => reject(err))); @@ -106,7 +163,7 @@ function runCli(bin: string, args: string[], input: string, cwd: string, scrubEn */ export function spawnClaudeSummary(input: string, cwd: string): Promise { return runCli( - 'claude', + cliPath('claude'), ['-p', '--model', 'haiku', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'], input, cwd, (k) => k === 'CLAUDECODE' || k.startsWith('CLAUDE_CODE_'), @@ -126,7 +183,7 @@ export function spawnClaudeSummary(input: string, cwd: string): Promise */ export function spawnCodexSummary(input: string, cwd: string): Promise { return runCli( - 'codex', + cliPath('codex'), ['exec', '--json', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--skip-git-repo-check', '-s', 'read-only', '-'], input, cwd, () => false, @@ -146,8 +203,13 @@ const DEFAULT_RUNNERS: Partial> = { codex: (input) => spawnCodexSummary(input, summaryCwd()), }; -export function makeAiSummarizer(deps: { runners?: Partial>; now?: () => number; cooldownMs?: number } = {}): AiSummarizer { +export function makeAiSummarizer(deps: { + runners?: Partial>; now?: () => number; cooldownMs?: number; + /** Where a failed generation is reported (the diagnostics log, when the caller has one). */ + onFailure?: (message: string) => void; +} = {}): AiSummarizer { const runners = deps.runners ?? DEFAULT_RUNNERS; + const onFailure = deps.onFailure ?? ((message: string) => console.warn('DevDeck:', message)); const now = deps.now ?? (() => Date.now()); const cooldownMs = deps.cooldownMs ?? AI_COOLDOWN_MS; const cache = new Map(); @@ -172,8 +234,11 @@ export function makeAiSummarizer(deps: { runners?: Partial { expect(h.child.kill).toHaveBeenCalledTimes(1); }); + it('stops the child through the tree kill, so app-server under a .cmd shim does not outlive it', async () => { + const killChild = vi.fn(); + const h = harness({ killChild }); + const p = h.run(); + await tick(); + h.timers[0].fn(); + await p; + expect(killChild).toHaveBeenCalledTimes(1); + expect(killChild).toHaveBeenCalledWith(h.child); + expect(h.child.kill).not.toHaveBeenCalled(); + }); + it('read timeout after a successful handshake => offline', async () => { const h = harness(); const p = h.run(); diff --git a/src/main/codexUsage.ts b/src/main/codexUsage.ts index 1f0a1bf..c2ac287 100644 --- a/src/main/codexUsage.ts +++ b/src/main/codexUsage.ts @@ -5,6 +5,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { clampPercent, parseResetTime, safeUsageLabel, type ProviderUsage, type UsageCredits, type UsageLimit } from '../shared/usageWindows'; import { resolveAgentCliPath } from './cliExecutable'; +import { cliInvocation, killChildTree } from './aiSummary'; const STARTUP_TIMEOUT_MS = 8_000; const REQUEST_TIMEOUT_MS = 12_000; @@ -20,6 +21,8 @@ export interface CodexUsageDeps { /** Injected in tests so timers don't depend on real time. */ setTimer?: (fn: () => void, ms: number) => unknown; clearTimer?: (handle: unknown) => void; + /** How the child is stopped; defaults to a process-tree kill (see killChildTree). */ + killChild?: (child: ChildProcessWithoutNullStreams) => void; } function result(state: ProviderUsage['state'], now: number, over: Partial = {}): ProviderUsage { @@ -144,7 +147,8 @@ export function getCodexUsage(deps: CodexUsageDeps): Promise { child.removeAllListeners('error'); child.removeAllListeners('exit'); try { child.stdin?.end(); } catch { /* already closed */ } - if (!killed) { killed = true; try { child.kill(); } catch { /* already gone */ } } + // A tree kill: through a .cmd shim, child.kill() would end cmd.exe and leave app-server running. + if (!killed) { killed = true; try { (deps.killChild ?? killChildTree)(child); } catch { /* already gone */ } } resolve(value); }; @@ -204,10 +208,11 @@ function loginOrOffline(error: unknown, now: number): ProviderUsage { /** Production spawn: the official CLI, stdio pipes only, no shell. */ export function spawnCodexAppServer(): ChildProcessWithoutNullStreams { - const command = resolveAgentCliPath('codex'); - // npm installs a `codex.cmd` shim on Windows. CreateProcess cannot execute that shim directly; - // route the fixed command through cmd.exe there (there is no user-controlled argv in this call). - return spawn(command, ['app-server'], { - stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32', + // npm installs a `codex.cmd` shim on Windows, which CreateProcess cannot execute directly; + // cliInvocation routes only that case through cmd.exe, with the argument quoted, and starts a + // real codex.exe directly — no `shell: true` either way. + const how = cliInvocation(resolveAgentCliPath('codex'), ['app-server']); + return spawn(how.file, how.args, { + stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, windowsVerbatimArguments: how.windowsVerbatimArguments, }) as ChildProcessWithoutNullStreams; } diff --git a/src/main/errorGuard.test.ts b/src/main/errorGuard.test.ts index ef17c25..cf9d260 100644 --- a/src/main/errorGuard.test.ts +++ b/src/main/errorGuard.test.ts @@ -72,28 +72,28 @@ describe('installAppCrashHandlers', () => { }); describe('makeCrashRecovery', () => { - // A crashed renderer loses its `live` session map, but the node-pty conptys it owned keep - // running in main with no owner. On reload the renderer restores sessions from the persisted - // list and spawns FRESH ptys — so the orphans must be reaped first or every crash leaks - // (and double-runs) a full set of terminal processes. + // A crashed renderer loses its `live` session map, but the project ptys keep running in main and + // the reloaded renderer re-adopts them (`cockpit:liveSessions`). Only DevDeck's internal login + // shells — which nothing can re-adopt — are reaped; killing project sessions would destroy the + // agents' in-flight work (and sessions remote viewers are using) for nothing. const detail = { reason: 'crashed', exitCode: 11 }; - it('render-process-gone: logs, reaps orphaned ptys, then reloads the window', () => { + it('render-process-gone: logs, reaps only the internal ptys, then reloads the window', () => { const calls: string[] = []; const respond = makeCrashRecovery({ log: (kind) => calls.push(`log:${kind}`), - reapPtys: () => calls.push('reap'), + reapInternalPtys: () => calls.push('reap-internal'), reloadWindow: () => calls.push('reload'), }); respond('render-process-gone', detail); - expect(calls).toEqual(['log:render-process-gone', 'reap', 'reload']); + expect(calls).toEqual(['log:render-process-gone', 'reap-internal', 'reload']); }); it('child-process-gone (GPU/utility): logs only — the renderer and its sessions are still alive', () => { const calls: string[] = []; const respond = makeCrashRecovery({ log: (kind) => calls.push(`log:${kind}`), - reapPtys: () => calls.push('reap'), + reapInternalPtys: () => calls.push('reap-internal'), reloadWindow: () => calls.push('reload'), }); respond('child-process-gone', { type: 'GPU', reason: 'crashed', exitCode: 139 }); @@ -104,7 +104,7 @@ describe('makeCrashRecovery', () => { const calls: string[] = []; const respond = makeCrashRecovery({ log: () => calls.push('log'), - reapPtys: () => { throw new Error('kill failed'); }, + reapInternalPtys: () => { throw new Error('kill failed'); }, reloadWindow: () => calls.push('reload'), }); expect(() => respond('render-process-gone', detail)).not.toThrow(); diff --git a/src/main/errorGuard.ts b/src/main/errorGuard.ts index eef7b2d..e3e4d76 100644 --- a/src/main/errorGuard.ts +++ b/src/main/errorGuard.ts @@ -37,22 +37,27 @@ export interface AppCrashDetail { reason: string; exitCode: number; type?: strin */ export interface CrashRecoveryDeps { log: (kind: AppCrashKind, detail: AppCrashDetail) => void; - reapPtys: () => void; + /** Kill only DevDeck's own terminals (provider-login shells) — never project sessions. */ + reapInternalPtys: () => void; reloadWindow: () => void; } /** * What to actually DO when a crash event fires (installAppCrashHandlers only observes). - * A dead renderer loses its in-memory session map while its node-pty conptys keep running in main - * with no owner; the reload then restores sessions from the persisted list with FRESH ptys. Reap - * the orphans first, or every renderer crash leaks — and double-runs — a full set of terminals. + * + * Project sessions are LEFT RUNNING. They used to be killed here, back when a reloaded renderer + * could only restore by spawning fresh ptys; it now asks main for the sessions already running + * (`cockpit:liveSessions`) and re-adopts them, and `cockpit:open` reuses a running conversation. So + * killing them bought nothing and destroyed whatever the agents were in the middle of — and the + * sessions a paired machine was watching along with them. Only the internal login shells are reaped: + * they are never listed, so nothing can re-adopt them and they would otherwise be orphaned for good. * A GPU/utility crash (`child-process-gone`) leaves the renderer alive, so it is log-only. */ export function makeCrashRecovery(deps: CrashRecoveryDeps): (kind: AppCrashKind, detail: AppCrashDetail) => void { return (kind, detail) => { try { deps.log(kind, detail); } catch { /* logging is best-effort */ } if (kind !== 'render-process-gone') return; - try { deps.reapPtys(); } catch { /* a failed kill must not block the reload */ } + try { deps.reapInternalPtys(); } catch { /* a failed kill must not block the reload */ } try { deps.reloadWindow(); } catch { /* window may already be tearing down */ } }; } diff --git a/src/main/gitInfo.test.ts b/src/main/gitInfo.test.ts index 895264f..2461f28 100644 --- a/src/main/gitInfo.test.ts +++ b/src/main/gitInfo.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits, _clearRemoteCache } from './gitInfo'; +import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits, _clearRemoteCache, createGitRunner, gitEnv } from './gitInfo'; beforeEach(() => _clearRemoteCache()); @@ -169,3 +169,24 @@ describe('the git command is bounded', () => { } }); }); + +describe('the production git runner', () => { + // `git status` refreshes the index opportunistically under index.lock unless told not to, and that + // collides with the agent's own `git add`/`git commit` in the same repository. + it('runs every git call with optional locks and terminal prompts disabled', async () => { + const exec = vi.fn(async () => ({ stdout: 'out' })); + const run = createGitRunner(exec); + expect(await run(['-C', 'C:/g/x', 'status', '--porcelain'])).toBe('out'); + const [file, args, opts] = exec.mock.calls[0] as unknown as [string, string[], { env: NodeJS.ProcessEnv; windowsHide: boolean; timeout: number }]; + expect(file).toBe('git'); + expect(args).toEqual(['-C', 'C:/g/x', 'status', '--porcelain']); + expect(opts.env.GIT_OPTIONAL_LOCKS).toBe('0'); + expect(opts.env.GIT_TERMINAL_PROMPT).toBe('0'); + expect(opts.windowsHide).toBe(true); + expect(opts.timeout).toBeGreaterThan(0); + }); + + it('keeps the rest of the environment (PATH, HOME) intact', () => { + expect(gitEnv({ PATH: '/bin', HOME: '/h', GIT_OPTIONAL_LOCKS: '1' })).toEqual({ PATH: '/bin', HOME: '/h', GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }); + }); +}); diff --git a/src/main/gitInfo.ts b/src/main/gitInfo.ts index 52b9b19..4eefd9e 100644 --- a/src/main/gitInfo.ts +++ b/src/main/gitInfo.ts @@ -32,17 +32,39 @@ const GIT_TIMEOUT_MS = 10_000; */ const GIT_MAX_BUFFER = 32 * 1024 * 1024; -const defaultRunner: GitRunner = async (args) => { - const { stdout } = await execFileAsync('git', args, { - windowsHide: true, +/** + * The environment every DevDeck git call runs with. + * + * GIT_OPTIONAL_LOCKS=0: `git status` otherwise takes index.lock opportunistically to refresh the + * index's stat cache — and DevDeck runs it on every project, every refresh, in the very repositories + * an agent is committing in. The agent's own `git add`/`git commit` then fails with "Unable to create + * index.lock: File exists" whenever the two land together. The refresh is only an optimization; + * a read-only dashboard has no business taking a write lock for it. + * GIT_TERMINAL_PROMPT=0: a credential prompt nobody can see would sit there until the timeout. + */ +export function gitEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { ...base, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' }; +} + +type ExecFileAsync = (file: string, args: string[], opts: Parameters[2]) => Promise<{ stdout: string | Buffer }>; + +/** The production runner, over an injectable execFile so its options are testable. */ +export function createGitRunner(exec: ExecFileAsync = execFileAsync as unknown as ExecFileAsync): GitRunner { + return async (args) => { + const { stdout } = await exec('git', args, { + env: gitEnv(), + windowsHide: true, timeout: GIT_TIMEOUT_MS, - // SIGTERM is the default and is ignored on Windows for a process that is not responding; git - // spawns children (credential helpers, pagers) and this has to actually end them. - killSignal: 'SIGKILL', - maxBuffer: GIT_MAX_BUFFER, - }); - return stdout; -}; + // SIGTERM is the default and is ignored on Windows for a process that is not responding; git + // spawns children (credential helpers, pagers) and this has to actually end them. + killSignal: 'SIGKILL', + maxBuffer: GIT_MAX_BUFFER, + }); + return String(stdout); + }; +} + +const defaultRunner: GitRunner = createGitRunner(); async function safe(run: GitRunner, args: string[]): Promise { try { diff --git a/src/main/main.ts b/src/main/main.ts index 9e16372..212896c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import { uptime, homedir, tmpdir } from 'node:os'; import { Store } from './store'; import { registerIpc } from './ipc'; -import { PtyHost, type PtySpawn } from './ptyHost'; +import { PtyHost, taskkillTree, withTreeKill, type PtyProcess, type PtySpawn } from './ptyHost'; import { setupTray } from './tray'; import { registerUpdater } from './updater'; import { applyOpenAtLogin } from './autostart'; @@ -35,7 +35,7 @@ try { nodePty = require('@homebridge/node-pty-prebuilt-multiarch') as NodePty; } const realSpawn: PtySpawn = (file, args, opts) => { if (!nodePty) throw new Error('node-pty native binding unavailable — embedded terminals need Windows (or a matching prebuilt)'); const p = nodePty.spawn(file, args, { name: 'xterm-256color', cwd: opts.cwd, cols: opts.cols, rows: opts.rows }); - return { + const proc: PtyProcess = { pid: p.pid, onData: (cb) => { p.onData(cb); }, onExit: (cb) => { p.onExit((e) => cb({ exitCode: e.exitCode })); }, @@ -43,6 +43,9 @@ const realSpawn: PtySpawn = (file, args, opts) => { resize: (c, r) => p.resize(c, r), kill: () => p.kill(), }; + // Closing a tile has to end what the agent started too (MCP servers, hidden dev servers), which + // node-pty's console-only kill leaves running on Windows — see withTreeKill. + return process.platform === 'win32' ? withTreeKill(proc, (pid) => taskkillTree(pid)) : proc; }; const ptyHost = new PtyHost(realSpawn); @@ -130,11 +133,11 @@ if (!gotLock) { // render-process-gone / child-process-gone fire on `app`, not `process` — a renderer or GPU // crash previously left zero trace anywhere (no log entry, no Windows crash event either, // since Crashpad intercepts it before the OS's own crash reporting sees it). - // On a renderer crash the recovery also reaps the now-ownerless ptys and reloads the window, - // which restores the sessions from the persisted list (fresh ptys) instead of leaking the old ones. + // On a renderer crash the recovery reloads the window, which re-adopts the project sessions that + // are still running; only the internal login shells, which nothing re-adopts, are reaped. installAppCrashHandlers(app, makeCrashRecovery({ log: (kind, detail) => logLine(`[${kind}] ${JSON.stringify(detail)}`), - reapPtys: () => ptyHost.killAll(), + reapInternalPtys: () => ptyHost.killInternal(), reloadWindow: () => { if (win && !win.isDestroyed()) win.webContents.reload(); }, })); // Match the installer shortcut's AppUserModelID (electron-builder sets it to @@ -171,18 +174,20 @@ if (!gotLock) { logLine(`[shutdown] ${msg}`); try { if (!w.isDestroyed()) w.webContents.send('devdeck:error', msg); } catch { /* renderer gone */ } }; - const spawnShutdown = (args: string[]): void => { + const spawnShutdown = (args: string[], onFailed: (msg: string) => void = reportShutdownError): void => { // args array + windowsHide; an exec error must surface, not silently strand an "issued" record. const p = spawn('shutdown', args, { windowsHide: true, stdio: 'ignore' }); - p.on('error', (e) => reportShutdownError(`shutdown.exe failed to spawn: ${e.message}`)); + p.on('error', (e) => onFailed(`shutdown.exe failed to spawn: ${e.message}`)); // error never fires for a clean spawn that FAILS (e.g. a shutdown already pending, policy denial) // — that's a non-zero exit, and it means the countdown the user is watching will never fire. - p.on('exit', (code) => { if (code !== null && code !== 0) reportShutdownError(`shutdown ${args[0]} exited with code ${code}`); }); + p.on('exit', (code) => { if (code !== null && code !== 0) onFailed(`shutdown ${args[0]} exited with code ${code}`); }); }; shutdown = new ShutdownScheduler({ log: shutdownLog, now: Date.now, - execShutdown: (sec) => spawnShutdown(['/s', '/f', '/t', String(sec), '/c', 'DevDeck idle auto-shutdown']), + // A failed issue goes back to the scheduler (which reports it and leaves the countdown), not + // straight to the user: otherwise the UI keeps counting down to a shutdown the OS never took. + execShutdown: (sec, onFailed) => spawnShutdown(['/s', '/f', '/t', String(sec), '/c', 'DevDeck idle auto-shutdown'], onFailed), execAbort: () => spawnShutdown(['/a']), transcriptMtime: () => latestTranscriptMtime(path.join(homedir(), '.claude', 'projects')), idleHoldMs: () => store.getShutdownIdleMinutes() * 60_000, diff --git a/src/main/ptyHost.test.ts b/src/main/ptyHost.test.ts index 19e15ae..5c3e4ea 100644 --- a/src/main/ptyHost.test.ts +++ b/src/main/ptyHost.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { PtyHost, type PtyProcess, type PtySpawn } from './ptyHost'; +import { EventEmitter } from 'node:events'; +import { PtyHost, MAX_SESSIONS, taskkillTree, withTreeKill, type PtyProcess, type PtySpawn } from './ptyHost'; function fakeProc() { let dataCb: (d: string) => void = () => {}; @@ -252,3 +253,94 @@ describe('what a machine can say about its own sessions', () => { expect(host.note('gone', { label: 'triage' })).toBe(false); }); }); + +describe('limits and crash recovery', () => { + it(`refuses project terminal number ${MAX_SESSIONS + 1} with a readable error — internal ones do not count`, () => { + const host = new PtyHost(() => fakeProc()); + for (let i = 0; i < MAX_SESSIONS; i++) host.create(`p${i}`, 'pwsh', [], '.', 80, 24, () => {}, () => {}); + expect(() => host.create('one-too-many', 'pwsh', [], '.', 80, 24, () => {}, () => {})) + .toThrow(`Too many terminals open (${MAX_SESSIONS}). Close some sessions first.`); + expect(() => host.create('login', 'pwsh', [], '.', 80, 24, () => {}, () => {}, { internal: true })).not.toThrow(); + host.kill('p0'); + expect(() => host.create('room-again', 'pwsh', [], '.', 80, 24, () => {}, () => {})).not.toThrow(); + }); + + it("killInternal() reaps only DevDeck's own login shells and leaves project sessions running", () => { + const login = fakeProc(), project = fakeProc(); + const host = new PtyHost(vi.fn().mockReturnValueOnce(login).mockReturnValueOnce(project)); + host.create('usage-login:claude:1', 'pwsh', [], '.', 80, 24, () => {}, () => {}, { internal: true }); + host.create('C:\repo#1', 'pwsh', [], '.', 80, 24, () => {}, () => {}); + host.killInternal(); + expect(login.kill).toHaveBeenCalledTimes(1); + expect(project.kill).not.toHaveBeenCalled(); + expect(host.list().map((s) => s.id)).toEqual(['C:\repo#1']); + expect(host.pid('usage-login:claude:1')).toBeNull(); + }); +}); + +describe('Windows process-tree kill', () => { + it('runs the tree kill FIRST (while the shell still exists) and the pty kill only after it settles', async () => { + const order: string[] = []; + let release!: () => void; + const inner = fakeProc(); + (inner.kill as ReturnType).mockImplementation(() => order.push('pty.kill')); + const wrapped = withTreeKill(inner, (pid) => { order.push(`taskkill ${pid}`); return new Promise((r) => { release = r; }); }); + wrapped.kill(); + wrapped.kill(); // a second close while the first is in flight does not start another + expect(order).toEqual(['taskkill 1234']); + release(); + await new Promise((r) => setTimeout(r, 0)); + expect(order).toEqual(['taskkill 1234', 'pty.kill']); + }); + + it('still closes the pty when the tree kill fails', async () => { + const inner = fakeProc(); + const wrapped = withTreeKill(inner, () => Promise.reject(new Error('taskkill missing'))); + wrapped.kill(); + await new Promise((r) => setTimeout(r, 0)); + expect(inner.kill).toHaveBeenCalledTimes(1); + }); + + it('forwards everything else to the real pty', () => { + const inner = fakeProc(); + const wrapped = withTreeKill(inner, async () => {}); + wrapped.write('x'); wrapped.resize(100, 30); + expect(wrapped.pid).toBe(1234); + expect(inner.write).toHaveBeenCalledWith('x'); + expect(inner.resize).toHaveBeenCalledWith(100, 30); + }); + + it('shutdown still drains: exit callbacks fire once the deferred pty kill has run', async () => { + const inner = fakeProc(); + (inner.kill as ReturnType).mockImplementation(() => inner.emitExit(1)); + const host = new PtyHost(() => withTreeKill(inner, async () => {})); + const onExit = vi.fn(); + host.create('a', 'pwsh', [], '.', 80, 24, () => {}, onExit); + await host.shutdown(1_000); + expect(onExit).toHaveBeenCalledWith({ exitCode: 1 }); + }); + + it('taskkillTree spawns `taskkill /PID /T /F` hidden and resolves when it exits', async () => { + const child = new EventEmitter(); + const spawnFn = vi.fn(() => child); + const done = taskkillTree(4321, spawnFn as never, 1_000); + expect(spawnFn).toHaveBeenCalledWith('taskkill', ['/PID', '4321', '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); + child.emit('exit', 0); + await expect(done).resolves.toBeUndefined(); + }); + + it('taskkillTree gives up after its timeout instead of holding the close forever', async () => { + vi.useFakeTimers(); + try { + const done = taskkillTree(4321, (() => new EventEmitter()) as never, 3_000); + await vi.advanceTimersByTimeAsync(3_000); + await expect(done).resolves.toBeUndefined(); + } finally { vi.useRealTimers(); } + }); + + it('taskkillTree does nothing for a pid it cannot trust', async () => { + const spawnFn = vi.fn(); + await taskkillTree(0, spawnFn as never); + expect(spawnFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/ptyHost.ts b/src/main/ptyHost.ts index 8c3d28f..bfa29f3 100644 --- a/src/main/ptyHost.ts +++ b/src/main/ptyHost.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { spawn as spawnChild } from 'node:child_process'; import { withTimeout } from '../shared/withTimeout'; export interface PtyProcess { @@ -63,6 +64,66 @@ export interface PtySessionInfo { */ const SCROLLBACK_BYTES = 256 * 1024; +/** + * The most project terminals this machine will run at once. Each one is a real agent process + * holding memory and quota, and the restore path has run away before — one bug opened 50 claude + * processes in a loop and froze the host. Far past any real working set; a refusal there is a + * readable error instead of a machine that stops responding. Internal terminals do not count. + */ +export const MAX_SESSIONS = 32; + +/** How long `taskkill` may take before the plain kill goes ahead without it. */ +const TREE_KILL_TIMEOUT_MS = 3_000; + +export type TreeKiller = (pid: number) => Promise; + +/** + * `taskkill /PID /T /F`: end a Windows process AND everything it started. Never rejects — + * however it ends (killed, nothing to kill, taskkill missing, too slow), the caller carries on. + */ +export function taskkillTree(pid: number, spawnFn: typeof spawnChild = spawnChild, timeoutMs = TREE_KILL_TIMEOUT_MS): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (): void => { if (!done) { done = true; clearTimeout(timer); resolve(); } }; + const timer = setTimeout(finish, timeoutMs); + if (!Number.isInteger(pid) || pid <= 0) { finish(); return; } + try { + const p = spawnFn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); + p.once('error', finish); + p.once('exit', finish); + } catch { finish(); } + }); +} + +/** + * Make a pty's kill() take the shell's whole process tree, not only what is attached to its console. + * + * node-pty's Windows kill() ends the processes in the pseudoconsole's process list and closes it. + * An agent's own children that were started hidden or detached — MCP servers, a dev server it ran + * with Start-Process, anything spawned with windowsHide — are not in that list and outlive the tile, + * holding ports and files. `taskkill /T` walks the parent chain instead, so it has to run FIRST, + * while the shell still exists to walk from; the original kill() follows once it is done (or has + * timed out), so the pseudoconsole is always closed and node-pty's exit still fires. + */ +export function withTreeKill(proc: PtyProcess, killTree: TreeKiller): PtyProcess { + let killing = false; + return { + get pid() { return proc.pid; }, + onData: (cb) => proc.onData(cb), + onExit: (cb) => proc.onExit(cb), + write: (d) => proc.write(d), + resize: (c, r) => proc.resize(c, r), + kill: () => { + if (killing) return; + killing = true; + const pid = proc.pid; + void killTree(pid).catch(() => { /* the plain kill below still runs */ }).then(() => { + try { proc.kill(); } catch (error) { console.error('DevDeck: terminal close failed', error); } + }); + }, + }; +} + interface Session { /** * The size the pty is at right now — and therefore the size every byte in `chunks` was drawn for. @@ -105,6 +166,9 @@ export class PtyHost { ): void { if (this.stopping) throw new Error('Terminals are shutting down'); if (this.sessions.has(id)) throw new Error(`Terminal already exists: ${id}`); + if (info?.internal !== true && this.list().length >= MAX_SESSIONS) { + throw new Error(`Too many terminals open (${MAX_SESSIONS}). Close some sessions first.`); + } const proc = this.spawn(file, args, { cwd, cols, rows }); let exited!: () => void; const exit = new Promise((resolve) => { exited = resolve; }); @@ -235,6 +299,13 @@ export class PtyHost { try { this.kill(id); } catch (error) { console.error('DevDeck: terminal close failed', error); } } } + /** Kill only the terminals DevDeck opened for itself (provider logins), leaving project sessions running. */ + killInternal(): void { + for (const [id, s] of [...this.sessions]) { + if (!s.internal) continue; + try { this.kill(id); } catch (error) { console.error('DevDeck: terminal close failed', error); } + } + } /** Drain native exit callbacks while Electron's Node environment is still alive. */ shutdown(timeoutMs = 5_000): Promise { diff --git a/src/main/sessionMeta.test.ts b/src/main/sessionMeta.test.ts index 636576a..4eaf985 100644 --- a/src/main/sessionMeta.test.ts +++ b/src/main/sessionMeta.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { appendFileSync, mkdirSync, mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { readClaudeSessionMeta, emptySessionMeta } from './sessionMeta'; +import { readClaudeSessionMeta, emptySessionMeta, _cachedTimestampCount } from './sessionMeta'; import { encodeProjectPath } from '../shared/paths'; describe('readClaudeSessionMeta', () => { @@ -64,6 +64,22 @@ describe('readClaudeSessionMeta over an append-only log', () => { expect(second.activeMs).toBe(4 * 60_000); }); + it('two reads of the same grown log at once fold the appended lines once, not twice', async () => { + // The cockpit tick and a paired machine's poll ask about the same session concurrently; both + // used to resume from the one cached state and append the same lines to it. + writeFileSync(file, `${userLine('start', 0)} +`); + await read(); + let tail = ''; + for (let i = 1; i <= 500; i++) tail += `${asstLine(`step ${i}`, i % 50, 'claude-opus-5', i)} +`; + append(tail); + const [a, b] = await Promise.all([read(), read()]); + expect(a).toEqual(b); + expect(a).toMatchObject({ contextTokens: 500, assistantText: 'step 500' }); + expect(_cachedTimestampCount(file)).toBe(501); + }); + it('never consumes a half-written last line, and reads it once the rest lands', async () => { const whole = asstLine('Whole line', 3, 'claude-fable-5', 999); const cut = Math.floor(whole.length / 2); diff --git a/src/main/sessionMeta.ts b/src/main/sessionMeta.ts index 30c3bcc..f453df2 100644 --- a/src/main/sessionMeta.ts +++ b/src/main/sessionMeta.ts @@ -26,6 +26,8 @@ const NEWLINE = 0x0a; * active session costs the few KB that turn wrote, not the whole log again. */ const _metaCache = new Map(); +/** Test-only: how many line timestamps the cached state for this log holds (each line counted once). */ +export function _cachedTimestampCount(file: string): number { return _metaCache.get(file)?.state.timestamps.length ?? 0; } /** * Fold a log's bytes from `start` into `state`, stopping at the last complete line. @@ -72,11 +74,26 @@ async function foldFrom(handle: FileHandle, start: number, size: number, state: return pos - carry.length; // the trailing partial line is NOT consumed — it may still be being written } +/** + * Reads in progress, by file. The same session is routinely asked about twice at once — this + * machine's cockpit tick and a paired machine polling it — and both used to resume from the one + * cached state, folding the same appended lines into it twice. A second caller now waits for the + * first and is answered from what it cached. + */ +const _reading = new Map>(); + /** Read a Claude session's meta from its on-disk .jsonl (best-effort; an empty meta if missing). */ export async function readClaudeSessionMeta(projectPath: string, sessionId: string, claudeProjectsDir: string): Promise { // Guard the id before it touches a path — a crafted sessionId must not escape ~/.claude/projects (path traversal). if (!isValidSessionId(sessionId)) return emptySessionMeta(); const file = join(claudeProjectsDir, encodeProjectPath(projectPath), sessionId + '.jsonl'); + for (let inFlight = _reading.get(file); inFlight; inFlight = _reading.get(file)) await inFlight.catch(() => undefined); + const work = readMetaFile(file); + _reading.set(file, work); + try { return await work; } finally { if (_reading.get(file) === work) _reading.delete(file); } +} + +async function readMetaFile(file: string): Promise { let mtimeMs: number; let size: number; try { ({ mtimeMs, size } = await stat(file)); } catch { return emptySessionMeta(); } @@ -87,7 +104,8 @@ export async function readClaudeSessionMeta(projectPath: string, sessionId: stri // shrank was rewritten, not appended to, and the state carried over would describe a file that is // no longer there — reparse from zero. const resume = cached && size >= cached.offset; - const state = resume ? cached.state : emptySessionMetaState(); + // A copy, so a read that fails partway leaves the cached state exactly as it was committed. + const state = resume ? copyState(cached.state) : emptySessionMetaState(); let handle: FileHandle; try { handle = await open(file, 'r'); } catch { return emptySessionMeta(); } let offset: number; @@ -102,3 +120,7 @@ export async function readClaudeSessionMeta(projectPath: string, sessionId: stri _metaCache.set(file, { mtimeMs, offset, state, meta }); return meta; } + +function copyState(s: SessionMetaState): SessionMetaState { + return { ...s, timestamps: [...s.timestamps], edited: [...s.edited] }; +} diff --git a/src/main/shutdownScheduler.test.ts b/src/main/shutdownScheduler.test.ts index 00b99e9..ca04fc5 100644 --- a/src/main/shutdownScheduler.test.ts +++ b/src/main/shutdownScheduler.test.ts @@ -44,7 +44,7 @@ describe('ShutdownScheduler auto path', () => { expect(h.deps.log.append).toHaveBeenCalledTimes(1); expect(h.records[0]).toMatchObject({ kind: 'auto', status: 'issued', idleMinutes: 10, sessions: [{ project: 'devdeck', activity: 'attention' }] }); expect(h.records[0].scheduledAt - h.records[0].at).toBe(60_000); - expect(h.deps.execShutdown).toHaveBeenCalledWith(60); + expect(h.deps.execShutdown).toHaveBeenCalledWith(60, expect.any(Function)); expect(h.s.status().phase).toBe('countdown'); }); @@ -130,7 +130,7 @@ describe('manual + cancel', () => { it('shutdownNow issues immediately with the 15s countdown, no arming needed', () => { const h = makeHarness(); h.s.shutdownNow(); - expect(h.deps.execShutdown).toHaveBeenCalledWith(15); + expect(h.deps.execShutdown).toHaveBeenCalledWith(15, expect.any(Function)); expect(h.records[0]).toMatchObject({ kind: 'manual', status: 'issued' }); expect(h.records[0].idleMinutes).toBeUndefined(); expect(h.s.status().phase).toBe('countdown'); @@ -162,6 +162,39 @@ describe('manual + cancel', () => { }); }); +describe('a shutdown command that fails', () => { + // shutdown.exe exiting non-zero (one already pending, policy denial) means there is no OS timer. + // Staying in 'countdown' showed a countdown to nothing and blocked re-arming until restart. + it('returns to disarmed, marks the record as not having happened, and reports it', () => { + const h = makeHarness(); + h.s.shutdownNow(); + const onFailed = (h.deps.execShutdown as ReturnType).mock.calls[0][1] as (m: string) => void; + onFailed('shutdown /s exited with code 1190'); + expect(h.s.status()).toMatchObject({ phase: 'disarmed', scheduledAt: null, kind: null }); + expect(h.records[0]).toMatchObject({ status: 'cancelled' }); + expect(h.deps.onError).toHaveBeenCalledWith(expect.stringContaining('exited with code 1190')); + h.s.arm(); // and the user can arm again without restarting + expect(h.s.status().phase).toBe('armed'); + }); + + it('a late failure from an EARLIER command cannot end a newer countdown', () => { + const h = makeHarness(); + h.s.shutdownNow(); + const stale = (h.deps.execShutdown as ReturnType).mock.calls[0][1] as (m: string) => void; + h.s.cancel(); + h.s.shutdownNow(); + stale('late'); + expect(h.s.status().phase).toBe('countdown'); + }); + + it('a command that throws while starting is handled the same way', () => { + const h = makeHarness({ execShutdown: vi.fn(() => { throw new Error('spawn EPERM'); }) }); + h.s.shutdownNow(); + expect(h.s.status().phase).toBe('disarmed'); + expect(h.deps.onError).toHaveBeenCalledWith(expect.stringContaining('spawn EPERM')); + }); +}); + describe('pendingBootBanner', () => { const issued: ShutdownRecord = { at: 5000, scheduledAt: 65_000, kind: 'auto', idleMinutes: 10, sessions: [], status: 'issued' }; diff --git a/src/main/shutdownScheduler.ts b/src/main/shutdownScheduler.ts index 917a58d..135f84a 100644 --- a/src/main/shutdownScheduler.ts +++ b/src/main/shutdownScheduler.ts @@ -13,8 +13,11 @@ export interface ShutdownStatus { export interface SchedulerDeps { log: { append(r: ShutdownRecord): boolean; updateLast(p: Partial): boolean }; now(): number; - /** spawn `shutdown /s /f /t /c ...` — the countdown is the OS's from here on. */ - execShutdown(delaySec: number): void; + /** + * spawn `shutdown /s /f /t /c ...` — the countdown is the OS's from here on. + * `onFailed` is called if the command could not be started or exited non-zero. + */ + execShutdown(delaySec: number, onFailed: (message: string) => void): void; /** spawn `shutdown /a` */ execAbort(): void; transcriptMtime(): Promise; @@ -43,6 +46,8 @@ export class ShutdownScheduler { private workingCount = 0; private sessions: ShutdownSessionSummary[] = []; private ticking = false; + /** Bumped per issue, so a late failure report from an earlier command cannot end a newer countdown. */ + private issueSeq = 0; constructor(private readonly deps: SchedulerDeps) { this.lastBusyAt = deps.now(); @@ -110,11 +115,29 @@ export class ShutdownScheduler { this.push(); return; } - this.deps.execShutdown(Math.round(countdownMs / 1000)); + const seq = ++this.issueSeq; this.phase = 'countdown'; this.scheduledAt = rec.scheduledAt; this.kind = kind; this.push(); + const failed = (message: string): void => this.issueFailed(seq, message); + try { this.deps.execShutdown(Math.round(countdownMs / 1000), failed); } catch (e) { failed(e instanceof Error ? e.message : String(e)); } + } + + /** + * shutdown.exe refused (a shutdown already pending, policy, missing binary). There is no OS timer, + * so staying in 'countdown' would show a countdown to nothing and block re-arming until restart. + * Back to disarmed, and the record says it never happened — the next-boot banner must not report + * a shutdown that was never scheduled as one that "did not execute". + */ + private issueFailed(seq: number, message: string): void { + if (seq !== this.issueSeq || this.phase !== 'countdown') return; + this.deps.log.updateLast({ status: 'cancelled', cancelledAt: this.deps.now() }); + this.phase = 'disarmed'; + this.scheduledAt = null; + this.kind = null; + this.deps.onError(`DevDeck: shutdown could not be scheduled — ${message}`); + this.push(); } private tick(): void { diff --git a/src/main/store.test.ts b/src/main/store.test.ts index ace3138..c822e36 100644 --- a/src/main/store.test.ts +++ b/src/main/store.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; import { hostname, tmpdir } from 'node:os'; import { join } from 'node:path'; -import { Store } from './store'; +import { Store, renameWithRetry } from './store'; import { sanitizeMachineName } from '../shared/link/machine'; let dir: string; @@ -359,3 +359,35 @@ describe('session summary settings', () => { expect(store.getMachineName()).toBe(sanitizeMachineName(hostname(), 'DevDeck')); // empty falls back rather than showing a blank chip }); }); + +describe('renameWithRetry (state.json commit on Windows)', () => { + const fsError = (code: string) => Object.assign(new Error(code), { code }); + + it('rides out a few transient EPERM/EBUSY/EACCES failures from AV / indexer / OneDrive', () => { + const failures = ['EPERM', 'EBUSY', 'EACCES']; + const rename = vi.fn(() => { const code = failures.shift(); if (code) throw fsError(code); }); + const sleep = vi.fn(); + renameWithRetry('a.tmp', 'a', rename, sleep); + expect(rename).toHaveBeenCalledTimes(4); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([20, 40, 80]); + }); + + it('gives up after 5 attempts and throws the last error', () => { + const rename = vi.fn(() => { throw fsError('EBUSY'); }); + expect(() => renameWithRetry('a.tmp', 'a', rename, () => {})).toThrow('EBUSY'); + expect(rename).toHaveBeenCalledTimes(5); + }); + + it('does not retry an error that waiting cannot fix', () => { + const rename = vi.fn(() => { throw fsError('ENOSPC'); }); + expect(() => renameWithRetry('a.tmp', 'a', rename, () => {})).toThrow('ENOSPC'); + expect(rename).toHaveBeenCalledTimes(1); + }); + + it('the real sync sleep actually waits (Atomics.wait works on this thread)', () => { + const rename = vi.fn().mockImplementationOnce(() => { throw fsError('EPERM'); }).mockImplementationOnce(() => {}); + const t0 = Date.now(); + renameWithRetry('a.tmp', 'a', rename); + expect(Date.now() - t0).toBeGreaterThanOrEqual(15); + }); +}); diff --git a/src/main/store.ts b/src/main/store.ts index 323e31b..c307467 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -15,6 +15,36 @@ interface StateFile { settings?: { language?: string; baseDir?: string; folders?: Folder[]; thresholds?: { freshDays: number; warnDays: number; neglectedDays: number }; agent?: string; openAtLogin?: boolean; viewMode?: 'cards' | 'list'; cockpitSessions?: PersistedSession[]; trayAlert?: 'off' | 'attention' | 'all'; pendingAutoRestore?: PersistedSession[]; contextWindow?: number; shutdownIdleMinutes?: number; cockpitSidebarCollapsed?: boolean; sessionSummary?: boolean; aiSessionSummary?: boolean; windowBounds?: WindowBounds; machineId?: string; machineName?: string; linkHostMode?: boolean; linkPort?: number; linkDevices?: PairedDevice[]; linkHosts?: KnownHost[] }; } +/** Error codes Windows returns while another process (AV scan, indexer, OneDrive) briefly holds the file. */ +const TRANSIENT_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']); +const RENAME_ATTEMPTS = 5; + +function sleepSync(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * renameSync, retried briefly on the errors a scanner or sync client holding the target causes. + * + * On Windows those are routine rather than exceptional: Defender, the search indexer and OneDrive + * all open freshly written files, and a rename onto a file someone has open fails with EPERM/EBUSY + * for a few milliseconds. Unretried, that one collision failed the whole save — a lost setting or + * task edit, reported to the user as an error. Five attempts over ~300 ms ride it out; anything + * else (or anything that outlasts that) still throws to the caller as before. + */ +export function renameWithRetry( + from: string, to: string, + rename: (from: string, to: string) => void = renameSync, sleep: (ms: number) => void = sleepSync, +): void { + for (let attempt = 1; ; attempt++) { + try { rename(from, to); return; } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (attempt >= RENAME_ATTEMPTS || !code || !TRANSIENT_RENAME_CODES.has(code)) throw err; + sleep(20 * 2 ** (attempt - 1)); // 20, 40, 80, 160 ms + } + } +} + const EMPTY: StoreEntry = { note: '', pinned: false, hidden: false, lastOpened: null, todos: [], }; @@ -70,7 +100,7 @@ export class Store { try { const serialized = JSON.stringify(this.state, null, 2); writeFileSync(tmp, serialized, 'utf8'); - renameSync(tmp, this.filePath); + renameWithRetry(tmp, this.filePath); this.committed = serialized; // Mirror the just-written good state to .bak so external corruption of the live file (disk error, // a sync tool, a manual edit) is recoverable on the next load instead of starting from empty. diff --git a/src/main/usageScan.test.ts b/src/main/usageScan.test.ts index 9804562..3cc44ed 100644 --- a/src/main/usageScan.test.ts +++ b/src/main/usageScan.test.ts @@ -161,6 +161,21 @@ describe('scanUsage', () => { expect((await scan()).global.input).toBe(16); }); + it('two scans resuming the same grown file at once each count the appended lines exactly once', async () => { + // The deck's all-time report and the usage view's last-N-days run concurrently. Both used to + // resume from the SAME cached digest and fold the tail into it in place — doubling it for good. + writeFileSync(file, asst('claude-opus-4-8', { input_tokens: 100 }) + '\n'); + expect((await scan()).global.input).toBe(100); + let tail = ''; + for (let i = 0; i < 2000; i++) tail += asst('claude-opus-4-8', { input_tokens: 100 }) + '\n'; + appendFileSync(file, tail); + const since = Date.parse('2026-05-01T00:00:00.000Z'); + const [all, recent] = await Promise.all([scan(), scanUsage([{ path: project, name: 'live' }], root, since)]); + expect(all.global.input).toBe(2001 * 100); + expect(recent.global.input).toBe(2001 * 100); + expect((await scan()).global.input).toBe(2001 * 100); // and the cache it left behind is right too + }); + it('re-reads from the beginning when the file was replaced rather than appended to', async () => { // A shorter file, or one born at a different moment, is not the file the digest describes. writeFileSync(file, [asst('claude-opus-4-8', { input_tokens: 10 }), asst('claude-opus-4-8', { input_tokens: 10 })].join('\n') + '\n'); diff --git a/src/main/usageScan.ts b/src/main/usageScan.ts index da60c77..0d0edb6 100644 --- a/src/main/usageScan.ts +++ b/src/main/usageScan.ts @@ -169,9 +169,14 @@ async function foldDigest( ): Promise { const resume = resumable(previous, size, birthtimeMs); const base = resume ? previous : emptyDigest(mtimeMs, size, birthtimeMs); - const rollup = new Map(base.entries.map((e) => [e.day + ' ' + e.model, e])); - const stamps = base.stamps; - const stampDayMs = base.stampDayMs; + // Fold into COPIES, never into the cached digest. Two scans can be resuming the same file at once + // (the deck's all-time report and the usage view's last-N-days, or a remote viewer asking too); + // folding in place had both add the same appended lines to the one shared rollup, doubling them in + // the cache until restart (200,100 tokens became 400,100). A read that fails halfway likewise left + // its partial sums behind. With copies, the cache only changes when a whole fold is committed. + const rollup = new Map(base.entries.map((e) => [e.day + ' ' + e.model, cloneEntry(e)])); + const stamps = [...base.stamps]; + const stampDayMs = [...base.stampDayMs]; let offset = resume ? base.offset : 0; if (offset >= size) { const settled = { ...base, mtimeMs, size, birthtimeMs, offset }; @@ -226,6 +231,35 @@ function cloneEntry(e: DigestEntry): DigestEntry { return { ...e, totals: { ...e.totals } }; } +/** + * Folds in progress, by file. A second scan reaching a file that is already being folded waits for + * that fold and then reads the cache it committed, instead of reading the same tail a second time. + * (Correctness does not depend on this — folds work on copies — it only avoids the duplicate read.) + */ +const _folding = new Map>(); + +/** The digest to count for one file: the cached one when nothing changed, else a fresh fold. */ +async function digestFor(full: string, fileMs: number, size: number, birthtimeMs: number): Promise { + for (let inFlight = _folding.get(full); inFlight; inFlight = _folding.get(full)) await inFlight; + const cached = _fileCache.get(full); + // Nothing appended AND nothing left unread: the digest already answers for this file. + if (cached && cached.mtimeMs === fileMs && cached.size === size && cached.offset >= size) { + cacheTouch(full); // keep the hot file at the recent end of the LRU order + return cached; + } + const work = foldDigest(full, fileMs, fileMs, size, birthtimeMs, cached); + const settled = work.catch(() => null); + _folding.set(full, settled); + try { + const parsed = await settled; + if (!parsed) return null; + cacheSet(full, parsed.durable); // only what is aligned to a record boundary may be resumed + return parsed.view; // what is counted includes a line still being written + } finally { + if (_folding.get(full) === settled) _folding.delete(full); + } +} + /** Aggregate token usage across the given repos' Claude sessions. sinceMs filters by day (Infinity = all). */ export async function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs: number): Promise { const global = emptyTotals(); @@ -250,16 +284,8 @@ export async function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sin let size = 0; let birthtimeMs = 0; try { ({ mtimeMs: fileMs, size, birthtimeMs } = await stat(full)); } catch { /* keep defaults */ } - let digest = _fileCache.get(full); - // Nothing appended AND nothing left unread: the digest already answers for this file. - if (digest && digest.mtimeMs === fileMs && digest.size === size && digest.offset >= size) { - cacheTouch(full); // keep the hot file at the recent end of the LRU order - } else { - const parsed = await foldDigest(full, fileMs, fileMs, size, birthtimeMs, digest); - if (!parsed) continue; // unreadable — skip, don't poison the cache - cacheSet(full, parsed.durable); // only what is aligned to a record boundary may be resumed - digest = parsed.view; // what is counted includes a line still being written - } + const digest = await digestFor(full, fileMs, size, birthtimeMs); + if (!digest) continue; // unreadable — skip, don't poison the cache let contributed = false; for (const e of digest.entries) { if (!inRange(e.dayMs)) continue; @@ -275,7 +301,7 @@ export async function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sin webSearch += e.webSearch; webFetch += e.webFetch; } - const stampsInRange = sinceMs === Infinity ? digest.stamps : digest.stamps.filter((_, i) => digest!.stampDayMs[i] >= sinceMs); + const stampsInRange = sinceMs === Infinity ? digest.stamps : digest.stamps.filter((_, i) => digest.stampDayMs[i] >= sinceMs); projActiveMs += activeMsFromTimestamps(stampsInRange); // Count a session only when it had activity in the selected range (an in-range usage entry, or // any in-range message). Counting every file regardless of range let long-dead projects render From 8471c49623b661a28cf7236578286f78a80af276 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 13:06:21 -0700 Subject: [PATCH 3/8] fix(cockpit): a closed session stays closed, everywhere; draw terminals correctly; apply agent updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close, measured on two paired instances before and after (qa/close.mjs, 22 checks): - Closing at one machine left an "exited" tile on the OTHER one, which was saved, came back as a previous session, and was re-run by an update restart. The exit now says it was a close (and from where), and every deck showing the session follows it. - A bulk close brought all but one of the closed sessions back as dead tiles: each close makes the machine announce its sessions, and the first announcement still listed the rest, which adoption read as "started elsewhere". Closed ids are tombstoned until the machine stops listing them. - A remote close was a fire-and-forget notify. Offline, it vanished and the tile was adopted back on reconnect with the agent still running; without the `control` permission it looked like it worked. A new `cockpit:closeSession` answers: offline closes are kept and delivered on reconnect, refusals put the tile back and say why. Older hosts get the old notify. - Update restarts and the restart counter skip tiles whose terminal already ended. - Sessions another machine starts no longer take the selection and the keyboard; "+ New session" on a remote tile opens on that machine; reopening a project only dedupes against tiles on the machine being opened; Enter on a focused Cancel cancels; a failed restart keeps the tile as a saved entry; a restart waits for the old agent to exit and starts a never-written conversation fresh under the same id instead of failing to resume it. Rendering: tiles draw with xterm's DOM renderer again. Under the WebGL addon Claude Code's screen built up stale cells - old status lines stacked down the screen, earlier text showing through where new text had spaces - while the buffer was right: minimize/restore, which only makes xterm redraw every row (measured: no pty resize at all), cleared it every time. WebGL had left the GPU process's cost unchanged when it was measured. Agent updates: Claude Code shows "Update installed · Restart to apply", and its in-session /restart exists only on macOS and Linux (gated on platform), so on Windows the update waited for a manual /exit. The tile header now offers "Apply update": the restart keeps the conversation, and while a turn is running it waits for the turn to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta (cherry picked from commit d0162e01cf381057033255e66b97237a4ecf5c08) --- qa/close.mjs | 292 +++++++++++++++++++++ src/main/api/deckApi.close.test.ts | 147 +++++++++++ src/main/api/deckApi.ts | 85 ++++++- src/main/ipc.cockpit.test.ts | 5 +- src/preload/preload.ts | 6 +- src/renderer/cockpitView.ts | 293 ++++++++++++++++------ src/renderer/features/cockpit/cockpit.css | 18 ++ src/renderer/global.d.ts | 4 +- src/renderer/locales/en.json | 10 + src/renderer/locales/ja.json | 10 + src/renderer/locales/ko.json | 10 + src/renderer/locales/zh.json | 10 + src/renderer/main.ts | 4 +- src/shared/closeTombstones.test.ts | 46 ++++ src/shared/closeTombstones.ts | 62 +++++ src/shared/sessionStatus.test.ts | 20 +- src/shared/sessionStatus.ts | 15 ++ src/shared/types.ts | 6 + 18 files changed, 951 insertions(+), 92 deletions(-) create mode 100644 qa/close.mjs create mode 100644 src/main/api/deckApi.close.test.ts create mode 100644 src/shared/closeTombstones.test.ts create mode 100644 src/shared/closeTombstones.ts diff --git a/qa/close.mjs b/qa/close.mjs new file mode 100644 index 0000000..6a59961 --- /dev/null +++ b/qa/close.mjs @@ -0,0 +1,292 @@ +// What "close" actually does, on both ends of a DevDeck Link, driven the way a person drives it. +// +// Every defect this guards against was silent: the deck that pressed close looked right, and the +// damage was somewhere else — an "exited" tile left on the OTHER machine (saved, and re-run after an +// update restart), a close pressed while the link was down that never arrived (the agent kept running +// and the tile was adopted straight back on reconnect), a close refused for lack of permission that +// looked like it worked, and a bulk close where all but one of the closed sessions came back as dead +// tiles. None of it shows up in unit tests, because each is an ordering between two processes. +// +// Run with `npm run qa:close`. Two Electron instances: do NOT run it alongside `npm run qa`/`qa:link`. +// Sessions run the real `claude` CLI in a throwaway repository with no conversation to continue (see +// the fixture note in link.mjs for why it must never be this checkout). +import { _electron as electron } from 'playwright'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { closeElectron } from './electron-lifecycle.mjs'; + +const repo = dirname(dirname(fileURLToPath(import.meta.url))); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const stamp = () => new Date().toISOString().slice(11, 19); + +const fixtureRepo = mkdtempSync(join(tmpdir(), 'devdeck-close-fixture-')); +writeFileSync(join(fixtureRepo, 'README.md'), '# fixture'); +execFileSync('git', ['init', '-q'], { cwd: fixtureRepo }); +execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'add', '-A'], { cwd: fixtureRepo }); +execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'commit', '-q', '-m', 'fixture'], { cwd: fixtureRepo }); + +async function launch(tag, registerFolder) { + const userData = mkdtempSync(join(tmpdir(), `devdeck-close-${tag}-`)); + const temp = mkdtempSync(join(tmpdir(), `devdeck-closetmp-${tag}-`)); + const app = await electron.launch({ + args: ['.', `--user-data-dir=${userData}`, '--no-sandbox', '--disable-gpu'], + cwd: repo, + // A nested agent must not inherit this session's identity (same scrub as link.mjs / perf.mjs). + env: { ...process.env, TEMP: temp, TMP: temp, TMPDIR: temp, CLAUDE_CODE_SSE_PORT: '', CLAUDECODE: '', CLAUDE_CODE_ENTRYPOINT: '' }, + }); + const win = await app.firstWindow(); + win.on('pageerror', (e) => console.error(`[${stamp()}] ${tag}: page error ${String(e).slice(0, 200)}`)); + await win.waitForSelector('#cards .card, #cards .empty', { timeout: 30000 }).catch(() => {}); + if (registerFolder) { + await app.evaluate(({ dialog }, p) => { dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [p] }); }, fixtureRepo); + await win.evaluate(async () => window.devdeck.pickFolder()); + await win.evaluate(async (p) => window.devdeck.addFolder(p, 'repo'), fixtureRepo); + } + await win.setViewportSize({ width: 1400, height: 900 }); + return { app, win }; +} + +async function freePort() { + const { createServer } = await import('node:net'); + return new Promise((resolve, reject) => { + const s = createServer(); s.on('error', reject); + s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); + }); +} + +/** The agent processes under a pid: what is actually still running, whatever the UI says. */ +function agentProcessCount(rootPid) { + if (process.platform !== 'win32') return null; + const out = execFileSync('powershell', ['-NoProfile', '-Command', + 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress'], { encoding: 'utf8', maxBuffer: 64 << 20 }); + const kids = new Map(); + for (const p of JSON.parse(out)) { if (!kids.has(p.ParentProcessId)) kids.set(p.ParentProcessId, []); kids.get(p.ParentProcessId).push(p); } + let n = 0; const stack = [rootPid]; + while (stack.length) for (const c of kids.get(stack.pop()) ?? []) { if (/^(powershell|pwsh|claude|node)\.exe$/i.test(c.Name)) n++; stack.push(c.ProcessId); } + return n; +} + +const rows = (win) => win.evaluate(() => [...document.querySelectorAll('#shell-session-groups .shell-session')].map((n) => ({ + label: n.querySelector('strong')?.textContent ?? '', + activity: [...n.classList].find((c) => c.startsWith('activity-'))?.slice(9) ?? '?', + previous: n.closest('.shell-session-wrap')?.dataset.previous === 'true', +}))); +const liveRows = async (win, label) => (await rows(win)).filter((r) => r.label.includes(label) && !r.previous); +const persisted = (win, label) => win.evaluate(async (l) => (await window.devdeck.cockpit.loadSessions()).filter((s) => (s.label ?? '').includes(l)).length, label); +const running = (win, label) => win.evaluate(async (l) => (await window.devdeck.cockpit.liveSessions()).some((s) => (s.label ?? '').includes(l)), label); +const headerTitle = (win) => win.evaluate(() => document.querySelector('#ck-header .title')?.textContent ?? ''); + +async function waitFor(fn, ms = 20000, step = 400) { + const end = Date.now() + ms; + while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(step); } + return null; +} + +/** Open a session on `owner` (window of the machine that runs it) and name it there. */ +async function openNamed(owner, label) { + return owner.win.evaluate(async (label) => { + const projects = await window.devdeck.listProjects(); + const res = await window.devdeck.cockpit.open({ projectPath: projects[0].path, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }); + await new Promise((r) => setTimeout(r, 800)); + const me = (await window.devdeck.cockpit.liveSessions()).find((s) => s.id === res.id); + if (me) await window.devdeck.cockpit.renameSession(me.id, label, me); + return res.id; + }, label); +} + +/** Open a session on the host FROM the viewer, then name it on the host. */ +async function openFromViewer(label) { + await viewer.win.evaluate(async () => { + const m = (await window.devdeck.link.machines()).find((x) => x.state === 'connected'); + const projects = await window.devdeck.machine(m.machineId).listProjects(); + await window.devdeck.machine(m.machineId).cockpit.open({ projectPath: projects[0].path, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }); + }); + await sleep(800); + await host.win.evaluate(async (label) => { + const me = (await window.devdeck.cockpit.liveSessions()).find((s) => !s.label); + if (me) await window.devdeck.cockpit.renameSession(me.id, label, me); + }, label); + await waitFor(async () => (await liveRows(viewer.win, label)).length && (await liveRows(host.win, label)).length); +} + +async function selectRow(win, label) { + await win.locator('#shell-session-groups .shell-session-wrap:not(.is-previous) .shell-session', { hasText: label }).first().click(); + return waitFor(async () => (await headerTitle(win)).includes(label), 5000, 150); +} + +/** Header button by position: new session, pin, rename, folder, restart, close. */ +const HEADER = { newSession: 0, restart: 4, close: 5 }; +async function pressHeader(win, which) { + await win.evaluate((i) => document.querySelectorAll('#ck-header .ck-act')[i].click(), HEADER[which]); +} +async function closeViaUi(win, label) { + if (!(await selectRow(win, label))) return false; + await pressHeader(win, 'close'); + await win.waitForSelector('.ck-confirm-ok', { timeout: 5000 }); + await win.click('.ck-confirm-ok'); + return true; +} + +const host = await launch('host', true); +const viewer = await launch('viewer', false); +const checks = {}; +const notes = {}; +const check = (name, ok, detail) => { checks[name] = !!ok; if (detail !== undefined) notes[name] = detail; console.log(`[${stamp()}] ${ok ? 'PASS' : 'FAIL'} ${name}${detail !== undefined ? ` ${JSON.stringify(detail)}` : ''}`); }; + +try { + const hostPid = host.app.process().pid; + await openNamed(host, 'qa-H1'); + await openNamed(host, 'qa-H2'); + + // --- pair through the UI, on a port nobody else holds --- + await host.win.evaluate(async (p) => window.devdeck.link.setPort(p), await freePort()); + await host.win.click('.rail-item[data-view="settings"]'); + await host.win.waitForSelector('#settings-form .link-block .chip.chip-primary', { timeout: 15000 }); + await host.win.click('#settings-form .link-block .chip.chip-primary'); + await host.win.waitForSelector('#settings-form .link-code', { timeout: 15000 }); + const code = (await host.win.textContent('#settings-form .link-code')).trim(); + await viewer.win.click('.rail-item[data-view="settings"]'); + await viewer.win.fill('#settings-form .link-code-input', code); + await viewer.win.click('#settings-form .link-add-row .chip'); + await viewer.win.waitForFunction(() => document.querySelector('#settings-form .link-state.is-connected') !== null, undefined, { timeout: 20000 }); + const machineId = await viewer.win.evaluate(async () => (await window.devdeck.link.machines()).find((m) => m.state === 'connected').machineId); + check('viewerAdoptsHostSessions', await waitFor(async () => (await liveRows(viewer.win, 'qa-H1')).length && (await liveRows(viewer.win, 'qa-H2')).length)); + + // --- 1. a remote close ends the session THERE, and the host's own deck follows --- + await openFromViewer('qa-V1'); + await sleep(2500); + const procsBefore = agentProcessCount(hostPid); + await closeViaUi(viewer.win, 'qa-V1'); + await sleep(5000); + const procsAfter = agentProcessCount(hostPid); + check('remoteClose.killsHostSession', !(await running(host.win, 'qa-V1'))); + check('remoteClose.hostProcessesEnd', procsBefore === null || procsBefore - procsAfter >= 2, { procsBefore, procsAfter }); + check('remoteClose.noGhostOnHost', (await liveRows(host.win, 'qa-V1')).length === 0 && (await persisted(host.win, 'qa-V1')) === 0); + check('remoteClose.goneOnViewer', (await liveRows(viewer.win, 'qa-V1')).length === 0 && (await persisted(viewer.win, 'qa-V1')) === 0); + + // --- 2. closing at the host removes the session from the viewer watching it --- + await closeViaUi(host.win, 'qa-H1'); + await sleep(5000); + check('hostClose.noGhostOnViewer', (await liveRows(viewer.win, 'qa-H1')).length === 0 && (await persisted(viewer.win, 'qa-H1')) === 0); + + // --- 3. a close pressed while the link is down is delivered when it comes back --- + await openFromViewer('qa-V2'); + await sleep(1500); + await host.win.evaluate(async () => window.devdeck.link.setHostMode(false)); + await waitFor(() => viewer.win.evaluate(async () => (await window.devdeck.link.machines()).every((m) => m.state !== 'connected')), 20000); + await closeViaUi(viewer.win, 'qa-V2'); + await sleep(1000); + await host.win.evaluate(async () => window.devdeck.link.setHostMode(true)); + await sleep(1500); + await viewer.win.evaluate(async (id) => window.devdeck.link.reconnect(id).catch(() => {}), machineId); + await waitFor(() => viewer.win.evaluate(async () => (await window.devdeck.link.machines()).some((m) => m.state === 'connected')), 30000); + await sleep(8000); + check('offlineClose.deliveredOnReconnect', !(await running(host.win, 'qa-V2'))); + check('offlineClose.notReadopted', (await liveRows(viewer.win, 'qa-V2')).length === 0); + + // --- 4. a close this device is not permitted to make is not pretended --- + const fp = await host.win.evaluate(async () => (await window.devdeck.link.hostStatus()).devices?.[0]?.fingerprint ?? null); + await host.win.evaluate(async (fp) => window.devdeck.link.setDevicePermissions(fp, ['observe']), fp); + await sleep(1500); + await closeViaUi(viewer.win, 'qa-H2'); + await sleep(4000); + check('deniedClose.sessionKeepsRunning', await running(host.win, 'qa-H2')); + check('deniedClose.tileComesBackHonestly', (await liveRows(viewer.win, 'qa-H2')).length === 1); + await host.win.evaluate(async (fp) => window.devdeck.link.setDevicePermissions(fp, ['observe', 'control', 'spawn', 'write']), fp); + await sleep(1500); + + // --- 5. a bulk close leaves nothing behind (it used to bring all but one back as dead tiles) --- + for (const l of ['qa-B1', 'qa-B2', 'qa-B3', 'qa-B4']) await openNamed(host, l); + await waitFor(async () => (await liveRows(host.win, 'qa-B4')).length); + await sleep(3000); + for (const l of ['qa-B1', 'qa-B2', 'qa-B3']) { + await host.win.locator('#shell-session-groups .shell-session', { hasText: l }).first().click({ modifiers: ['Control'] }); + await sleep(150); + } + await host.win.click('.shell-selection-close'); + await host.win.waitForSelector('.ck-confirm-ok', { timeout: 5000 }); + await host.win.click('.ck-confirm-ok'); + await sleep(6000); + const leftHost = (await rows(host.win)).filter((r) => /qa-B[123]/.test(r.label)); + const leftViewer = (await rows(viewer.win)).filter((r) => /qa-B[123]/.test(r.label)); + check('bulkClose.noZombiesOnHost', leftHost.length === 0, leftHost); + check('bulkClose.noZombiesOnViewer', leftViewer.length === 0, leftViewer); + check('bulkClose.keepsTheUnselected', (await liveRows(host.win, 'qa-B4')).length === 1); + + // --- 6. a session started elsewhere does not take the screen or the keyboard --- + await viewer.win.click('.rail-item[data-view="cockpit"]').catch(() => {}); + await selectRow(viewer.win, 'qa-B4'); + const before6 = await headerTitle(viewer.win); + await openNamed(host, 'qa-H6'); + await waitFor(async () => (await liveRows(viewer.win, 'qa-H6')).length); + await sleep(1500); + check('adoption.doesNotStealSelection', (await headerTitle(viewer.win)) === before6, { before: before6, after: await headerTitle(viewer.win) }); + + // --- 7. "+ new session" on a remote tile opens on THAT machine --- + const hostCount7 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length); + await selectRow(viewer.win, 'qa-H6'); + await pressHeader(viewer.win, 'newSession'); + await sleep(4000); + const hostCount7b = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length); + const viewerLocal7 = await viewer.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length); + check('remoteNewSession.opensOnHost', hostCount7b === hostCount7 + 1 && viewerLocal7 === 0, { hostCount7, hostCount7b, viewerLocal7 }); + + // --- 8. Enter on a focused Cancel cancels --- + await selectRow(host.win, 'qa-H6'); + await pressHeader(host.win, 'close'); + await host.win.waitForSelector('.ck-confirm-cancel', { timeout: 5000 }); + await host.win.focus('.ck-confirm-cancel'); + await host.win.keyboard.press('Enter'); + await sleep(1500); + check('confirm.enterOnCancelCancels', await running(host.win, 'qa-H6')); + + // --- 9. restart keeps the tile, its name, and leaves exactly one terminal for it --- + const count9 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length); + await selectRow(host.win, 'qa-H6'); + await pressHeader(host.win, 'restart'); + await host.win.waitForSelector('.ck-confirm-ok', { timeout: 5000 }); + await host.win.click('.ck-confirm-ok'); + await sleep(7000); + const count9b = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length); + check('restart.oneTerminalSameName', count9b === count9 && (await liveRows(host.win, 'qa-H6')).length === 1, { count9, count9b, rows: await liveRows(host.win, 'qa-H6') }); + check('restart.noGhostOnViewer', (await liveRows(viewer.win, 'qa-H6')).filter((r) => r.activity === 'exited').length === 0, await liveRows(viewer.win, 'qa-H6')); + + // --- 10. "Update installed · Restart to apply" offers a one-click restart that keeps the tile --- + // Claude Code's own /restart is macOS/Linux-only, so on Windows this button is the way onto a new + // version. The notice is printed into a real tile (the agent is left for its shell first), exactly + // where Claude Code draws it, and the button must restart the session under the same name. + await openNamed(host, 'qa-U1'); + await waitFor(async () => (await liveRows(host.win, 'qa-U1')).length); + await waitFor(() => selectRow(host.win, 'qa-U1'), 15000, 1000); + const u1 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).find((s) => s.label === 'qa-U1')?.id); + const screenHas = (text) => host.win.evaluate((t) => [...(document.querySelector('.ck-term.show .xterm-rows')?.children ?? [])].some((r) => (r.textContent ?? '').includes(t)), text); + await waitFor(() => screenHas('No, exit'), 20000); + await host.win.evaluate((id) => window.devdeck.cockpit.input(id, '\r'), u1); // trust prompt: "No, exit" -> PowerShell + await waitFor(() => screenHas('PS '), 15000); + await host.win.evaluate((id) => window.devdeck.cockpit.input(id, "Write-Host ' >> bypass permissions on Update installed · Restart to apply'\r"), u1); + const appeared = await waitFor(() => host.win.evaluate(() => !!document.querySelector('#ck-header .ck-update')), 8000); + check('update.buttonAppears', appeared, appeared ? undefined : await host.win.evaluate(() => [...(document.querySelector('.ck-term.show .xterm-rows')?.children ?? [])].map((r) => r.textContent).filter((t) => t && t.trim()).slice(-6))); + const count10 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).length); + await host.win.click('#ck-header .ck-update').catch(() => {}); + await sleep(7000); + const after10 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).map((s) => ({ id: s.id, label: s.label }))); + check('update.restartsSameTile', after10.length === count10 && after10.some((s) => s.label === 'qa-U1' && s.id !== u1), { u1, after10 }); + check('update.buttonGoneAfterRestart', !(await host.win.evaluate(() => !!document.querySelector('#ck-header .ck-update')))); + + // --- what a relaunch / update restart would bring back --- + const exitedAnywhere = [...await rows(host.win), ...await rows(viewer.win)].filter((r) => r.activity === 'exited' && !r.previous); + check('noExitedTilesAnywhere', exitedAnywhere.length === 0, exitedAnywhere); +} catch (e) { + console.error('close QA failed', e); + checks.harness = false; +} finally { + for (const i of [viewer, host]) { try { await closeElectron(i.app); } catch (e) { console.error(String(e)); checks.cleanExit = false; } } +} + +const failed = Object.entries(checks).filter(([, ok]) => !ok).map(([name]) => name); +console.log(JSON.stringify({ checks, notes }, null, 2)); +console.log(failed.length ? `FAILED: ${failed.join(', ')}` : 'all close checks passed'); +process.exit(failed.length ? 1 : 0); diff --git a/src/main/api/deckApi.close.test.ts b/src/main/api/deckApi.close.test.ts new file mode 100644 index 0000000..90cb909 --- /dev/null +++ b/src/main/api/deckApi.close.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { join } from 'node:path'; + +vi.mock('electron', () => ({ + ipcMain: { handle: () => {}, on: () => {} }, + dialog: { showOpenDialog: vi.fn() }, + shell: {}, + clipboard: { readImage: vi.fn() }, + app: { getPath: () => '', getVersion: () => '0.0.0', isPackaged: false }, +})); + +import { createDeckApi, type DeckApiConfig } from './deckApi'; +import type { DeckApi } from './methods'; +import { remoteCallContext } from '../link/hostServer'; +import { qualifyRemoteId } from '../../shared/link/machine'; + +const ALLOWED_ROOT = join(process.cwd(), 'allowed-root'); + +/** A pty table whose terminals exit when killed, the way node-pty reports it: asynchronously. */ +function makePtys() { + const sessions = new Map void }>(); + return { + killed: [] as string[], + create(id: string, _f: string, _a: string[], _c: string, _w: number, _h: number, _d: unknown, onExit: (e: { exitCode: number }) => void) { + sessions.set(id, { onExit }); + }, + list() { return [...sessions.keys()].map((id) => ({ id, projectPath: ALLOWED_ROOT, sessionId: null, agentId: 'claude', startedAtMs: 0, label: null })); }, + kill(id: string) { + const s = sessions.get(id); if (!s) return; + sessions.delete(id); this.killed.push(id); + queueMicrotask(() => s.onExit({ exitCode: 1 })); + }, + exit(id: string) { const s = sessions.get(id); sessions.delete(id); s?.onExit({ exitCode: 0 }); }, + note: () => false, pid: () => null, buffer: () => ({ data: '', cols: 0, rows: 0 }), + }; +} + +type Link = { machines: () => { machineId: string; state: string }[]; call: (m: string, method: string, args: unknown[]) => Promise; notify: ReturnType; detach: ReturnType; attach: ReturnType }; + +function build(link: Link | null) { + const ptys = makePtys(); + const bundle = createDeckApi({ + win: { on: () => {}, isDestroyed: () => true, webContents: { send: () => {} } }, + defaultBaseDir: ALLOWED_ROOT, + store: { getFolders: () => [{ path: ALLOWED_ROOT, kind: 'root' }], getAgent: () => 'claude', getSessionSummary: () => true, getAiSessionSummary: () => false, getCockpitSessions: () => [], setCockpitSessions: () => {}, setLastOpened: () => {} }, + sendError: vi.fn(), defaultLanguage: 'en', ptyHost: ptys, ptyAvailable: true, + tray: { applyCounts: vi.fn(), setAlertImage: vi.fn() }, shutdown: null, shutdownLog: null, bootTimeMs: () => 0, + link: () => link, + } as unknown as DeckApiConfig); + const exits: unknown[] = []; + bundle.events.subscribe((channel, payload) => { if (channel === 'cockpit:exit') exits.push(payload); }); + return { api: bundle.methods as DeckApi, ptys, exits }; +} + +const call = (api: DeckApi, name: string, ...args: unknown[]) => Promise.resolve(api[name].handler(...args)); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +describe('closing a session', () => { + let link: Link; + beforeEach(() => { + link = { machines: () => [{ machineId: '0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', state: 'connected' }], call: vi.fn(async () => ({ ok: true })), notify: vi.fn(), detach: vi.fn(), attach: vi.fn() }; + }); + + it('tells every deck showing it that it was CLOSED, not that it crashed', async () => { + const { api, ptys, exits } = build(link); + const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string }; + expect(res.id).toBeTruthy(); + await call(api, 'cockpit:closeSession', res.id); + await flush(); + expect(exits).toContainEqual({ id: res.id, exitCode: 1, closed: true, closedBy: 'local' }); + }); + + it('marks a close requested by a paired machine as remote', async () => { + const { api, exits } = build(link); + const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string }; + await remoteCallContext.run('remote', () => call(api, 'cockpit:closeSession', res.id)); + await flush(); + expect(exits).toContainEqual({ id: res.id, exitCode: 1, closed: true, closedBy: 'remote' }); + }); + + it('an exit nobody asked for stays a plain exit', async () => { + const { api, ptys, exits } = build(link); + const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string }; + ptys.exit(res.id); + expect(exits).toContainEqual({ id: res.id, exitCode: 0 }); + }); + + it('the legacy notify close is marked too', async () => { + const { api, exits } = build(link); + const res = await call(api, 'cockpit:open', { projectPath: ALLOWED_ROOT, sessionId: null, cols: 80, rows: 24, mode: 'new', agentId: 'claude' }) as { id: string }; + api['cockpit:close'].handler(res.id); + await flush(); + expect(exits).toContainEqual({ id: res.id, exitCode: 1, closed: true, closedBy: 'local' }); + }); + + const remoteId = qualifyRemoteId('0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', 'C:\\repo#3'); + + it('a remote close is ASKED, and answered', async () => { + const { api } = build(link); + expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true }); + expect(link.call).toHaveBeenCalledWith('0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', 'cockpit:closeSession', ['C:\\repo#3']); + expect(link.detach).toHaveBeenCalledWith(remoteId); + }); + + it('an unreachable machine is reported as offline, so the close can be sent later', async () => { + link.machines = () => [{ machineId: '0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', state: 'offline' }]; + const { api } = build(link); + expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: false, reason: 'offline' }); + expect(link.call).not.toHaveBeenCalled(); + link.machines = () => [{ machineId: '0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', state: 'connected' }]; + link.call = vi.fn(async () => { throw new Error('link is closed'); }); + expect(await call(api, 'cockpit:closeSession', remoteId)).toMatchObject({ ok: false, reason: 'offline' }); + }); + + it('a refusal says so instead of pretending the session closed', async () => { + link.call = vi.fn(async () => { throw new Error('not permitted: cockpit:closeSession'); }); + const { api } = build(link); + expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: false, reason: 'denied' }); + }); + + it('a session already gone there is a close already done', async () => { + link.call = vi.fn(async () => { throw new Error('not a session on this machine: cockpit:closeSession'); }); + const { api } = build(link); + expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true }); + }); + + it('falls back to the old notify for a host that predates the answered close', async () => { + link.call = vi.fn(async () => { throw new Error('no such method: cockpit:closeSession'); }); + const { api } = build(link); + expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true }); + expect(link.notify).toHaveBeenCalledWith('0b6b3c1e-6f7a-4c2d-9a8e-1f2e3d4c5b6a', 'cockpit:close', ['C:\\repo#3']); + }); + + it('waits out the host rate limit rather than dropping part of a bulk close', async () => { + let n = 0; + link.call = vi.fn(async () => { if (n++ < 2) throw new Error('too many requests: cockpit:closeSession'); return { ok: true }; }); + const { api } = build(link); + expect(await call(api, 'cockpit:closeSession', remoteId)).toEqual({ ok: true }); + expect(link.call).toHaveBeenCalledTimes(3); + }); + + it('refuses to relay a qualified id for a remote caller', async () => { + const { api } = build(link); + expect(await remoteCallContext.run('remote', () => call(api, 'cockpit:closeSession', remoteId))).toEqual({ ok: false, reason: 'error' }); + expect(link.call).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index 56d1f96..5a399a6 100644 --- a/src/main/api/deckApi.ts +++ b/src/main/api/deckApi.ts @@ -12,7 +12,7 @@ import { applyOpenAtLogin, effectiveOpenAtLogin } from '../autostart'; import { scanFolders, isRepo } from '../scanner'; import { getGitInfo, getRepoUrl, getGitBranchDirty, getRecentCommits } from '../gitInfo'; import { getProvider, availableAgents, resolveOpenSession, resolveProjectOpenCommand } from '../agents'; -import { toAgentId, type AgentId, type Folder, type OpenMode, type ProjectOpenIntent, type SessionMeta } from '../../shared/types'; +import { toAgentId, type AgentId, type CloseResult, type Folder, type OpenMode, type ProjectOpenIntent, type SessionMeta } from '../../shared/types'; import { isAllowedPath, isAllowedFilePath, resolveAgentFilePath, AGENT_OPEN_EXT } from '../../shared/pathGuard'; import { basename, cwdKey } from '../../shared/paths'; import { isAllowedExternalUrl, isSafeRepoUrl, isOpenableTerminalLink } from '../../shared/externalUrl'; @@ -149,7 +149,8 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // Opt-in AI layer for the cockpit's per-session summary line (see aiSummary.ts). Constructed // regardless of the setting — it is inert while disabled — so toggling it needs no restart. - const aiSummarizer = makeAiSummarizer(); + // A failed summary used to vanish into a cached null with no trace of why; it belongs in this machine's log. + const aiSummarizer = makeAiSummarizer({ onFailure: (message) => cfg.diagnostics?.write('warn', 'ai', message) }); aiSummarizer.setEnabled(cfg.store.getAiSessionSummary()); const activeAgent = (): AgentId => { @@ -615,7 +616,13 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { (e) => { ptyBatch.flush(); // flush buffered output before the exit notice cfg.diagnostics?.write(e.exitCode === 0 ? 'info' : 'warn', 'pty', `exit ${id} code=${e.exitCode}`); - emit('cockpit:exit', { id, exitCode: e.exitCode }); + // Says whether somebody CLOSED it, and from where. Every deck showing this terminal hears the + // exit, but only the one that pressed close knew it was deliberate — the others kept a dead + // "exited" tile, saved it, and brought it back as a previous session (or, after an update + // restart, re-ran it). Optional fields: an older viewer reads only `id`/`exitCode`. + const closedBy = closing.get(id); + closing.delete(id); + emit('cockpit:exit', closedBy ? { id, exitCode: e.exitCode, closed: true, closedBy } : { id, exitCode: e.exitCode }); publishSessions(); }, { projectPath: req.projectPath, sessionId: resolved.sessionId, agentId: a.id }, @@ -662,6 +669,18 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // lands on top of the older, wider one. emit('cockpit:resized', { id: target, cols: c, rows: r }); }); + /** Ids this machine is killing on request, and who asked — read by the exit handler above. */ + const closing = new Map(); + function closeLocal(target: string): void { + ptyBatch.drop(target); + const by = calledFromRemote() ? 'remote' : 'local'; + cfg.diagnostics?.write('info', 'pty', `close ${target} by=${by}`); + if (cfg.ptyHost.list().some((s) => s.id === target)) closing.set(target, by); + cfg.ptyHost.kill(target); + publishSessions(); + } + // Fire-and-forget, kept for paired machines on an older build. It cannot say whether the close + // happened, which is why a viewer now uses `cockpit:closeSession` below. send('cockpit:close', allowSession('control'), (id: string) => { const target = String(id); if (isRemoteId(target)) { @@ -670,10 +689,47 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { remote(target, (link, machineId, hostId) => link.notify(machineId, 'cockpit:close', [hostId])); return; } - ptyBatch.drop(target); - cfg.diagnostics?.write('info', 'pty', `close ${target}`); - cfg.ptyHost.kill(target); - publishSessions(); + closeLocal(target); + }); + /** + * Close a session and SAY whether it closed. + * + * A remote close used to be a notify: offline, without the `control` permission, or simply dropped, + * it vanished — the tile disappeared here while the agent went on running there, and the next time + * that machine listed its sessions the tile was adopted straight back. The answer lets the deck keep + * the close pending until the machine is reachable, or put the tile back and say why. + */ + invoke('cockpit:closeSession', allowSession('control'), async (id: string): Promise => { + const target = String(id); + if (!isRemoteId(target)) { closeLocal(target); return { ok: true }; } + if (refuseRelay(target, 'cockpit:closeSession')) return { ok: false, reason: 'error' }; + const link = cfg.link?.(); + if (!link) return { ok: false, reason: 'offline' }; + const { machineId, hostId } = parseRemoteId(target); + if (link.machines().find((m) => m.machineId === machineId)?.state !== 'connected') return { ok: false, reason: 'offline' }; + // The host meters requests, and a bulk close of a dozen remote tiles is a dozen of them at once. + for (let attempt = 0; ; attempt++) { + try { + await link.call(machineId, 'cockpit:closeSession', [hostId]); + link.detach(target); + return { ok: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (/^too many requests/.test(message) && attempt < 6) { await new Promise((r) => setTimeout(r, 400 * (attempt + 1))); continue; } + // A host on an older build has only the notify. Send that; it is what every close used to be. + if (/^no such method/.test(message)) { link.detach(target); link.notify(machineId, 'cockpit:close', [hostId]); return { ok: true }; } + // Not running there any more: closing it is already done. + if (/^not a session on this machine/.test(message)) { link.detach(target); return { ok: true }; } + if (/^not permitted/.test(message)) return { ok: false, reason: 'denied' }; + return { ok: false, reason: /offline|closed|timed out|unknown machine/.test(message) ? 'offline' : 'error', error: message }; + } + } + }); + // A tile that stopped showing a remote terminal without closing it (it was retired or rebound to a + // new id): stop asking the host to stream it, or every reconnect re-attaches an id that is gone. + send('cockpit:detach', localOnly, (id: string) => { + const target = String(id); + if (isRemoteId(target)) cfg.link?.()?.detach(target); }); // Cockpit session persistence: remember the open sessions so a quit/crash doesn't lose them. @@ -866,7 +922,14 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { const lastDataAtMs = Number(opts.lastDataAtMs) || 0; // No id at all (the tile's provider was just re-detected, so its old id went with the old // provider): nothing to drift FROM, and a `-c` resume writes a file born before the tile opened. - if (!currentId) return pickAdoptedSessionId(stats, { claimedIds, sinceMs, lastDataAtMs }); + if (!currentId) { + const picked = pickAdoptedSessionId(stats, { claimedIds, sinceMs, lastDataAtMs }); + // Recorded on the pty for the same reason as a drift below: without it this machine keeps + // announcing the session with no conversation, and an open of that conversation from another + // deck does not find it running and starts a second agent on it. + if (picked && typeof opts.ptyId === 'string' && opts.ptyId && cfg.ptyHost.note(opts.ptyId, { sessionId: picked })) publishSessions(); + return picked; + } const drifted = pickDriftedSessionId(stats, { currentId, claimedIds, @@ -892,7 +955,11 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { invoke('cockpit:liveAgent', allowSession('observe'), async (id: string) => { const pid = cfg.ptyHost.pid(String(id)); if (!pid) return null; - try { return await probeAgent(pid); } catch { return null; } + let found: Awaited> = null; + try { found = await probeAgent(pid); } catch { return null; } + // The provider a tile is really running is part of what this machine announces about it. + if (typeof found === 'string' && cfg.ptyHost.note(String(id), { agentId: found })) publishSessions(); + return found; }); // Live git branch + dirty count for a cockpit session's project. Re-read on a slow tick so a diff --git a/src/main/ipc.cockpit.test.ts b/src/main/ipc.cockpit.test.ts index 3e8019d..5bc4198 100644 --- a/src/main/ipc.cockpit.test.ts +++ b/src/main/ipc.cockpit.test.ts @@ -62,6 +62,7 @@ import { cwdKey } from '../shared/paths'; const ALLOWED_ROOT = join(process.cwd(), 'cockpit-allowed-root'); let storedAgent = 'claude'; const ptyCreate = vi.fn(); +const ptyNote = vi.fn((_id: string, _patch: Record) => false); beforeAll(() => { registerIpc({ @@ -74,7 +75,7 @@ beforeAll(() => { }, sendError: vi.fn(), defaultLanguage: 'en', - ptyHost: { create: ptyCreate, list: () => [], pid: (id: string) => (id === 'live#1' ? 4321 : null) }, + ptyHost: { create: ptyCreate, list: () => [], pid: (id: string) => (id === 'live#1' ? 4321 : null), note: ptyNote }, ptyAvailable: true, tray: {}, shutdown: null, @@ -194,6 +195,8 @@ describe('cockpit:liveAgent', () => { probe.mockClear(); expect(await liveAgent(null, 'live#1')).toBe('claude'); expect(probe).toHaveBeenCalledWith(4321); + // ...and recorded on the pty, so what this machine announces names the agent really running. + expect(ptyNote).toHaveBeenCalledWith('live#1', { agentId: 'claude' }); probe.mockClear(); expect(await liveAgent(null, 'gone#9')).toBeNull(); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index a4f00cd..d185b25 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -73,9 +73,13 @@ contextBridge.exposeInMainWorld('devdeck', { input: (id: string, data: string) => ipcRenderer.send('cockpit:input', id, data), resize: (id: string, cols: number, rows: number) => ipcRenderer.send('cockpit:resize', id, cols, rows), close: (id: string) => ipcRenderer.send('cockpit:close', id), + /** Close and report the outcome — the machine owning a remote tile may be offline or refuse. */ + closeSession: (id: string): Promise => ipcRenderer.invoke('cockpit:closeSession', id), + /** Stop streaming a remote terminal this deck no longer shows, without closing it. */ + detach: (id: string) => ipcRenderer.send('cockpit:detach', id), onData: (cb: (p: { id: string; chunk: string }) => void) => ipcRenderer.on('cockpit:data', (_e, p) => cb(p)), - onExit: (cb: (p: { id: string; exitCode: number }) => void) => + onExit: (cb: (p: { id: string; exitCode: number; closed?: boolean; closedBy?: 'local' | 'remote' }) => void) => ipcRenderer.on('cockpit:exit', (_e, p) => cb(p)), onResized: (cb: (p: { id: string; cols: number; rows: number }) => void) => ipcRenderer.on('cockpit:resized', (_e, p) => cb(p)), diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index 46bb365..af3567b 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -1,13 +1,13 @@ import { SessionReadGate } from '../shared/sessionReadGate'; +import { CloseTombstones } from '../shared/closeTombstones'; import { conversationKey, sameScope } from '../shared/sessionIdentity'; import { hasLabelVersion, acceptsLabelVersion, normalizeSessionLabel, type SessionLabelVersion } from '../shared/sessionLabel'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { SearchAddon } from '@xterm/addon-search'; -import { WebglAddon } from '@xterm/addon-webgl'; import { activityOrderStamp, filterSessions, groupByActivity, needsAttentionCount, numberCollidingNames, cockpitListSignature, shouldNotifyAttention, foldProjectActivity, sessionNavigationItem, tileHoldingSession, type CockpitSession } from '../shared/cockpitModel'; import type { ShellSessionAction, ShellSessionGroup, ShellSessionInput } from '../shared/shellNavigation'; -import { computeActivity, stripAnsi, type ActivityState } from '../shared/sessionStatus'; +import { agentUpdateReady, computeActivity, stripAnsi, type ActivityState } from '../shared/sessionStatus'; import { friendlyModel, contextPercent, contextSeverity } from '../shared/sessionMeta'; import { formatDuration } from '../shared/usage'; import { decideKeyAction, selectionCellLength } from '../shared/terminalKeys'; @@ -34,7 +34,6 @@ interface Live { /** The machine this tile's terminal actually runs on. */ machineId: string; tileId: string; session: CockpitSession; term: Terminal; fit: FitAddon; search: SearchAddon; el: HTMLElement; /** The GPU renderer, when the machine could give us one; null means xterm is drawing with DOM nodes. */ - webgl: WebglAddon | null; lastDataAt: number; lastInputAt: number; recentOutput: string; openedSessionId: string | null; openedAt: number; idCheckAt: number; labelVersion: SessionLabelVersion | null; @@ -65,11 +64,19 @@ interface Live { * and by Refresh — those are the moments the other view may no longer be there. */ ptyCap: TerminalDims | null; + /** The agent says a newer version of itself is installed and waiting for a restart. */ + updateReady: boolean; + /** The person asked for that restart while a turn was running; it happens when the turn ends. */ + updateWhenIdle: boolean; } /** The renderer's display metadata plus the explicit provider launch intent. */ -export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; tileId?: string; sessionId?: string | null; mode: OpenMode; label?: string | null; pinned?: boolean; agentId: AgentId; /** Which machine the project lives on; absent means this one. */ machineId?: string; /** Bind to a terminal that is ALREADY running under this id instead of starting one. */ adoptId?: string; instanceId?: string; labelRevision?: number; } +export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; tileId?: string; sessionId?: string | null; mode: OpenMode; label?: string | null; pinned?: boolean; agentId: AgentId; /** Which machine the project lives on; absent means this one. */ machineId?: string; /** Bind to a terminal that is ALREADY running under this id instead of starting one. */ adoptId?: string; instanceId?: string; labelRevision?: number; /** Picked up because a machine announced it, not because anybody here asked: build it without taking the screen or the keyboard. */ background?: boolean; } const live = new Map(); +/** Sessions closed here that their machine may still list for a moment — never adopted back. */ +const closedTiles = new CloseTombstones(); +/** Restart waits for the old terminal to be gone before resuming its conversation. */ +const exitWaiters = new Map void>(); const navigationListeners = new Set<(items: readonly ShellSessionInput[]) => void>(); const sessionSelectionListeners = new Set<(id: string) => void>(); const sessionsLoadedListeners = new Set<(items: readonly ShellSessionInput[]) => void>(); @@ -160,7 +167,6 @@ export function mountCockpit(): void { document.getElementById('ck-collapse')!.addEventListener('click', () => toggleSidebar(true)); document.getElementById('ck-expand')!.addEventListener('click', () => toggleSidebar(false)); refreshCockpitSidebar(); - void window.devdeck.getAppInfo().then((info) => { webglAllowed = info.gpu !== false; }).catch(() => { /* keep the default */ }); window.devdeck.cockpit.onData(({ id, chunk }) => { const l = live.get(id); if (!l) return; @@ -170,8 +176,21 @@ export function mountCockpit(): void { l.lastDataAt = Date.now(); l.recentOutput = (l.recentOutput + stripAnsi(chunk)).slice(-4096); }); - window.devdeck.cockpit.onExit(({ id }) => { + window.devdeck.cockpit.onExit(({ id, closed }) => { + exitWaiters.get(id)?.(); const l = live.get(id); if (!l) return; + if (closed) { + // Somebody closed it — at the machine running it, or from another deck watching it. That was a + // decision, not a crash, so this deck follows it rather than keeping a dead tile that gets + // saved and comes back as a previous session (or, after an update restart, runs again). + const name = liveLabels.get(id) ?? l.customLabel ?? l.session.name; + removeTile(l); + persist(); + toast(l.machineId === LOCAL_MACHINE_ID + ? tr('cockpit.closed_elsewhere', { name }) + : tr('cockpit.closed_on_machine', { name, machine: machineName(l.machineId) })); + return; + } l.session.status = 'exited'; l.session.activity = 'exited'; renderList(); renderHeader(); updateRailBadge(); }); @@ -217,7 +236,9 @@ export function mountCockpit(): void { // Reconcile FIRST, then repaint: reconciling is what rebinds a tile whose terminal came back under // a new id (a machine that restarted restores its sessions with fresh ids) and retires the ones that // did not come back. Repainting before that would ask the machine for screens it no longer has. - onMachineConnected((machineId) => { void pullMachineSessions(machineId).then(() => resyncMachineTiles(machineId)); }); + // Closes owed to that machine go first: they were pressed while it was unreachable, and the + // sessions they name are still running there until they arrive. + onMachineConnected((machineId) => { void deliverPendingCloses(machineId).then(() => pullMachineSessions(machineId)).then(() => resyncMachineTiles(machineId)); }); // A machine coming or going is exactly when a size another view was holding this pty down to stops // being true — drop the caps and let the pane have its terminals back. onMachinesChanged(() => { @@ -396,7 +417,17 @@ export function liveSessionsForPersist(): PersistedSession[] { return [...live.values()].map((l) => ({ tileId: l.tileId, runtimeId: l.labelVersion?.instanceId, projectPath: l.session.projectPath, name: l.session.name, sessionId: l.openedSessionId, agentId: l.session.agentId, label: l.customLabel, pinned: l.pinned, lastActiveMs: liveActivityAt(l), machineId: l.machineId === LOCAL_MACHINE_ID ? undefined : l.machineId })); } /** How many cockpit sessions are live right now (for the update-restart button label). */ -export function liveSessionCount(): number { return live.size; } +export function liveSessionCount(): number { return [...live.values()].filter((l) => l.session.status !== 'exited').length; } + +/** + * The sessions an update restart should bring back: the RUNNING ones. A tile whose terminal already + * ended stays a saved entry like any other; re-running it on relaunch revived sessions somebody had + * closed — or that had crashed — on the strength of a tile nobody had cleared yet. + */ +export function runningSessionsForAutoRestore(): PersistedSession[] { + const running = new Set([...live.values()].filter((l) => l.session.status !== 'exited').map((l) => l.tileId)); + return liveSessionsForPersist().filter((entry) => running.has(entry.tileId)); +} function navigationIdentity(liveSession: Live): { tileId: string; projectPath: string; sessionId: string | null; runtimeId: string } { return { tileId: liveSession.tileId, projectPath: liveSession.session.projectPath, sessionId: liveSession.openedSessionId, runtimeId: liveSession.session.id }; @@ -553,14 +584,16 @@ export function showCockpit(): void { */ async function duplicateTileFor(p: OpenReq): Promise { if (p.mode === 'new') return null; + // The same path on two machines is two projects: only a tile on the machine being opened counts. + const machineId = p.machineId ?? LOCAL_MACHINE_ID; const tiles = [...live.entries()] - .filter(([, l]) => l.session.projectPath === p.path && l.session.agentId === p.agentId) + .filter(([, l]) => l.machineId === machineId && l.session.projectPath === p.path && l.session.agentId === p.agentId) .map(([id, l]) => ({ id, sessionId: l.openedSessionId, agentId: l.session.agentId, exited: l.session.status === 'exited' })); if (!tiles.length) return null; // nothing of this project is open — no lookup needed at all let target = p.sessionId ?? null; if (!target) { try { - const ids = await window.devdeck.cockpit.sessionIds(p.path, p.agentId); + const ids = await deckFor(machineId).cockpit.sessionIds(p.path, p.agentId); target = ids[0] ?? null; // newest-first — what the main process's continue path resolves to } catch { return null; } } @@ -625,54 +658,16 @@ async function createSession(p: OpenReq): Promise { } /** - * Draw with the GPU when there is one. + * Every tile draws with xterm's DOM renderer, which repaints a changed row as a whole. * - * xterm's default renderer is DOM nodes: every cell that changes is an element restyled, and a TUI - * that redraws its whole screen on every spinner frame keeps the layout engine and the compositor - * busy for as long as an agent is thinking. Measured on the reporting machine: one idle tile cost the - * GPU process ~9% and the renderer ~6% of a core, all day. The WebGL addon draws the same cells as - * textured quads. It is loaded after `open()` (it needs the element) and dropped — back to DOM, - * automatically — if the context is ever lost, so a machine without usable WebGL is exactly as it was. - */ -/** False once main says GPU acceleration is off (--disable-gpu): software WebGL is not worth having. */ -let webglAllowed = true; - -/** - * Whether WebGL here is a real GPU. Decided once, synchronously, from the driver's own name: with - * GPU acceleration off Chromium still hands out a context, backed by a software rasterizer (WARP's - * "Microsoft Basic Render Driver", SwiftShader, llvmpipe) that is no cheaper than DOM rendering and - * has crashed the renderer under a resize storm. Asked before the first tile, not after an IPC answer - * — a tile made in that gap would be the one on the software path. + * Tiles used to draw with the WebGL addon, which repaints changed CELLS. On the machines that + * reported it, Claude Code's screen built up stale cells under that renderer: old status lines + * stacked down the screen, and earlier text showing through exactly where new text had spaces. The + * terminal's own buffer was right all along — minimizing and restoring the window, which makes xterm + * redraw every row and changes nothing else (no resize reaches the pty), cleared it every time. A + * renderer that can leave a screen wrong is not a trade worth making: measured when it was added, it + * left the GPU process's cost where it was (14.2% -> 14.5%) and took about a fifth off the renderer's. */ -let hardwareGl: boolean | null = null; -function hasHardwareGl(): boolean { - if (hardwareGl !== null) return hardwareGl; - try { - const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl2') as WebGL2RenderingContext | null; - if (!gl) { hardwareGl = false; return false; } - const info = gl.getExtension('WEBGL_debug_renderer_info'); - const name = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : ''; - hardwareGl = !/basic render driver|swiftshader|llvmpipe|softpipe|software/i.test(name); - gl.getExtension('WEBGL_lose_context')?.loseContext(); - } catch { - hardwareGl = false; - } - return hardwareGl; -} - -function attachWebgl(term: Terminal): WebglAddon | null { - if (!webglAllowed || !hasHardwareGl()) return null; - try { - const addon = new WebglAddon(); - addon.onContextLoss(() => { try { addon.dispose(); } catch { /* already gone */ } }); - term.loadAddon(addon); - return addon; - } catch { - return null; // no WebGL here (software GL disabled, remote desktop, an old GPU): the DOM renderer stays - } -} - async function buildTile(p: OpenReq): Promise { // Before any DOM: xterm has to be told, at construction, whether its pty is a Windows one, and // that is a fact about the machine the session runs on (see ptyCompatFor). @@ -689,8 +684,7 @@ async function buildTile(p: OpenReq): Promise { // and the spinner beside the input already says whether the agent is busy. const term = new Terminal({ fontFamily: 'Cascadia Mono, Consolas, monospace', fontSize: 12, theme: { background: '#0a0b0e' }, cursorBlink: false, scrollback: 10_000, windowsPty }); const fit = new FitAddon(); term.loadAddon(fit); term.open(el); fit.fit(); - const webgl = attachWebgl(term); - el.dataset.termRenderer = webgl ? 'webgl' : 'dom'; // which renderer this tile got — read by the perf harness and by anyone debugging a slow machine + el.dataset.termRenderer = 'dom'; // read by the perf/link harnesses and by anyone debugging a slow machine // The size this terminal is actually drawing at. Written on every resize because a pty is shared: // when two machines watch one session, "do both terminals agree with the pty" is the whole // question, and it cannot be read back off the screen — a row of text is as long as its content. @@ -869,7 +863,7 @@ async function buildTile(p: OpenReq): Promise { const displaced = live.get(res.id); if (displaced && displaced.el !== el) { displaced.el.remove(); displaced.term.dispose(); } live.set(res.id, { - machineId, tileId: adopted.tileId ?? createCockpitTileId(), session, term, fit, search, el, webgl, + machineId, tileId: adopted.tileId ?? createCockpitTileId(), session, term, fit, search, el, lastDataAt: Date.now(), lastInputAt: 0, recentOutput: '', openedSessionId: res.sessionId ?? null, openedAt: Date.now(), idCheckAt: Date.now(), labelVersion: hasLabelVersion(res) ? { instanceId: res.instanceId, labelRevision: res.labelRevision } : null, @@ -880,6 +874,7 @@ async function buildTile(p: OpenReq): Promise { // history to paint, so it writes through from the first byte. replayPending: adopting ? [] : null, ptyCap: null, + updateReady: false, updateWhenIdle: false, }); // Repaint what is already on that terminal. Without this, attaching to work in progress shows a // blank rectangle until the agent next speaks — which, while it is thinking, can be minutes, and @@ -897,7 +892,15 @@ async function buildTile(p: OpenReq): Promise { if ('snapshot' in result) syncAdoptedLabel(machineId, res.id, result.snapshot.label, result.snapshot); } catch (error) { reportSaveError(error); } } // Attaching to an existing terminal is a READ, never a cached-title write-back. - select(res.id); + // A session another machine started arrives in the sidebar, NOT under the person's hands: taking + // the screen and the keyboard sent whatever they were typing into somebody else's agent — possibly + // into its "allow this?" prompt. It was made visible above only so it could be measured. + const current = selectedId && selectedId !== res.id ? live.get(selectedId) : undefined; + if (p.background && current) { + el.classList.remove('show'); + current.el.classList.add('show'); + renderList(); + } else select(res.id); updateRailBadge(); persist(); // Attaching does not itself tell the host our size; a resize does, and it is also what starts the @@ -940,8 +943,9 @@ const sessionReads = new SessionReadGate(); function adoptAnnounced(machineId: string, sessions: readonly RunningSession[], token = sessionReads.begin(machineId)): Promise { return onMachineQueue(machineId, async () => { if (!sessionReads.current(machineId, token)) return; - const adopted = await syncMachineSessions(machineId, sessions); + const adopted = await syncMachineSessions(machineId, sessions, () => sessionReads.current(machineId, token)); if (!sessionReads.current(machineId, token)) return; + closedTiles.observe(machineId, sessions.map((info) => info.id)); noteSessionsGone(machineId, sessions.map((info) => info.id)); if (adopted > 0 && machineId !== LOCAL_MACHINE_ID) { toast(tr('cockpit.adopted_remote', { n: adopted, machine: machineName(machineId) })); @@ -981,6 +985,7 @@ function markMachineReachability(): void { */ function rebindTile(l: Live, newId: string): void { const oldId = l.session.id; + if (oldId !== newId && l.machineId !== LOCAL_MACHINE_ID) window.devdeck.cockpit.detach(oldId); live.delete(oldId); l.session.id = newId; l.labelVersion = null; @@ -1002,9 +1007,9 @@ function retireToPrevious(l: Live): void { agentId: l.session.agentId, label: l.customLabel, pinned: l.pinned || undefined, lastActiveMs: liveActivityAt(l), machineId: l.machineId === LOCAL_MACHINE_ID ? undefined : l.machineId, }; - l.webgl?.dispose(); l.term.dispose(); l.el.remove(); live.delete(l.session.id); - if (selectedId === l.session.id) { selectedId = [...live.keys()][0] ?? null; if (selectedId) select(selectedId); else hideAllTerminals(); } + if (l.machineId !== LOCAL_MACHINE_ID) window.devdeck.cockpit.detach(l.session.id); restorable = [entry, ...restorable.filter((r) => r.tileId !== entry.tileId)]; + removeTile(l); } /** Ask a machine what it is running, then reconcile. Used when a link comes up. */ @@ -1031,11 +1036,16 @@ export async function pullMachineSessions(machineId: string): Promise { * machine and this deck may hold tiles from several, and an absent entry is far more often "not that * machine's session" than "gone". */ -export async function syncMachineSessions(machineId: string, sessions: readonly RunningSession[]): Promise { +export async function syncMachineSessions(machineId: string, sessions: readonly RunningSession[], stillCurrent: () => boolean = () => true): Promise { const known = new Set(live.keys()); let adopted = 0; for (const info of sessions) { + // A newer announcement superseded this one while an earlier tile was being built: what this list + // says is running may already be wrong, and the newer list is reconciled in its own turn. + if (!stillCurrent()) break; const id = info.id; // already qualified by the link when it came from another machine + // Closed here a moment ago; the machine listing it has not caught up yet (see CloseTombstones). + if (closedTiles.has(machineId, id)) continue; if (known.has(id)) { const held = live.get(id); if (held && info.instanceId && held.labelVersion && info.instanceId !== held.labelVersion.instanceId) rebindTile(held, id); @@ -1070,6 +1080,7 @@ export async function syncMachineSessions(machineId: string, sessions: readonly label: info.label ?? null, instanceId: info.instanceId, labelRevision: info.labelRevision, pinned: saved?.pinned, + background: true, }); if (ok) adopted += 1; } @@ -1537,12 +1548,24 @@ function tickActivity(): void { let changed = false; for (const l of live.values()) { const prev = l.session.activity; + const screenText = liveScreenTail(l); // 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' : computeActivity({ exited: l.session.status === 'exited', lastDataAt: l.lastDataAt, lastInputAt: l.lastInputAt, now, - recentOutput: l.recentOutput, screenText: liveScreenTail(l), prev: prev === 'offline' ? 'idle' : prev, spinnerReliable: l.session.agentId === 'claude', + recentOutput: l.recentOutput, screenText, prev: prev === 'offline' ? 'idle' : prev, spinnerReliable: l.session.agentId === 'claude', }); + const updateReady = l.session.status !== 'exited' && agentUpdateReady(l.session.agentId, screenText); + if (updateReady !== l.updateReady) { + l.updateReady = updateReady; + if (!updateReady) l.updateWhenIdle = false; + if (l.session.id === selectedId && !editingId) renderHeader(); + } + // Asked for while the agent was mid-turn: restart the moment the turn is over, not in the middle. + if (l.updateWhenIdle && next !== 'working' && next !== 'offline' && next !== 'exited') { + l.updateWhenIdle = false; + void restartSession(l.session.id, false); + } if (next !== prev) { l.session.activity = next; changed = true; // A turn just finished → a new assistant model may have been logged; refresh so the sidebar model @@ -1959,14 +1982,26 @@ function renderHeader(): void { }); const restart = actBtn('restart', tr('cockpit.restart'), () => restartSession(s.id)); const close = actBtn('close', tr('cockpit.close'), () => void requestClose(s.id)); - headerEl.append(...pills, sp, newSession, pin, rename, folder, restart, close); + // The agent installed a newer version of itself and is waiting to be restarted. Claude Code's own + // in-session `/restart` exists only on macOS and Linux; on Windows the way to the new version was to + // /exit and start it again by hand. This is that, keeping the conversation. + const update: HTMLElement[] = []; + if (l.updateReady) { + const b = document.createElement('button'); b.className = 'ck-pill ck-update'; + b.append(createIcon('restart'), tr(l.updateWhenIdle ? 'cockpit.update_queued' : 'cockpit.apply_update')); + b.title = tr('cockpit.apply_update_title'); b.disabled = l.updateWhenIdle; + b.addEventListener('click', () => applyAgentUpdate(s.id)); + update.push(b); + } + headerEl.append(...pills, sp, ...update, newSession, pin, rename, folder, restart, close); } /** "+ New session": spawn another, fresh conversation in the SAME project as the selected session. */ async function addSessionToCurrentProject(): Promise { const l = selectedId ? live.get(selectedId) : null; if (!l) return; const s = l.session; - await createSession({ path: s.projectPath, name: s.name, staleLevel: s.staleLevel, branch: s.branch, dirty: s.dirty, mode: 'new', agentId: s.agentId }); + // On the machine the selected session runs on — the same path here is a different project, if any. + await createSession({ path: s.projectPath, name: s.name, staleLevel: s.staleLevel, branch: s.branch, dirty: s.dirty, mode: 'new', agentId: s.agentId, machineId: l.machineId }); } function actBtn(icon: IconName, title: string, onClick: () => void): HTMLButtonElement { @@ -1984,18 +2019,67 @@ function actBtn(icon: IconName, title: string, onClick: () => void): HTMLButtonE * It asks first, because it kills a running agent. The ✕ beside it always has; this had not, and it * sits one button away. */ -async function restartSession(id: string): Promise { +async function restartSession(id: string, ask = true): Promise { const l = live.get(id); if (!l) return; const name = liveLabels.get(id) ?? l.session.name; - if (!(await confirmDialog(tr('cockpit.restart_confirm', { name }), tr('cockpit.restart')))) return; + if (ask && !(await confirmDialog(tr('cockpit.restart_confirm', { name }), tr('cockpit.restart')))) return; const still = live.get(id); if (!still) return; // closed while the question was up // Carry the user-given label + pin into the re-created session — ⟳ must not silently reset them. + // A tile that never learned its conversation id (Codex/Antigravity before drift settles) restarts + // FRESH: "auto" without an id is "continue", the project's newest conversation, which may well be + // another tile's — and the machine then hands back that tile, so this one simply disappeared. + // And a conversation that was never written (nothing was ever typed, so the agent created no + // transcript) cannot be resumed: `--resume` of it fails and the tile comes back as an error. It + // starts fresh under the SAME id instead, as a restore does, so the tile keeps its identity. + let resumable = !!still.openedSessionId; + if (resumable) { + try { + const [exists] = await deckFor(still.machineId).cockpit.sessionsExist([{ projectPath: still.session.projectPath, sessionId: still.openedSessionId, agentId: still.session.agentId }]); + resumable = exists !== false; + } catch { /* could not ask: try the resume, as before */ } + if (live.get(id) !== still) return; // closed or rebuilt while we asked + } const p: OpenReq = { path: still.session.projectPath, name: still.session.name, staleLevel: still.session.staleLevel, - branch: still.session.branch, dirty: still.session.dirty, mode: 'auto', sessionId: still.openedSessionId, + branch: still.session.branch, dirty: still.session.dirty, mode: resumable ? 'auto' : 'new', sessionId: still.openedSessionId, label: still.customLabel, pinned: still.pinned, agentId: still.session.agentId, machineId: still.machineId, }; - closeSession(id); await createSession(p); + const entry: PersistedSession = { + tileId: still.tileId, projectPath: p.path, name: p.name, sessionId: still.openedSessionId, agentId: p.agentId, + label: still.customLabel, pinned: still.pinned || undefined, lastActiveMs: liveActivityAt(still), + machineId: still.machineId === LOCAL_MACHINE_ID ? undefined : still.machineId, + }; + // The old agent may still be flushing its transcript; resuming the same conversation on top of a + // process that has not exited yet gives two writers one file. + const exited = still.machineId === LOCAL_MACHINE_ID && still.session.status !== 'exited' ? waitForExit(id, 3_000) : Promise.resolve(); + closeSession(id); + await exited; + // A restart that cannot reopen (machine went away, path refused) must not cost the tile: it goes + // back to the saved list with its name and pin, one click from trying again. + if (!(await createSession({ ...p, tileId: still.tileId }))) { + restorable = [entry, ...restorable.filter((r) => r.tileId !== entry.tileId)]; + persist(); renderList(); + } +} + +/** Restart onto the agent's newly installed version — now if it is between turns, else when the turn ends. */ +function applyAgentUpdate(id: string): void { + const l = live.get(id); if (!l) return; + if (l.session.activity === 'working') { + l.updateWhenIdle = true; + toast(tr('cockpit.update_after_turn', { name: liveLabels.get(id) ?? l.session.name })); + renderHeader(); + return; + } + void restartSession(id, false); +} + +function waitForExit(id: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const done = (): void => { clearTimeout(timer); exitWaiters.delete(id); resolve(); }; + const timer = setTimeout(done, timeoutMs); + exitWaiters.set(id, done); + }); } /** Small in-app confirmation modal (a DOM overlay, NOT window.confirm). Resolves true on confirm. */ @@ -2022,7 +2106,9 @@ function confirmDialog(message: string, confirmLabel: string): Promise } if (e.key !== 'Escape' && e.key !== 'Enter') return; e.preventDefault(); e.stopPropagation(); // keep Esc/Enter inside the dialog (don't leak to terminal/rename) - done(e.key === 'Enter'); + // Enter presses the FOCUSED button. Confirming regardless meant tabbing to Cancel and pressing + // Enter closed (or restarted) the sessions anyway. + done(e.key === 'Enter' && document.activeElement !== cancel); }; cancel.addEventListener('click', () => done(false)); ok.addEventListener('click', () => done(true)); @@ -2038,22 +2124,75 @@ function confirmDialog(message: string, confirmLabel: string): Promise async function requestClose(id: string): Promise { const l = live.get(id); if (!l) return; const name = liveLabels.get(id) ?? l.session.name; - if (await confirmDialog(tr('cockpit.close_confirm').replace('{name}', name), tr('cockpit.close'))) closeSession(id); + // On a remote tile the agent being stopped is on the other machine — possibly in front of someone. + const question = l.machineId === LOCAL_MACHINE_ID + ? tr('cockpit.close_confirm', { name }) + : tr('cockpit.close_confirm_remote', { name, machine: machineName(l.machineId) }); + if (await confirmDialog(question, tr('cockpit.close'))) closeSession(id); } function closeSession(id: string): void { const l = live.get(id); if (!l) return; - window.devdeck.cockpit.close(id); - l.webgl?.dispose(); l.term.dispose(); l.el.remove(); live.delete(id); updateRailBadge(); + // Remembered BEFORE anything is sent: the machine's next announcement can be one it made before it + // processed this close, and it must not bring the tile straight back (see CloseTombstones). + closedTiles.add(l.machineId, id); + if (l.machineId === LOCAL_MACHINE_ID) window.devdeck.cockpit.close(id); + else void deliverClose(l.machineId, id, liveLabels.get(id) ?? l.customLabel ?? l.session.name); + removeTile(l); persist(); // close = forget (the closed session drops out of persistence) +} + +/** + * Take a tile off the screen and out of `live`, and hand the selection to the session that was + * looked at most recently — not to whichever happened to be opened first. + */ +function removeTile(l: Live): void { + const id = l.session.id; + l.term.dispose(); l.el.remove(); live.delete(id); updateRailBadge(); if (selectedId === id) { - const next = [...live.keys()][0] ?? null; + const next = [...live.values()].sort((a, b) => b.lastSelectedAt - a.lastSelectedAt)[0]?.session.id ?? null; selectedId = null; if (next) select(next); - else { setActiveUsageProvider(null); renderAll(); mainEl.classList.toggle('has-session', false); } // no session → footer goes back to the cross-provider max + else { hideAllTerminals(); setActiveUsageProvider(null); renderAll(); mainEl.classList.toggle('has-session', false); } // no session → footer goes back to the cross-provider max } else renderList(); } +/** Closes owed to a machine that could not be reached when they were pressed. */ +const owedCloseNames = new Map(); + +/** + * Ask the machine running a remote tile to close it, and act on the answer. + * + * Unreachable: the close is kept and sent the moment the machine is back, and until then the session + * stays suppressed here — it is still running there. Refused (this device may not drive that + * machine's sessions): the tile comes back and the person is told, rather than watching it vanish + * while the agent carries on. + */ +async function deliverClose(machineId: string, id: string, name: string): Promise { + let result: import('../shared/types').CloseResult; + try { result = await window.devdeck.cockpit.closeSession(id); } catch (err) { result = { ok: false, reason: 'error', error: String(err) }; } + window.devdeck.logDiagnostic(`close ${id.slice(-24)} on ${machineName(machineId)}: ${result.ok ? 'ok' : result.reason}`, result.ok ? 'info' : 'warn', 'link'); + if (result.ok) { closedTiles.settle(machineId, id); owedCloseNames.delete(id); return true; } + if (result.reason === 'offline') { + closedTiles.markPending(machineId, id); + if (!owedCloseNames.has(id)) toast(tr('cockpit.close_pending', { name, machine: machineName(machineId) })); + owedCloseNames.set(id, name); + return false; + } + closedTiles.remove(machineId, id); + owedCloseNames.delete(id); + toast(result.reason === 'denied' + ? tr('cockpit.close_denied', { name, machine: machineName(machineId) }) + : tr('cockpit.close_failed', { name, machine: machineName(machineId) })); + // It is still running there; show it again rather than leave it invisible and alive. + void pullMachineSessions(machineId); + return false; +} + +async function deliverPendingCloses(machineId: string): Promise { + for (const id of closedTiles.pending(machineId)) await deliverClose(machineId, id, owedCloseNames.get(id) ?? id); +} + // Restores in flight, keyed by project + saved id. A "Previous" row stays clickable for as long as // the PTY takes to spawn, so a second click would restore the SAME entry twice — once as a duplicate // tile, and now that an unopenable conversation comes back fresh, as an empty one. diff --git a/src/renderer/features/cockpit/cockpit.css b/src/renderer/features/cockpit/cockpit.css index 6d24e57..346ddf7 100644 --- a/src/renderer/features/cockpit/cockpit.css +++ b/src/renderer/features/cockpit/cockpit.css @@ -27,6 +27,24 @@ gap: 5px; } +/* The agent has a new version waiting. A real button among the read-only pills, so it says so. */ +#view-cockpit .ck-update { + cursor: pointer; + color: var(--dd-success); + border: 1px solid color-mix(in srgb, var(--dd-success) 45%, transparent); + background: color-mix(in srgb, var(--dd-success) 10%, transparent); + font: inherit; +} + +#view-cockpit .ck-update:hover:not(:disabled) { + background: color-mix(in srgb, var(--dd-success) 18%, transparent); +} + +#view-cockpit .ck-update:disabled { + cursor: default; + opacity: 0.7; +} + #shell-session-groups .shell-group + .shell-group { margin-top: 2px; } diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 89998ab..39b066f 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -123,8 +123,10 @@ declare global { input(id: string, data: string): void; resize(id: string, cols: number, rows: number): void; close(id: string): void; + closeSession(id: string): Promise; + detach(id: string): void; onData(cb: (p: { id: string; chunk: string }) => void): void; - onExit(cb: (p: { id: string; exitCode: number }) => void): void; + onExit(cb: (p: { id: string; exitCode: number; closed?: boolean; closedBy?: 'local' | 'remote' }) => void): void; onResized(cb: (p: { id: string; cols: number; rows: number }) => void): void; liveSessions(): Promise; sessionBuffer(id: string): Promise; diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 19ba35b..ff6d40b 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -214,6 +214,10 @@ "cockpit.empty": "No open sessions. Open one from Projects.", "cockpit.restart": "Restart this session", "cockpit.restart_confirm": "Restart '{name}'? What it is doing right now stops; the conversation is resumed.", + "cockpit.apply_update": "Apply update", + "cockpit.update_queued": "Updating after this turn", + "cockpit.apply_update_title": "Claude Code installed a new version. Restart this session on it — the conversation continues.", + "cockpit.update_after_turn": "'{name}' will restart on the new version when this turn finishes", "cockpit.close": "Close", "cockpit.new_session": "New session", "cockpit.sidebar_hide": "Hide session list", @@ -221,6 +225,12 @@ "cockpit.rename": "Rename", "cockpit.cancel": "Cancel", "cockpit.close_confirm": "Close session '{name}'?", + "cockpit.close_confirm_remote": "Close session '{name}'? The agent running on {machine} will be stopped.", + "cockpit.closed_elsewhere": "Session '{name}' was closed from another machine", + "cockpit.closed_on_machine": "Session '{name}' was closed on {machine}", + "cockpit.close_pending": "{machine} is offline — '{name}' will be closed there when it reconnects", + "cockpit.close_denied": "This device may not close sessions on {machine} — '{name}' is still running there", + "cockpit.close_failed": "Could not close '{name}' on {machine} — it is still running there", "cockpit.open_folder": "Open folder", "cockpit.st_working": "Working", "cockpit.st_attention": "Awaiting you", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index 890d120..7ecf89b 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -214,6 +214,10 @@ "cockpit.empty": "開いているセッションがありません。プロジェクトから「開く」を押してください。", "cockpit.restart": "このセッションだけ再起動", "cockpit.restart_confirm": "「{name}」を再起動しますか?進行中の作業は中断され、会話はそのまま復元されます。", + "cockpit.apply_update": "アップデートを適用", + "cockpit.update_queued": "このターン後に更新", + "cockpit.apply_update_title": "Claude Code が新しいバージョンをインストールしました。このセッションを新しいバージョンで再起動します — 会話はそのまま続きます。", + "cockpit.update_after_turn": "「{name}」はこのターンが終わったら新しいバージョンで再起動します", "cockpit.close": "閉じる", "cockpit.new_session": "新規セッション", "cockpit.sidebar_hide": "セッションリストを隠す", @@ -221,6 +225,12 @@ "cockpit.rename": "名前を変更", "cockpit.cancel": "キャンセル", "cockpit.close_confirm": "セッション「{name}」を閉じますか?", + "cockpit.close_confirm_remote": "セッション「{name}」を閉じますか?{machine} で実行中のエージェントが終了します。", + "cockpit.closed_elsewhere": "セッション「{name}」は別のマシンから閉じられました", + "cockpit.closed_on_machine": "セッション「{name}」は {machine} で閉じられました", + "cockpit.close_pending": "{machine} はオフラインです — 再接続したら「{name}」をそちらで閉じます", + "cockpit.close_denied": "このデバイスには {machine} のセッションを閉じる権限がありません — 「{name}」はそちらで実行中です", + "cockpit.close_failed": "{machine} で「{name}」を閉じられませんでした — そちらで実行中です", "cockpit.open_folder": "フォルダを開く", "cockpit.st_working": "実行中", "cockpit.st_attention": "確認待ち", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index e7f5112..21db819 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -214,6 +214,10 @@ "cockpit.empty": "열린 세션이 없습니다. 프로젝트에서 \"열기\"를 누르세요.", "cockpit.restart": "이 세션만 재시작", "cockpit.restart_confirm": "'{name}' 세션을 재시작할까요? 지금 하던 작업은 중단되고, 대화는 이어서 복구됩니다.", + "cockpit.apply_update": "업데이트 적용", + "cockpit.update_queued": "이 작업 끝나면 업데이트", + "cockpit.apply_update_title": "Claude Code가 새 버전을 설치했습니다. 이 세션을 새 버전으로 다시 시작합니다 — 대화는 그대로 이어집니다.", + "cockpit.update_after_turn": "'{name}' 세션은 지금 작업이 끝나면 새 버전으로 다시 시작합니다", "cockpit.close": "닫기", "cockpit.new_session": "새 세션", "cockpit.sidebar_hide": "세션 목록 접기", @@ -221,6 +225,12 @@ "cockpit.rename": "이름 변경", "cockpit.cancel": "취소", "cockpit.close_confirm": "'{name}' 세션을 닫을까요?", + "cockpit.close_confirm_remote": "'{name}' 세션을 닫을까요? {machine}에서 실행 중인 에이전트가 종료됩니다.", + "cockpit.closed_elsewhere": "'{name}' 세션이 다른 기기에서 닫혔습니다", + "cockpit.closed_on_machine": "'{name}' 세션이 {machine}에서 닫혔습니다", + "cockpit.close_pending": "{machine}이(가) 오프라인입니다 — 다시 연결되면 '{name}' 세션을 그쪽에서 닫습니다", + "cockpit.close_denied": "이 기기에는 {machine}의 세션을 닫을 권한이 없습니다 — '{name}' 세션은 그쪽에서 계속 실행 중입니다", + "cockpit.close_failed": "{machine}에서 '{name}' 세션을 닫지 못했습니다 — 그쪽에서 계속 실행 중입니다", "cockpit.open_folder": "폴더 열기", "cockpit.st_working": "작업 중", "cockpit.st_attention": "질문 대기", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 780867b..2a2a902 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -214,6 +214,10 @@ "cockpit.empty": "没有打开的会话。请从「项目」中打开。", "cockpit.restart": "仅重启此会话", "cockpit.restart_confirm": "重启「{name}」?当前进行中的工作会中断,对话会照原样恢复。", + "cockpit.apply_update": "应用更新", + "cockpit.update_queued": "本轮结束后更新", + "cockpit.apply_update_title": "Claude Code 已安装新版本。将以新版本重启此会话 — 对话会继续。", + "cockpit.update_after_turn": "'{name}' 将在本轮结束后以新版本重启", "cockpit.close": "关闭", "cockpit.new_session": "新会话", "cockpit.sidebar_hide": "收起会话列表", @@ -221,6 +225,12 @@ "cockpit.rename": "重命名", "cockpit.cancel": "取消", "cockpit.close_confirm": "关闭会话 '{name}'?", + "cockpit.close_confirm_remote": "关闭会话 '{name}'?{machine} 上运行的代理将被停止。", + "cockpit.closed_elsewhere": "会话 '{name}' 已在另一台机器上关闭", + "cockpit.closed_on_machine": "会话 '{name}' 已在 {machine} 上关闭", + "cockpit.close_pending": "{machine} 离线 — 重新连接后将在该机器上关闭 '{name}'", + "cockpit.close_denied": "此设备无权关闭 {machine} 上的会话 — '{name}' 仍在该机器上运行", + "cockpit.close_failed": "无法在 {machine} 上关闭 '{name}' — 它仍在该机器上运行", "cockpit.open_folder": "打开文件夹", "cockpit.st_working": "工作中", "cockpit.st_attention": "等待确认", diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 9606bbc..6fb92c3 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -7,7 +7,7 @@ import type { ShellSessionInput } from '../shared/shellNavigation'; import { mountUsage, showUsage } from './usageView'; import { mountSettings, showSettings } from './settingsView'; import { mountNext, showNext } from './nextView'; -import { activateCockpitSession, closeCockpitSessionGroup, closeCockpitSessions, cockpitNavigationAliases, cockpitNavigationItems, manageCockpitSessionAction, mountCockpit, onCockpitNavigationChange, onCockpitSessionsLoaded, onCockpitSessionSelected, restoreAllCockpitSessions, setCockpitNavigationCallback, showCockpit, liveSessionCount, liveSessionsForPersist, refreshLiveSessionIds, setCockpitContextWindow, setCockpitTrayAlert, setCockpitSidebarCollapsed, refreshCockpitSidebar, setCockpitSessionSummary, setCockpitAiSummary } from './cockpitView'; +import { activateCockpitSession, closeCockpitSessionGroup, closeCockpitSessions, cockpitNavigationAliases, cockpitNavigationItems, manageCockpitSessionAction, mountCockpit, onCockpitNavigationChange, onCockpitSessionsLoaded, onCockpitSessionSelected, restoreAllCockpitSessions, setCockpitNavigationCallback, showCockpit, liveSessionCount, runningSessionsForAutoRestore, refreshLiveSessionIds, setCockpitContextWindow, setCockpitTrayAlert, setCockpitSidebarCollapsed, refreshCockpitSidebar, setCockpitSessionSummary, setCockpitAiSummary } from './cockpitView'; import { isCockpitAvailable } from '../shared/cockpitModel'; import { setLanguage, tr, currentLang, languageName, SUPPORTED } from './i18n-runtime'; import { toast } from './loadError'; @@ -79,7 +79,7 @@ function renderUpdate(p: import('../shared/update').UpdatePayload): void { // Sweep the drift check first: a /clear in the last seconds before the restart would otherwise // freeze the pre-clear id and the relaunch would restore the PAST conversation. try { await refreshLiveSessionIds(); } catch { /* best-effort */ } - const live = liveSessionsForPersist(); + const live = runningSessionsForAutoRestore(); if (live.length) { try { await window.devdeck.setPendingAutoRestore(live); } catch { /* proceed regardless */ } } void window.devdeck.installUpdate(); }); diff --git a/src/shared/closeTombstones.test.ts b/src/shared/closeTombstones.test.ts new file mode 100644 index 0000000..46b6045 --- /dev/null +++ b/src/shared/closeTombstones.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { CloseTombstones } from './closeTombstones'; + +describe('CloseTombstones', () => { + it('suppresses a closed id through the stale announcements that still list it', () => { + const t = new CloseTombstones(); + for (const id of ['A', 'B', 'C']) t.add('local', id); + // The first announcement after closing A still lists B and C: none of them may be adopted. + t.observe('local', ['B', 'C', 'D']); + expect(['A', 'B', 'C'].map((id) => t.has('local', id))).toEqual([false, true, true]); + t.observe('local', ['C', 'D']); + t.observe('local', ['D']); + expect(['B', 'C'].some((id) => t.has('local', id))).toBe(false); + }); + + it('keeps an undelivered close through announcements until it is settled', () => { + const t = new CloseTombstones(); + t.add('m1', 'link:m1:x', true); + t.observe('m1', []); + expect(t.has('m1', 'link:m1:x')).toBe(true); + expect(t.pending('m1')).toEqual(['link:m1:x']); + t.settle('m1', 'link:m1:x'); + expect(t.pending('m1')).toEqual([]); + t.observe('m1', ['link:m1:x']); // still running a moment after the close was delivered + expect(t.has('m1', 'link:m1:x')).toBe(true); + t.observe('m1', []); + expect(t.has('m1', 'link:m1:x')).toBe(false); + }); + + it('is per machine: one machine listing nothing releases nothing of another', () => { + const t = new CloseTombstones(); + t.add('local', 'p#1'); + t.add('m1', 'link:m1:p#1'); + t.observe('m1', []); + expect(t.has('local', 'p#1')).toBe(true); + expect(t.has('m1', 'link:m1:p#1')).toBe(false); + }); + + it('forgets a refused close so the session can be shown again', () => { + const t = new CloseTombstones(); + t.add('m1', 'x', true); + t.remove('m1', 'x'); + expect(t.has('m1', 'x')).toBe(false); + expect(t.pending('m1')).toEqual([]); + }); +}); diff --git a/src/shared/closeTombstones.ts b/src/shared/closeTombstones.ts new file mode 100644 index 0000000..420e601 --- /dev/null +++ b/src/shared/closeTombstones.ts @@ -0,0 +1,62 @@ +/** + * Sessions this deck has closed, remembered until the machine that ran them stops listing them. + * + * Closing a tile and hearing what a machine runs are two independent streams, and the second one + * lags: a machine announces its sessions after EVERY change, so closing three tiles in a row produces + * three announcements of which the first still lists the two not yet closed. Adoption reads "listed + * but not shown here" as "started somewhere else" and built tiles for them again — dead ones, which + * then sat in the sidebar as "exited" and were saved and restored with everything else. A remote close + * that could not be delivered (machine offline) came back the same way on reconnect, still running. + * + * A tombstone is released when an announcement from that machine no longer lists the id, which is + * the machine's own word that the terminal is gone. A close still OWED (the machine was unreachable) + * keeps its tombstone through announcements until it has been delivered. + */ +export class CloseTombstones { + private readonly byMachine = new Map>(); + + add(machineId: string, id: string, pending = false): void { + let ids = this.byMachine.get(machineId); + if (!ids) { ids = new Map(); this.byMachine.set(machineId, ids); } + ids.set(id, { pending }); + } + + has(machineId: string, id: string): boolean { + return this.byMachine.get(machineId)?.has(id) === true; + } + + /** The close could not be delivered yet; keep the session suppressed until it is. */ + markPending(machineId: string, id: string): void { + const entry = this.byMachine.get(machineId)?.get(id); + if (entry) entry.pending = true; + } + + /** The close reached the machine; the next announcement without the id releases it. */ + settle(machineId: string, id: string): void { + const entry = this.byMachine.get(machineId)?.get(id); + if (entry) entry.pending = false; + } + + /** Closes owed to a machine, for sending again once it is reachable. */ + pending(machineId: string): string[] { + return [...(this.byMachine.get(machineId) ?? new Map())] + .filter(([, entry]) => entry.pending).map(([id]) => id); + } + + /** Forget a close entirely — it was refused, so the session is legitimately still there. */ + remove(machineId: string, id: string): void { + const ids = this.byMachine.get(machineId); + if (!ids) return; + ids.delete(id); + if (!ids.size) this.byMachine.delete(machineId); + } + + /** An announcement of everything `machineId` runs: release what it no longer runs and owes nothing. */ + observe(machineId: string, aliveIds: readonly string[]): void { + const ids = this.byMachine.get(machineId); + if (!ids) return; + const alive = new Set(aliveIds); + for (const [id, entry] of [...ids]) if (!entry.pending && !alive.has(id)) ids.delete(id); + if (!ids.size) this.byMachine.delete(machineId); + } +} diff --git a/src/shared/sessionStatus.test.ts b/src/shared/sessionStatus.test.ts index 74ea1ea..89a15ec 100644 --- a/src/shared/sessionStatus.test.ts +++ b/src/shared/sessionStatus.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { stripAnsi, hasPromptPattern, hasWorkingSpinner, computeActivity, WORKING_MS, IDLE_MS } from './sessionStatus'; +import { agentUpdateReady, stripAnsi, hasPromptPattern, hasWorkingSpinner, computeActivity, WORKING_MS, IDLE_MS } from './sessionStatus'; describe('stripAnsi', () => { const ESC = String.fromCharCode(27); @@ -114,3 +114,21 @@ describe('computeActivity', () => { expect(computeActivity({ ...base, now: 1000 + 20_000, prev: 'working', recentOutput: '✻ Gesticulating… ❯ 1. Yes' })).toBe('attention'); }); }); + +describe('agentUpdateReady', () => { + it("reads Claude Code's footer notice", () => { + expect(agentUpdateReady('claude', '> \n ⏵⏵ bypass permissions on Update installed · Restart to apply\n')).toBe(true); + expect(agentUpdateReady('claude', ' ✗ Auto-update failed · Try claude doctor\n')).toBe(false); + expect(agentUpdateReady('claude', '> \n')).toBe(false); + }); + it('is Claude-only', () => { + expect(agentUpdateReady('codex', 'Update installed · Restart to apply')).toBe(false); + }); +}); + +describe('agentUpdateReady separator', () => { + it('does not depend on the middle dot surviving the code page', () => { + expect(agentUpdateReady('claude', 'Update installed ? Restart to apply')).toBe(true); + expect(agentUpdateReady('claude', 'Update installed • Restart to apply')).toBe(true); + }); +}); diff --git a/src/shared/sessionStatus.ts b/src/shared/sessionStatus.ts index e995cda..e7340d9 100644 --- a/src/shared/sessionStatus.ts +++ b/src/shared/sessionStatus.ts @@ -92,3 +92,18 @@ export function computeActivity(i: { exited: boolean; lastDataAt: number; lastIn if (sinceData < IDLE_MS) return 'turn'; return 'idle'; } + +/** + * Whether an agent's own screen says a newer version of it is installed and waiting for a restart. + * + * Claude Code installs updates in the background and then shows "Update installed · Restart to apply" + * in its footer — and on Windows it has no in-session way to do that (its `/restart` is macOS/Linux + * only), so the update sits unapplied until someone exits and starts it again. Read off the same live + * screen tail as the spinner; acting on it is always the person's click. + */ +export function agentUpdateReady(agentId: string, screenText: string): boolean { + if (agentId !== 'claude') return false; + // The separator is matched loosely: it is a middle dot today, and a code page or font that cannot + // carry it turns it into something else without changing what the notice says. + return /Update installed\W{1,6}Restart to apply/i.test(screenText); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index adacc70..e6ce563 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -167,3 +167,9 @@ export interface ProjectMemory { events: ProjectMemoryEvent[]; partial: Array<'git' | 'sessions'>; } + +/** + * What a close asked of a machine came back as. `offline` means the machine could not be asked (the + * close is still owed), `denied` that this device may not drive that machine's sessions. + */ +export type CloseResult = { ok: true } | { ok: false; reason: 'offline' | 'denied' | 'error'; error?: string }; From 6ee072eccf80bbd2e4ff0d49e6a00e735c32fe06 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 13:06:21 -0700 Subject: [PATCH 4/8] chore(deps): Electron 43.8, drop the WebGL addon, clear the audit gate; add qa:close `npm audit --audit-level=moderate` (the CI gate) failed at HEAD on advisories published since the last release: Electron 43.3 (sandbox inheritance, protocol-handler CORS, preload cache poisoning; fixed in 43.4.1/43.5.0) and, through electron-builder's @electron/get, global-agent 3 -> roarr -> sprintf-js (no fixed sprintf-js exists; global-agent 4 no longer depends on it), pinned by override. Now 0 vulnerabilities. Verified on 43.8: unit tests, qa, qa:close, qa:link, and a packaged win-unpacked build whose embedded terminal opens, streams and closes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta (cherry picked from commit 340d2bbb06506caef6e9bf34ccf66af532dbb7d6) --- package-lock.json | 114 ++++++++-------------------------------------- package.json | 5 +- 2 files changed, 23 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b971d3..2487478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@fontsource-variable/geist": "5.3.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@xterm/addon-search": "^0.16.0", - "@xterm/addon-webgl": "^0.19.0", "electron-updater": "^6.8.9" }, "devDependencies": { @@ -1502,12 +1501,6 @@ "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", "license": "MIT" }, - "node_modules/@xterm/addon-webgl": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", - "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", - "license": "MIT" - }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -1864,15 +1857,6 @@ "dev": true, "license": "MIT" }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/brace-expansion": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", @@ -2338,14 +2322,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -2774,14 +2750,6 @@ "node": ">= 0.4" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -3160,19 +3128,17 @@ } }, "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", "dev": true, "license": "BSD-3-Clause", "optional": true, "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" }, "engines": { "node": ">=10.0" @@ -3517,14 +3483,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3881,9 +3839,9 @@ } }, "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", "dev": true, "license": "MIT", "optional": true, @@ -3892,6 +3850,9 @@ }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/math-intrinsics": { @@ -4683,25 +4644,6 @@ "rimraf": "bin.js" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", @@ -4787,23 +4729,15 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "type-fest": "^0.13.1" + "type-fest": "^0.20.2" }, "engines": { "node": ">=10" @@ -4938,14 +4872,6 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5249,9 +5175,9 @@ } }, "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", "optional": true, diff --git a/package.json b/package.json index 4eb8729..ab85b6c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "qa": "npm run build && node qa/screenshot.mjs", "qa:audit": "npm run build && node qa/audit.mjs", "qa:link": "npm run build && node qa/link.mjs", + "qa:close": "npm run build && node qa/close.mjs", "qa:resilience": "npm run build && node qa/resilience.mjs", "qa:perf": "npm run build && node qa/perf.mjs", "qa:maintenance": "npm run build && node qa/maintenance.mjs", @@ -119,11 +120,11 @@ "@fontsource-variable/geist": "5.3.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@xterm/addon-search": "^0.16.0", - "@xterm/addon-webgl": "^0.19.0", "electron-updater": "^6.8.9" }, "overrides": { "undici": "^6.28.0", - "form-data": "^4.0.6" + "form-data": "^4.0.6", + "global-agent": "^4.1.3" } } From 42a9dd7bd64cd7af1591dfbf97bb76e5ebfc2676 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 14:33:00 -0700 Subject: [PATCH 5/8] perf+fix(renderer): spinners cost a frame stream, not a spinner; machine-scoped project state - Measured (12 sessions, 6 working off screen): any running spinner makes Chromium redraw the window every step, ~6% of a core whether one spinner turns or six. Spinners now tick 4x/s instead of 10x and pause while the window is unfocused (all-frozen measured 6.8% vs 13.1%). - qa/perf.mjs: its own rAF frame counter ran through every process-CPU window (idle read 6.8%, real 0.5%); it now runs only while a JS profile is taken. - Task board / project memory Open went to THIS machine for a remote project; project activity, badges, providers and sidebar project signals are now per machine. - A size change's ConPTY repaint no longer counts as agent activity (it flipped idle sessions to working); background refreshes no longer rebuild the header rename editor mid-typing; find bar follows a language switch; view mode is not reverted by a slow reload; tray overdue count stays this machine's; due dates save on blur/Enter; adding a single-project folder as a location no longer turns it into a scan root; the login terminal is not shadowed by the usage modal's keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta (cherry picked from commit 39354df1770c5a0f100be2d6c906759693da7287) --- qa/perf.mjs | 12 ++++-- src/renderer/cockpitView.ts | 62 ++++++++++++++++++++++-------- src/renderer/locales/en.json | 1 + src/renderer/locales/ja.json | 1 + src/renderer/locales/ko.json | 1 + src/renderer/locales/zh.json | 1 + src/renderer/main.ts | 5 ++- src/renderer/newProjectModal.ts | 4 ++ src/renderer/nextView.ts | 27 +++++++++---- src/renderer/projectMemoryModal.ts | 7 ++-- src/renderer/projectsView.ts | 19 ++++++--- src/renderer/shell.ts | 17 +++++++- src/renderer/shell/shell.css | 13 ++++--- src/renderer/styles.css | 3 +- src/renderer/usageLoginTerminal.ts | 2 +- src/renderer/usageModal.ts | 4 +- 16 files changed, 131 insertions(+), 48 deletions(-) diff --git a/qa/perf.mjs b/qa/perf.mjs index 8d801d9..8b60220 100644 --- a/qa/perf.mjs +++ b/qa/perf.mjs @@ -84,11 +84,14 @@ console.log(`view=${state.view} tiles=${state.tiles} shown=${state.shown} sizes= // ---- instrument the UI thread ------------------------------------------------------------- await win.evaluate(() => { - window.__perf = { long: [], raf: 0 }; + window.__perf = { long: [], raf: 0, counting: false }; new PerformanceObserver((l) => { for (const e of l.getEntries()) window.__perf.long.push(Math.round(e.duration)); }) .observe({ entryTypes: ['longtask'] }); - const tick = () => { window.__perf.raf++; requestAnimationFrame(tick); }; - requestAnimationFrame(tick); + // The frame counter is a requestAnimationFrame loop, and a rAF loop is itself a frame every vsync + // (240 a second on this display) — it runs only while a JS profile is being taken. Left running, it + // was part of every process-CPU figure below, idle included: the harness measuring its own cost. + const tick = () => { if (!window.__perf.counting) return; window.__perf.raf++; requestAnimationFrame(tick); }; + window.__perf.startCounting = () => { if (window.__perf.counting) return; window.__perf.counting = true; requestAnimationFrame(tick); }; }); const cdp = await win.context().newCDPSession(win); @@ -96,11 +99,12 @@ await cdp.send('Profiler.enable'); await cdp.send('Profiler.setSamplingInterval', { interval: 200 }); // µs async function profile(label, body) { - await win.evaluate(() => { window.__perf.long = []; window.__perf.raf = 0; }); + await win.evaluate(() => { window.__perf.long = []; window.__perf.raf = 0; window.__perf.startCounting(); }); await cdp.send('Profiler.start'); const t0 = Date.now(); await body(); const { profile: p } = await cdp.send('Profiler.stop'); + await win.evaluate(() => { window.__perf.counting = false; }); report(label, p, Date.now() - t0, ...Object.values(await win.evaluate(() => ({ long: window.__perf.long, raf: window.__perf.raf })))); } diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index af3567b..900dca2 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -68,11 +68,15 @@ interface Live { updateReady: boolean; /** The person asked for that restart while a turn was running; it happens when the turn ends. */ updateWhenIdle: boolean; + /** When this terminal last changed size: the repaint that follows is not the agent doing anything. */ + resizedAt: number; } /** The renderer's display metadata plus the explicit provider launch intent. */ export interface OpenReq { path: string; name: string; staleLevel: StaleLevel; branch: string | null; dirty: number; tileId?: string; sessionId?: string | null; mode: OpenMode; label?: string | null; pinned?: boolean; agentId: AgentId; /** Which machine the project lives on; absent means this one. */ machineId?: string; /** Bind to a terminal that is ALREADY running under this id instead of starting one. */ adoptId?: string; instanceId?: string; labelRevision?: number; /** Picked up because a machine announced it, not because anybody here asked: build it without taking the screen or the keyboard. */ background?: boolean; } const live = new Map(); +/** How long after a size change a terminal's output is taken to be the repaint, not the agent. */ +const RESIZE_ECHO_MS = 700; /** Sessions closed here that their machine may still list for a moment — never adopted back. */ const closedTiles = new CloseTombstones(); /** Restart waits for the old terminal to be gone before resuming its conversation. */ @@ -134,6 +138,7 @@ export function refreshCockpitSidebar(): void { const expand = document.getElementById('ck-expand'); if (collapse) { collapse.title = tr('cockpit.sidebar_hide'); collapse.setAttribute('aria-label', tr('cockpit.sidebar_hide')); collapse.setAttribute('aria-expanded', String(!sidebarCollapsed)); } if (expand) { expand.title = tr('cockpit.sidebar_show'); expand.setAttribute('aria-label', tr('cockpit.sidebar_show')); expand.setAttribute('aria-expanded', String(!sidebarCollapsed)); } + labelFindBar(); // built once at mount, so a language switch has to reach it here } /** Restore the persisted sidebar state (called from boot, after mountCockpit). */ export function setCockpitSidebarCollapsed(collapsed: boolean): void { sidebarCollapsed = collapsed === true; refreshCockpitSidebar(); } @@ -173,6 +178,10 @@ 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); + // 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. + if (Date.now() - l.resizedAt < RESIZE_ECHO_MS) return; l.lastDataAt = Date.now(); l.recentOutput = (l.recentOutput + stripAnsi(chunk)).slice(-4096); }); @@ -332,9 +341,9 @@ function buildFindBar(): void { findInput = document.createElement('input'); findInput.className = 'ck-find-input'; findInput.placeholder = tr('cockpit.find_ph'); findInput.setAttribute('aria-label', tr('cockpit.find_ph')); - const prev = document.createElement('button'); prev.className = 'ck-find-btn'; prev.textContent = '↑'; prev.title = tr('cockpit.find_prev'); - const next = document.createElement('button'); next.className = 'ck-find-btn'; next.textContent = '↓'; next.title = tr('cockpit.find_next'); - const close = document.createElement('button'); close.className = 'ck-find-btn'; close.textContent = '✕'; close.title = tr('cockpit.find_close'); + const prev = document.createElement('button'); prev.className = 'ck-find-btn ck-find-prev'; prev.textContent = '↑'; + const next = document.createElement('button'); next.className = 'ck-find-btn ck-find-next'; next.textContent = '↓'; + const close = document.createElement('button'); close.className = 'ck-find-btn ck-find-close'; close.textContent = '✕'; const sel = (): Live | undefined => (selectedId ? live.get(selectedId) : undefined); findInput.addEventListener('input', () => { const l = sel(); if (l && findInput!.value) l.search.findNext(findInput!.value, { incremental: true }); }); findInput.addEventListener('keydown', (e) => { @@ -346,9 +355,19 @@ function buildFindBar(): void { next.addEventListener('click', () => { const l = sel(); if (l && findInput!.value) l.search.findNext(findInput!.value); }); close.addEventListener('click', closeFindBar); findBar.append(findInput, prev, next, close); + labelFindBar(); termsEl.appendChild(findBar); // .ck-terms is position:relative — the bar floats over the terminal } +function labelFindBar(): void { + if (!findBar || !findInput) return; + findInput.placeholder = tr('cockpit.find_ph'); findInput.setAttribute('aria-label', tr('cockpit.find_ph')); + for (const [cls, key] of [['ck-find-prev', 'cockpit.find_prev'], ['ck-find-next', 'cockpit.find_next'], ['ck-find-close', 'cockpit.find_close']] as const) { + const b = findBar.querySelector(`.${cls}`); + if (b) { b.title = tr(key); b.setAttribute('aria-label', tr(key)); } + } +} + function openFindBar(): void { if (!findBar || !findInput) return; findBar.classList.remove('hidden'); @@ -550,15 +569,21 @@ export function activateCockpitSession(id: string): void { if (previous) void restoreSession(previous); } -/** Per-project live status for the deck's summary + card stripes (no IPC — renderer-shared). */ -export function liveProjectActivity(): Map { - return foldProjectActivity([...live.values()].map((l) => ({ projectPath: l.session.projectPath, activity: l.session.activity }))); +/** + * Per-project live status for the deck's summary + card stripes (no IPC — renderer-shared). + * + * For ONE machine's projects. Two machines usually carry the same folder layout, so keyed by path + * alone a session working on this PC lit up the same-named project on the paired one's deck — badge, + * summary count and activity order included. + */ +export function liveProjectActivity(machineId: string = LOCAL_MACHINE_ID): Map { + return foldProjectActivity([...live.values()].filter((l) => l.machineId === machineId).map((l) => ({ projectPath: l.session.projectPath, activity: l.session.activity }))); } /** Advisory provider presence for open-control copy; click-time routing still rechecks real history. */ -export function liveProjectProviders(projectPath: string): AgentId[] { +export function liveProjectProviders(projectPath: string, machineId: string = LOCAL_MACHINE_ID): AgentId[] { return [...new Set([...live.values()] - .filter((l) => l.session.projectPath === projectPath && l.session.status !== 'exited') + .filter((l) => l.machineId === machineId && l.session.projectPath === projectPath && l.session.status !== 'exited') .map((l) => l.session.agentId))]; } @@ -690,7 +715,9 @@ async function buildTile(p: OpenReq): Promise { // question, and it cannot be read back off the screen — a row of text is as long as its content. const stampSize = (): void => { el.dataset.termSize = `${term.cols}x${term.rows}`; }; stampSize(); - term.onResize(stampSize); + // Found by element, not by id: a resize can land before this tile is registered, and after a rebind + // its id is a different one. + term.onResize(() => { stampSize(); for (const l of live.values()) if (l.el === el) { l.resizedAt = Date.now(); break; } }); // Make http(s) links clickable → open via a scheme-guarded IPC. Custom provider instead of the // WebLinksAddon: Claude's renderer HARD-wraps long URLs at its own inner width (real newlines + // indentation), which the addon can't join — clicking then opened only the first fragment. Our @@ -874,7 +901,7 @@ async function buildTile(p: OpenReq): Promise { // history to paint, so it writes through from the first byte. replayPending: adopting ? [] : null, ptyCap: null, - updateReady: false, updateWhenIdle: false, + updateReady: false, updateWhenIdle: false, resizedAt: 0, }); // Repaint what is already on that terminal. Without this, attaching to work in progress shows a // blank rectangle until the agent next speaks — which, while it is thinking, can be minutes, and @@ -1335,8 +1362,9 @@ async function refreshMeta(id: string): Promise { if (l.meta?.model === meta.model && l.meta?.activeMs === meta.activeMs && l.meta?.contextTokens === meta.contextTokens && l.meta?.summary === meta.summary) return; // unchanged → no re-render l.meta = meta; - if (!editingId) renderList(); - renderHeader(); + // Not while a rename is open: the header IS the editor, and rebuilding it under the person's + // hands re-selected everything they had typed (and broke an IME composition mid-syllable). + if (!editingId) { renderList(); renderHeader(); } } /** Re-resolve WHICH on-disk conversation a live tile is actually writing to. /clear starts a * brand-new session id in the same terminal, so the open-time id goes permanently stale — persisting @@ -1383,8 +1411,9 @@ async function refreshProvider(id: string): Promise { publishCockpitNavigation(); if (selectedId === id) publishSessionSelection(l); if (selectedId === id) setActiveUsageProvider(actual); // the footer must follow the tile's REAL provider - if (!editingId) renderList(); - renderHeader(); + // Not while a rename is open: the header IS the editor, and rebuilding it under the person's + // hands re-selected everything they had typed (and broke an IME composition mid-syllable). + if (!editingId) { renderList(); renderHeader(); } } /** Await a provider + drift check for every live tile — the update-restart path calls this right before @@ -1404,8 +1433,9 @@ async function refreshGit(id: string): Promise { if (l.session.branch === info.branch && l.session.dirty === info.dirty) return; // unchanged → no re-render l.session.branch = info.branch; l.session.dirty = info.dirty; - if (!editingId) renderList(); - renderHeader(); + // Not while a rename is open: the header IS the editor, and rebuilding it under the person's + // hands re-selected everything they had typed (and broke an IME composition mid-syllable). + if (!editingId) { renderList(); renderHeader(); } } // The 30s tick: skip exited sessions — their model/branch can't change, so re-reading their log + // re-spawning git every tick is pure waste (matters most with many concurrent sessions). diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index ff6d40b..0009c55 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -10,6 +10,7 @@ "newproj.name": "Name", "newproj.name_ph": "project name", "newproj.add_location": "Add another location", + "newproj.err_is_repo": "That folder is already added as a single project. Pick the folder that contains it to create projects next to it.", "newproj.no_location": "Add a scan location first →", "newproj.create": "Create", "newproj.cancel": "Cancel", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index 7ecf89b..a5d5935 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -10,6 +10,7 @@ "newproj.name": "名前", "newproj.name_ph": "プロジェクト名", "newproj.add_location": "別の場所を追加", + "newproj.err_is_repo": "このフォルダーはすでに単一プロジェクトとして追加されています。隣にプロジェクトを作るには親フォルダーを選んでください。", "newproj.no_location": "先にスキャン場所を追加してください →", "newproj.create": "作成", "newproj.cancel": "キャンセル", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 21db819..840388b 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -10,6 +10,7 @@ "newproj.name": "이름", "newproj.name_ph": "프로젝트 이름", "newproj.add_location": "다른 위치 추가", + "newproj.err_is_repo": "이 폴더는 이미 단일 프로젝트로 추가되어 있습니다. 그 옆에 프로젝트를 만들려면 상위 폴더를 고르세요.", "newproj.no_location": "먼저 스캔 위치를 추가하세요 →", "newproj.create": "만들기", "newproj.cancel": "취소", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 2a2a902..8b84d34 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -10,6 +10,7 @@ "newproj.name": "名称", "newproj.name_ph": "项目名称", "newproj.add_location": "添加其他位置", + "newproj.err_is_repo": "此文件夹已作为单个项目添加。要在它旁边创建项目,请选择其上级文件夹。", "newproj.no_location": "请先添加扫描位置 →", "newproj.create": "创建", "newproj.cancel": "取消", diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 6fb92c3..7e27797 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -17,6 +17,7 @@ import { initializeAgentSelection, setSelectedAgent } from './agentSelection'; import type { AgentId } from '../shared/types'; import { createIcon } from './icons'; import { maximizeActionPresentation } from '../shared/windowControls'; +import { selectedMachineId } from './machineDeck'; let shellController: ShellController | null = null; let contextRestore: ContextRestoreCoordinator | null = null; @@ -349,11 +350,11 @@ async function boot(): Promise { applyRestore(contextRestore.sessionsLoaded(new Set())); } const syncShellProjects = (items: readonly import('../shared/types').ProjectViewModel[]): void => { - shellController?.setProjects(items.filter((item) => !item.hidden).map(({ path, name, branch, pinned }) => ({ path, name, branch, pinned }))); + shellController?.setProjects(items.filter((item) => !item.hidden).map(({ path, name, branch, pinned }) => ({ path, name, branch, pinned })), selectedMachineId()); applyRestore(contextRestore?.projectsLoaded(new Set(items.map((item) => item.path))) ?? null); }; onProjectsChanged(syncShellProjects); - shellController.setProjects(currentProjects().filter((item) => !item.hidden).map(({ path, name, branch, pinned }) => ({ path, name, branch, pinned }))); + shellController.setProjects(currentProjects().filter((item) => !item.hidden).map(({ path, name, branch, pinned }) => ({ path, name, branch, pinned })), selectedMachineId()); const agentSel = document.getElementById('agent-select') as HTMLSelectElement; const agentControl = document.getElementById('agent-select-control'); diff --git a/src/renderer/newProjectModal.ts b/src/renderer/newProjectModal.ts index e1db5b4..9e4df1f 100644 --- a/src/renderer/newProjectModal.ts +++ b/src/renderer/newProjectModal.ts @@ -106,6 +106,10 @@ export function openNewProjectModal(onCreated: (path: string) => void): void { addLoc.addEventListener('click', async () => { const picked = await window.devdeck.pickFolder(); if (!picked) return; + // Adding an existing path again CHANGES its kind, and a scan root's own folder is not listed — so + // picking a folder already added as a single project made that project vanish from the deck. + const existing = (await window.devdeck.getFolders()).find((f) => f.path.toLowerCase() === picked.toLowerCase()); + if (existing?.kind === 'repo') { toast(tr('newproj.err_is_repo')); return; } // Explicitly a scan ROOT: this picker chooses where NEW projects get created, and loadRoots only // lists kind:'root'. Auto-detection silently made it 'repo' when the picked dir happened to have a // `.git`, so the folder was added but never appeared in the dropdown. diff --git a/src/renderer/nextView.ts b/src/renderer/nextView.ts index e181083..5015ac8 100644 --- a/src/renderer/nextView.ts +++ b/src/renderer/nextView.ts @@ -12,7 +12,7 @@ import { createProviderOpenControl } from './providerOpenControl'; import { createIcon } from './icons'; import { liveProjectProviders } from './cockpitView'; import type { AgentId } from '../shared/types'; -import { deckFor, selectedMachineId, machineSelectionVersion, onMachineSelected } from './machineDeck'; +import { deckFor, selectedMachineId, machineSelectionVersion, onMachineSelected, LOCAL_MACHINE_ID } from './machineDeck'; let viewEl: HTMLElement; interface Proj { path: string; name: string; todos: Todo[]; revision: number | undefined; agentIds: AgentId[]; } @@ -147,13 +147,16 @@ function taskRow(it: TaskWithProject, now: number): HTMLElement { del.addEventListener('click', () => mutate(projectPath, (ts) => removeTodo(ts, todo.id))); const project = projects.find((p) => p.path === projectPath); + // The board lists the selected machine's projects, so a task's Open belongs to that machine. Left + // off, it opened the project on THIS computer — against whatever sits at that path here, or nothing. + const machineId = selectedMachineId(); const open = createProviderOpenControl({ path: projectPath, historyAgentIds: project?.agentIds ?? [], - liveAgentIds: () => liveProjectProviders(projectPath), + liveAgentIds: () => liveProjectProviders(projectPath, machineId), compact: true, onOpen: (intent) => openInTerminal([{ - ...intent, name: projectName, staleLevel: 'neutral', branch: null, dirty: 0, + ...intent, name: projectName, staleLevel: 'neutral', branch: null, dirty: 0, machineId, }]), }); open.classList.add('tk-open'); @@ -180,8 +183,17 @@ function startEdit(text: HTMLElement, path: string, todo: Todo): void { function startDueEdit(chip: HTMLElement, path: string, todo: Todo): void { const input = document.createElement('input'); input.type = 'date'; input.className = 'tk-due-input'; input.value = todo.due ?? ''; - input.addEventListener('change', () => mutate(path, (ts) => setTodoDue(ts, todo.id, input.value || null))); - input.addEventListener('blur', () => render()); + // Saved when the person is DONE with the field, not on every change: typing a date by keyboard + // passes through values like year 0002, which read as invalid and cleared the due date mid-entry. + let settled = false; + const commit = (save: boolean): void => { + if (settled) return; settled = true; + const next = input.value || null; + if (save && next !== (todo.due ?? null) && (next === null || /^(19|20|21)\d\d-\d\d-\d\d$/.test(next))) void mutate(path, (ts) => setTodoDue(ts, todo.id, next)); + else render(); + }; + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') commit(true); if (e.key === 'Escape') commit(false); }); + input.addEventListener('blur', () => commit(true)); chip.replaceWith(input); input.focus(); input.showPicker?.(); } @@ -284,8 +296,9 @@ function render(): void { const head = document.createElement('div'); head.className = 'next-head'; head.textContent = `${tr('next.title')} · ${open.length}` + (overdue ? ` · 🔴 ${overdue}` : ''); // Checking off / re-dating a task changes the overdue total — keep the tray tooltip current - // (partial update: the cockpit owns attention/turn on the same channel). - window.devdeck.setTrayCounts({ overdue }); + // (partial update: the cockpit owns attention/turn on the same channel). Only for this machine's + // board: the tray belongs to this machine. + if (selectedMachineId() === LOCAL_MACHINE_ID) window.devdeck.setTrayCounts({ overdue }); // Single control row: [project▾][new-todo…][date][add] | separator | [filter▾][filter…][show-done](+clear) | [list|calendar] const bar = document.createElement('div'); bar.className = 'tk-bar ui-toolbar'; diff --git a/src/renderer/projectMemoryModal.ts b/src/renderer/projectMemoryModal.ts index 171c8ab..ccc0961 100644 --- a/src/renderer/projectMemoryModal.ts +++ b/src/renderer/projectMemoryModal.ts @@ -44,10 +44,11 @@ export function openProjectMemoryModal(project: ProjectViewModel, trigger: HTMLE const foot = document.createElement('footer'); foot.className = 'pm-foot'; const footHint = document.createElement('span'); footHint.textContent = tr('memory.open_hint'); const open = createProviderOpenControl({ - path: project.path, historyAgentIds: project.agentIds, liveAgentIds: () => liveProjectProviders(project.path), + path: project.path, historyAgentIds: project.agentIds, liveAgentIds: () => liveProjectProviders(project.path, machine), onOpen: (intent) => { close(); - openInTerminal([{ ...intent, name: project.name, staleLevel: project.stale.level, branch: project.branch, dirty: project.uncommitted }]); + // The memory shown is `machine`'s, and so is the project: open it there, not on this computer. + openInTerminal([{ ...intent, name: project.name, staleLevel: project.stale.level, branch: project.branch, dirty: project.uncommitted, machineId: machine }]); }, }); foot.append(footHint, open); @@ -109,7 +110,7 @@ export function openProjectMemoryModal(project: ProjectViewModel, trigger: HTMLE close(); openInTerminal([{ path: project.path, name: project.name, staleLevel: project.stale.level, branch: project.branch, - dirty: project.uncommitted, sessionId: action.sessionId, agentId: action.agentId, mode: 'auto', + dirty: project.uncommitted, sessionId: action.sessionId, agentId: action.agentId, mode: 'auto', machineId: machine, }]); } else if (action.kind === 'tasks') { close(); presetBoardProject(project.path); diff --git a/src/renderer/projectsView.ts b/src/renderer/projectsView.ts index 73be71a..c82d7d1 100644 --- a/src/renderer/projectsView.ts +++ b/src/renderer/projectsView.ts @@ -192,7 +192,7 @@ function providerOpenControl(p: ProjectViewModel, compact = false): HTMLElement return createProviderOpenControl({ path: p.path, historyAgentIds: p.agentIds, - liveAgentIds: () => liveProjectProviders(p.path), + liveAgentIds: () => liveProjectProviders(p.path, selectedMachineId()), compact, onOpen: (intent) => openInTerminal([{ ...intent, name: p.name, staleLevel: p.stale.level, branch: p.branch, dirty: p.uncommitted, @@ -593,7 +593,7 @@ function render(): void { showHiddenBtn.setAttribute('aria-checked', String(showHidden)); // Computed once per render: drives the card status stripe/badge, the activity-sort // priority below, and the reconcile signature (so a live status flip forces a rebuild). - const act = liveProjectActivity(); + const act = liveProjectActivity(selectedMachineId()); let visible = showHidden ? projects.filter((p) => p.hidden) : projects.filter((p) => !p.hidden); // Search filter (case-insensitive substring match on name, branch, note) @@ -757,7 +757,7 @@ function showSkeleton(): void { function renderDeckPulse(todayCost: number | null): void { const el = document.getElementById('deck-pulse'); if (!el) return; lastPulseCost = todayCost; - const act = liveProjectActivity(); + const act = liveProjectActivity(selectedMachineId()); const attn = [...act.values()].filter((v) => v === 'attention').length; const work = act.size - attn; const neglected = neglectedCount(projects); // stale ≥ neglectedDays, excluding no-record (see isNeglected) @@ -824,6 +824,9 @@ async function reload(): Promise { async function reloadFor(forMachine: string, current: () => boolean): Promise { lastLoadMs = Date.now(); + // Settings are read with the projects, and a slow (remote) load can come back after the person has + // switched between cards and list — the setting it read is then the one they just changed away from. + const viewBefore = viewMode; // Skeleton only on the very first load. Background/manual refreshes reconcile in place, // so they never wipe the deck to gray placeholders. if (!hasRenderedOnce) showSkeleton(); @@ -855,15 +858,19 @@ async function reloadFor(forMachine: string, current: () => boolean): Promise n + taskCounts(p.todos, Date.now()).overdue, 0); - window.devdeck.setTrayCounts({ overdue }); + // This machine's tray counts this machine's tasks: a deck switched to a paired machine must not + // replace the number with that machine's. + if (forMachine === LOCAL_MACHINE_ID) { + const overdue = projects.reduce((n, p) => n + taskCounts(p.todos, Date.now()).overdue, 0); + window.devdeck.setTrayCounts({ overdue }); + } // Fill in per-project cost in the background (all-time; sinceMs=0 = since epoch), then // the toolbar pulse summary (live status counts + today's cost). Both best-effort: any // failure in this chain falls back to a status-only pulse rather than blocking reload(). diff --git a/src/renderer/shell.ts b/src/renderer/shell.ts index c8350f1..d6e110f 100644 --- a/src/renderer/shell.ts +++ b/src/renderer/shell.ts @@ -79,7 +79,8 @@ export interface ShellController { showView(view: ViewId): void; setCockpitAvailable(available: boolean): void; setSessionGroups(items: ShellSessionInput[]): void; - setProjects(items: ShellProjectInput[]): void; + /** The deck's projects, and which machine they are on — project rows light up only for that machine's sessions. */ + setProjects(items: ShellProjectInput[], machineId?: string): void; setActiveProject(path: string | null): void; setActiveSession(id: string | null): void; setCollapsed(collapsed: boolean): void; @@ -101,6 +102,14 @@ export function mountShell(options: { onGroupClose?(group: ShellSessionGroup): void; onRestoreAll(): void; }): ShellController { + // The working spinners stop while another window has the focus. Any running animation makes + // Chromium draw the whole window every step — measured at about half a percent of a core per frame a + // second, the same for one spinner as for six — and nobody is watching the turn of a spinner in a + // window they are not in. The working shape and colour stay, so the state still reads at a glance. + const syncFocus = (): void => { document.documentElement.classList.toggle('app-unfocused', !document.hasFocus()); }; + window.addEventListener('focus', syncFocus); + window.addEventListener('blur', syncFocus); + syncFocus(); const sidebar = document.getElementById('app-sidebar')!; const collapse = document.getElementById('shell-collapse') as HTMLButtonElement; const quickOpen = document.getElementById('shell-quick-open') as HTMLInputElement; @@ -117,6 +126,7 @@ export function mountShell(options: { const mobileBackdrop = document.getElementById('shell-mobile-backdrop') as HTMLButtonElement; let sessions: ShellSessionInput[] = []; let projects: ShellProjectInput[] = []; + let projectsMachine = 'local'; let activeEntityKey = ''; const sessionRows = new Map(); const sessionWraps = new Map(); @@ -630,6 +640,9 @@ export function mountShell(options: { const map = new Map(); for (const item of sessions) { if (item.previous) continue; + // The project rows are one machine's; a session on another machine with the same folder layout + // is a different project that happens to share the path. + if ((item.machineId || 'local') !== projectsMachine) continue; if (item.activity === 'attention') map.set(item.projectPath, 'attention'); else if (item.activity === 'working' && map.get(item.projectPath) !== 'attention') map.set(item.projectPath, 'working'); } @@ -937,7 +950,7 @@ export function mountShell(options: { // and each publish used to put the highlight back on the first result mid-navigation. if (quickOpen.value.trim()) { const keep = quickIndex; applyQuery(); highlightQuickResult(keep); } }, - setProjects: (items) => { projects = [...items]; renderProjects(projects); applyQuery(); }, + setProjects: (items, machineId = 'local') => { projects = [...items]; projectsMachine = machineId; renderProjects(projects); applyProjectActivity(); applyQuery(); }, setActiveProject: (path) => { if (path != null) revealProject(path); markActiveEntity(path == null ? null : shellEntityKey('project', path)); diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index 4f77f39..7561442 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -160,12 +160,15 @@ .shell-signal.signal-square { border-radius: 1px; background: var(--dd-text-faint); } .shell-signal.signal-spinner { border: 1.6px solid var(--dd-working); border-right-color: transparent; background: transparent; - /* Stepped, not smooth. A continuous rotation is composited at the display's refresh rate for as - long as it runs — measured at two thirds of the whole app's CPU while one agent worked, more than - the terminal it was decorating. Eight steps a cycle reads as the same spinner and costs ten - frames a second instead of sixty. */ - animation: shell-spin .8s steps(8, end) infinite; + /* Stepped, not smooth, and slow. A continuous rotation is composited at the display's refresh rate + for as long as it runs — measured at two thirds of the whole app's CPU while one agent worked. + Each step still redraws the whole window, at a cost that does not depend on how many spinners + are turning (measured: one spinner 12.2%, six 13.1% of a core, all frozen 6.8%), so what matters + is steps per second: a quarter turn four times a second ticks visibly alive at 4 frames instead + of 10. It stops while the window is not focused (see mountShell). */ + animation: shell-spin 1s steps(4, end) infinite; } +.app-unfocused .shell-signal.signal-spinner { animation-play-state: paused; } @keyframes shell-spin { to { rotate: 360deg; } } @media (prefers-reduced-motion: reduce) { .shell-signal.signal-spinner { animation: none; border-right-color: var(--dd-canvas); } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 833e9bb..3dc7282 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -592,7 +592,8 @@ button.primary:hover { background: var(--accent-hover); } .rail-badge.hidden { display: none; } .ck-ind { width: 16px; display: flex; align-items: center; justify-content: center; font-size: 12px; } .ck-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--neutral); } -.ck-spin { width: 10px; height: 10px; border: 1.6px solid #3fb950; border-right-color: transparent; border-radius: 50%; animation: ck-spin .8s steps(8, end) infinite; } /* stepped for the same reason as .shell-signal.signal-spinner */ +.ck-spin { width: 10px; height: 10px; border: 1.6px solid #3fb950; border-right-color: transparent; border-radius: 50%; animation: ck-spin 1s steps(4, end) infinite; } /* stepped and slow for the same reason as .shell-signal.signal-spinner */ +.app-unfocused .ck-spin { animation-play-state: paused; } @keyframes ck-spin { to { transform: rotate(360deg); } } .ck-row.act-turn .ck-dot { background: var(--accent); } .ck-row.act-idle .ck-dot { background: var(--neutral); opacity: .55; } diff --git a/src/renderer/usageLoginTerminal.ts b/src/renderer/usageLoginTerminal.ts index 0fc6b92..60da6fc 100644 --- a/src/renderer/usageLoginTerminal.ts +++ b/src/renderer/usageLoginTerminal.ts @@ -68,6 +68,6 @@ export async function openUsageLoginTerminal(providerId: LoginProvider): Promise if (!active || active.overlay !== overlay) { if (opened?.id) window.devdeck.cockpit.close(opened.id); return; } if (!opened) { term.writeln(tr('usage.login_unavailable')); return; } active.id = opened.id; - terminalHost.focus(); + term.focus(); // the host div is not focusable; keystrokes reach the sign-in only through xterm's own input } diff --git a/src/renderer/usageModal.ts b/src/renderer/usageModal.ts index 6514d37..d4955e2 100644 --- a/src/renderer/usageModal.ts +++ b/src/renderer/usageModal.ts @@ -147,7 +147,9 @@ function providerSection(p: ProviderUsage): HTMLElement { const row = document.createElement('div'); row.className = 'um-guidance'; const button = document.createElement('button'); button.type = 'button'; button.className = 'um-action'; button.textContent = tr('usage.login_open'); - button.addEventListener('click', () => { void openUsageLoginTerminal(p.providerId as 'claude' | 'codex'); }); + // The login terminal is its own dialog. Left open underneath, this one's capture-phase key handler + // went on taking Escape (closing it from under the sign-in) and Shift+Tab (pulling focus out of it). + button.addEventListener('click', () => { closeUsageModal(); void openUsageLoginTerminal(p.providerId as 'claude' | 'codex'); }); row.appendChild(button); // Signing in again is the heavy way. Claude Code refreshes this token itself whenever it runs — // which is exactly why the reported outage lasted 35 hours: the user had not run it. Say so, so From d577149c696a01ef1b8888cf0827da1b11f390c6 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 16:17:21 -0700 Subject: [PATCH 6/8] perf+fix(main): stop blocking the main thread on scans; close remote-write and launch gaps Performance (measured on synthetic stores at the user's scale: 100 projects, ~520k transcript lines, 5,000 Codex rollouts; "block" = longest main-thread stall): - Codex session re-resolution walked all 5,000 rollouts per project every TTL: 18.8 s -> 277 ms for 100 projects (the deck pass's head cache is reused). Per-tile drift stat pass ~230 ms -> 32 ms. - codexUsageScan re-read, deep-copied and rewrote its 4.5 MB index synchronously on every scan: block 84-99 ms -> 6-11 ms (kept in memory, written async only when it changed). - Tail search for the last user message: an O(n^2) 8 MB sync read, 43.6 ms block -> 1 ms cached, 6 ms on a miss (async, incremental, keyed by file size+mtime). - Active time is an incremental tally instead of copy+sort of every timestamp on every scan: warm usage scan 57-90 ms -> 23-30 ms, heap 40 MB -> 21 MB; session meta tick 3-5 ms -> ~1 ms. - `where` for the Codex CLI cached (44-56 ms sync -> 0.1 ms); session file listing async; `projects:list` shares the build already running (git once for N concurrent callers). Fixes: - `cockpit:liveSessionId` (observe permission) could rewrite which conversation any terminal here announces; only this machine's window may record, on a terminal of the asked project/provider. - Notes: versioned `project:saveNote` (old `setNote` kept for older peers, and it bumps the revision) so a note saved on another device is no longer silently overwritten. - Paired machines may write only to projects this machine actually scanned (the allowlist is a string check); pasted images from them are capped per hour; their usage scans run one at a time. - Session counts were the length of a list capped at 5; counted from the store now. - Open in VS Code expanded %VAR% inside the folder name (shell:true) - escaped and verbatim now, verified byte-exact through a real .cmd shim; wt args escape `;`; openFolder opens folders only; `git init` is bounded; the in-progress task is read from the newest 50 task files, not the oldest. - Tray menu and tooltip take their words from the window, in the app's language. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta (cherry picked from commit 8826a51a4da6f418f2519bdab11ca1769f3ebc10) --- src/main/api/deckApi.projects.test.ts | 94 ++++++++++++ src/main/api/deckApi.surface.test.ts | 2 +- src/main/api/deckApi.ts | 198 ++++++++++++++++-------- src/main/claudeTasks.ts | 4 +- src/main/cliExecutable.test.ts | 71 +++++++++ src/main/cliExecutable.ts | 26 ++++ src/main/codexSessions.test.ts | 67 +++++++++ src/main/codexSessions.ts | 208 ++++++++++++++++---------- src/main/codexUsageScan.test.ts | 64 +++++++- src/main/codexUsageScan.ts | 81 ++++++++-- src/main/createProject.ts | 4 +- src/main/ipc.cockpit.test.ts | 13 +- src/main/ipc.guard.test.ts | 16 +- src/main/launcher.test.ts | 13 +- src/main/launcher.ts | 18 ++- src/main/projects.ts | 68 +++++++-- src/main/sessionMeta.test.ts | 24 +++ src/main/sessionMeta.ts | 31 ++-- src/main/sessionScan.ts | 14 ++ src/main/sessions.test.ts | 92 +++++++++++- src/main/sessions.ts | 177 ++++++++++++++-------- src/main/store.test.ts | 14 +- src/main/store.ts | 26 +++- src/main/tray.ts | 24 ++- src/main/usageScan.test.ts | 39 ++++- src/main/usageScan.ts | 77 ++++++---- src/shared/trayState.ts | 12 +- src/shared/types.ts | 7 + src/shared/usage.test.ts | 43 ++++++ src/shared/usage.ts | 80 ++++++++++ src/shared/wtArgs.test.ts | 9 ++ src/shared/wtArgs.ts | 8 +- 32 files changed, 1315 insertions(+), 309 deletions(-) create mode 100644 src/main/api/deckApi.projects.test.ts create mode 100644 src/main/cliExecutable.test.ts diff --git a/src/main/api/deckApi.projects.test.ts b/src/main/api/deckApi.projects.test.ts new file mode 100644 index 0000000..8acaedf --- /dev/null +++ b/src/main/api/deckApi.projects.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { GitInfo, StoreEntry } from '../../shared/types'; + +vi.mock('electron', () => ({ + ipcMain: { handle: () => {}, on: () => {} }, + dialog: { showOpenDialog: vi.fn() }, + shell: {}, + clipboard: { readImage: vi.fn() }, + app: { getPath: () => '', getVersion: () => '0.0.0', isPackaged: false }, +})); + +// git is what makes a deck list slow; a counted, controllable stand-in shows how many lists ran it. +const { gitCalls, gitGate } = vi.hoisted(() => ({ gitCalls: [] as string[], gitGate: { release: null as null | (() => void) } })); +vi.mock('../gitInfo', async (importOriginal) => ({ + ...(await importOriginal()), + getGitInfo: async (dir: string): Promise => { + gitCalls.push(dir); + if (gitGate.release === null) await new Promise((resolve) => { gitGate.release = resolve; }); + return { branch: 'main', lastCommitMs: 1_000, lastSubject: 'x', uncommitted: 0, ahead: null, repoUrl: null }; + }, +})); + +import { createDeckApi, type DeckApiConfig } from './deckApi'; +import type { DeckApi } from './methods'; + +let root: string; +let api: DeckApi; +const entries = new Map>(); + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'devdeck-deck-')); + mkdirSync(join(root, 'alpha', '.git'), { recursive: true }); + mkdirSync(join(root, 'beta', '.git'), { recursive: true }); + api = createDeckApi({ + win: { on: () => {}, isDestroyed: () => true, webContents: { send: () => {} } }, + defaultBaseDir: root, + store: { + getFolders: () => [{ path: root, kind: 'root' }], + getThresholds: () => null, + get: (p: string): StoreEntry => ({ note: '', pinned: false, hidden: false, lastOpened: null, todos: [], ...entries.get(p) }), + getSessionSummary: () => true, + getAiSessionSummary: () => false, + }, + sendError: vi.fn(), + defaultLanguage: 'en', + ptyHost: { list: () => [] }, + ptyAvailable: true, + tray: { applyCounts: vi.fn(), setAlertImage: vi.fn() }, + shutdown: null, + bootTimeMs: () => 0, + } as unknown as DeckApiConfig).methods; +}); +afterAll(() => rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })); +beforeEach(() => { gitCalls.length = 0; gitGate.release = null; entries.clear(); }); + +type Row = { path: string; pinned: boolean; note: string }; +const list = (): Promise => api['projects:list'].handler() as Promise; +const until = async (cond: () => boolean): Promise => { while (!cond()) await new Promise((r) => setTimeout(r, 1)); }; + +describe('projects:list', () => { + it('lists asked for while one is being built share its git work', async () => { + const first = list(); + await until(() => gitGate.release !== null); // the first list is now waiting on git + const second = list(); + const third = list(); + gitGate.release!(); + const [a, b, c] = await Promise.all([first, second, third]); + expect(gitCalls.sort()).toEqual([join(root, 'alpha'), join(root, 'beta')]); // once per project, not three times + expect(b).toEqual(a); + expect(c).toEqual(a); + expect(a).not.toBe(b); // each caller gets rows of its own + }); + + it('a pin or note changed while a shared list is building shows in every answer', async () => { + const first = list(); + await until(() => gitGate.release !== null); + const second = list(); + entries.set(join(root, 'beta'), { pinned: true, note: 'changed mid-build' }); + gitGate.release!(); + for (const rows of await Promise.all([first, second])) { + expect(rows[0]).toMatchObject({ path: join(root, 'beta'), pinned: true, note: 'changed mid-build' }); + } + }); + + it('a list asked for after one finished runs git again', async () => { + gitGate.release = () => {}; // no gate: git answers at once + await list(); + await list(); + expect(gitCalls).toHaveLength(4); + }); +}); diff --git a/src/main/api/deckApi.surface.test.ts b/src/main/api/deckApi.surface.test.ts index c2c8cad..c9ae341 100644 --- a/src/main/api/deckApi.surface.test.ts +++ b/src/main/api/deckApi.surface.test.ts @@ -296,5 +296,5 @@ it('allows revision-checked task saves only to write-authorized devices', () => expect(mayCallRemotely(api['project:saveTodos'], ['observe'])).toBe(false); expect(mayCallRemotely(api['project:saveTodos'], ['write'])).toBe(true); expect(() => api['project:setTodos'].handler(ALLOWED_ROOT, [])).toThrow('TASKS_CLIENT_OUTDATED'); - expect(() => api['project:saveTodos'].handler(join(process.cwd(), 'outside'), [], 0)).toThrow('allowed folders'); + return expect(Promise.resolve().then(() => api['project:saveTodos'].handler(join(process.cwd(), 'outside'), [], 0))).rejects.toThrow('allowed folders'); }); diff --git a/src/main/api/deckApi.ts b/src/main/api/deckApi.ts index 5a399a6..cf2327a 100644 --- a/src/main/api/deckApi.ts +++ b/src/main/api/deckApi.ts @@ -17,7 +17,7 @@ import { isAllowedPath, isAllowedFilePath, resolveAgentFilePath, AGENT_OPEN_EXT import { basename, cwdKey } from '../../shared/paths'; import { isAllowedExternalUrl, isSafeRepoUrl, isOpenableTerminalLink } from '../../shared/externalUrl'; import { makeTtlCache } from '../../shared/ttlCache'; -import { buildProjectList } from '../projects'; +import { gatherProjects, withEntries, type GatheredProject } from '../projects'; import { createProject } from '../createProject'; import { openProjects, openInEditor, resolveShellPath, makeCliGuard } from '../launcher'; import type { WtTab } from '../../shared/wtArgs'; @@ -35,7 +35,7 @@ import { UsageCoordinator, antigravityUsage } from '../usageProviders'; import { pickAdoptedSessionId, pickDriftedSessionId, type PersistedSession } from '../../shared/cockpitPersist'; import { makeAgentProbe } from '../agentProcess'; import { loginPowerShellCommand } from '../cliExecutable'; -import { listSessionStats, listSessionIds } from '../sessions'; +import { listSessionStats, listSessionIds, countSessionFiles } from '../sessions'; import { listCodexSessionStats, indexCodexSessionsByCwd, readCodexSessionMeta } from '../codexSessions'; import { indexAntigravitySessionsByCwd } from '../antigravitySessions'; import { makeProjectSessionScan } from '../sessionScan'; @@ -74,6 +74,9 @@ const REPO_URL = 'https://github.com/writingdeveloper/devdeck'; /** Paste-image ceiling. A screenshot is around a megabyte; this is generous and still far below the * link's frame limit once base64 expansion is counted. */ const MAX_PASTE_IMAGE_BYTES = 5 * 1024 * 1024; +const REMOTE_IMAGE_BYTES_PER_HOUR = 200 * 1024 * 1024; +/** The note box says so too (maxLength): a note longer than this was cut off silently on save. */ +const MAX_NOTE_CHARS = 10_000; export interface DeckApiConfig { win: BrowserWindow; @@ -195,6 +198,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { const makeDeckScan = () => makeProjectSessionScan({ installed: availableAgents(), perProject: { claude: (p, limit) => getProvider('claude').listSessions(p, limit) }, + perProjectCount: { claude: (p) => countSessionFiles(p, CLAUDE_PROJECTS) }, indexed: { codex: () => cachedIndex(codexIndexCache, CODEX_SESSIONS, () => indexCodexSessionsByCwd(CODEX_SESSIONS)), antigravity: () => cachedIndex(antigravityIndexCache, ANTIGRAVITY_DIR, () => indexAntigravitySessionsByCwd(ANTIGRAVITY_DIR)), @@ -208,40 +212,50 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { lastUserMessage: (p, session) => getProvider(session.agentId).lastUserMessage(p, session.id), entry: (p) => cfg.store.get(p), }); - // ONE build at a time, shared by every caller. The deck gives up on an answer after 150s and asks - // again on its next refresh, but giving up does not stop the walk here — so on a machine that had - // just booted (cold disk, every repository's git and the whole Codex store still to read) each retry - // started a second and third walk on top of the first, and none finished: SIHYEONG-4080 logged five - // 150s timeouts in a row on 2026-10-12. A late caller now joins the walk already under way. - let projectListInFlight: ReturnType | null = null; - invoke('projects:list', allow('observe'), () => { - if (projectListInFlight) return projectListInFlight; - const scan = makeDeckScan(); - // Where a slow build spent its time, summed per stage across the worker pool — the only way to - // tell git from the transcript stores after the fact on a machine nobody was watching. - const spent = { scan: 0, git: 0, sessions: 0, cue: 0 }; - const timed = async (stage: keyof typeof spent, work: () => Promise | T): Promise => { - const t = Date.now(); - try { return await work(); } finally { spent[stage] += Date.now() - t; } - }; - const started = Date.now(); - let count = 0; - projectListInFlight = buildProjectList({ - nowMs: started, - thresholds: effThresholds(), - scan: () => timed('scan', memoScan), - git: (dir) => timed('git', () => getGitInfo(dir)), - sessions: (p) => timed('sessions', () => scan.sessions(p)), - resumeCue: (p, session) => timed('cue', () => getProvider(session.agentId).lastUserMessage(p, session.id)), - getEntry: (p) => cfg.store.get(p), - }).then((list) => { count = list.length; return list; }).finally(() => { - projectListInFlight = null; - const ms = Date.now() - started; - if (ms >= SLOW_PROJECT_LIST_MS) { - cfg.diagnostics?.write('warn', 'deck', `projects:list took ${ms}ms for ${count} projects (summed: scan=${spent.scan} git=${spent.git} sessions=${spent.sessions} cue=${spent.cue}ms)`); - } - }); - return projectListInFlight; + /** + * The git + session-store half of a deck list already being built, shared by every caller. + * + * Callers arrive in bunches — the 45s refresh, every window focus, the task board, a paired machine + * polling this deck — and the deck gives up on an answer after 150s and asks again without stopping + * the walk here. Each used to start another full walk on top of the first: SIHYEONG-4080 logged five + * 150s timeouts in a row on 2026-10-12. A late caller joins the walk under way, and each lays the + * store entries over it itself at the end, so a pin or note changed meanwhile is never lost. + * Cleared on completion, not on a timer: the next refresh must see git as it is then. + */ + const gatherInFlight = new Map>(); + invoke('projects:list', allow('observe'), async () => { + const thresholds = effThresholds(); + const key = JSON.stringify(thresholds); + let gathering = gatherInFlight.get(key); + if (!gathering) { + const scan = makeDeckScan(); + // Where a slow build spent its time, summed per stage across the worker pool — the only way to + // tell git from the transcript stores after the fact on a machine nobody was watching. + const spent = { scan: 0, git: 0, sessions: 0, cue: 0 }; + const timed = async (stage: keyof typeof spent, work: () => Promise | T): Promise => { + const t = Date.now(); + try { return await work(); } finally { spent[stage] += Date.now() - t; } + }; + const startedMs = Date.now(); + gathering = gatherProjects({ + nowMs: startedMs, + thresholds, + scan: () => timed('scan', memoScan), + git: (dir) => timed('git', () => getGitInfo(dir)), + sessions: (p) => timed('sessions', () => scan.sessions(p)), + resumeCue: (p, session) => timed('cue', () => getProvider(session.agentId).lastUserMessage(p, session.id)), + sessionCount: (p) => timed('sessions', () => scan.count(p)), + }); + gatherInFlight.set(key, gathering); + const started = gathering; + void started.then((list) => { + const ms = Date.now() - startedMs; + if (ms >= SLOW_PROJECT_LIST_MS) { + cfg.diagnostics?.write('warn', 'deck', `projects:list took ${ms}ms for ${list.length} projects (summed: scan=${spent.scan} git=${spent.git} sessions=${spent.sessions} cue=${spent.cue}ms)`); + } + }, () => undefined).finally(() => { if (gatherInFlight.get(key) === started) gatherInFlight.delete(key); }); + } + return withEntries(await gathering, (p) => cfg.store.get(p)); }); invoke('project:memory', allow('observe'), (projectPath: string, fresh?: boolean) => { const projectPathString = String(projectPath); @@ -252,9 +266,27 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // These persist to state.json keyed by `path`. Guard with the same allowlist every other path-taking // handler uses — a compromised renderer must not be able to write store entries (10KB notes, todo // lists) for arbitrary paths outside any scanned folder and grow state.json unboundedly. - invoke('project:setNote', allow('write'), (path: string, note: string) => { + /** + * A paired machine may write only to projects this machine actually scanned. The folder allowlist is + * a string check, and any path under a scanned folder passes it whether or not it exists — so a + * device with `write` could grow state.json with entries for invented paths, each write rewriting + * the whole file (and its backup) on the main thread. + */ + async function writableProject(path: string): Promise { if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders'); - cfg.store.setNote(path, String(note).slice(0, 10000)); + if (!calledFromRemote()) return; + const known = await memoScan().catch(() => [] as Awaited>); + if (!known.some((p) => p.path === path)) throw new Error('Not a project on this machine'); + } + invoke('project:setNote', allow('write'), async (path: string, note: string) => { + await writableProject(String(path)); + cfg.store.setNote(path, String(note).slice(0, MAX_NOTE_CHARS)); + }); + // Versioned, so a note saved on another device in the meantime is noticed instead of erased. A + // distinct method: an older host answers "no such method" and the caller falls back to setNote. + invoke('project:saveNote', allow('write'), async (path: string, note: unknown, expectedRevision: number) => { + await writableProject(String(path)); + return cfg.store.saveNote(String(path), String(note ?? '').slice(0, MAX_NOTE_CHARS), Number(expectedRevision)); }); // Keep the old channel only to explain the upgrade, never to accept unversioned writes. invoke('project:setTodos', allow('write'), (path: string) => { @@ -262,16 +294,16 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { throw new Error('TASKS_CLIENT_OUTDATED: Update DevDeck on both devices before editing tasks'); }); // A distinct method prevents older hosts from silently ignoring a revision argument. - invoke('project:saveTodos', allow('write'), (path: string, todos: unknown, expectedRevision: number) => { - if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders'); + invoke('project:saveTodos', allow('write'), async (path: string, todos: unknown, expectedRevision: number) => { + await writableProject(String(path)); return cfg.store.saveTodos(path, todos, expectedRevision); }); - invoke('project:setPinned', allow('write'), (path: string, pinned: boolean) => { - if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders'); + invoke('project:setPinned', allow('write'), async (path: string, pinned: boolean) => { + await writableProject(String(path)); cfg.store.setPinned(path, pinned); }); - invoke('project:setHidden', allow('write'), (path: string, hidden: boolean) => { - if (!isAllowedPath(effFolders(), path)) throw new Error('Project is no longer in the allowed folders'); + invoke('project:setHidden', allow('write'), async (path: string, hidden: boolean) => { + await writableProject(String(path)); cfg.store.setHidden(path, hidden); }); @@ -285,6 +317,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { * thing `memoScan` does for the folder walk, for the same reason. */ const usageInFlight = new Map>(); + let remoteUsageQueue: Promise = Promise.resolve(); invoke('usage:report', allow('observe'), async (sinceMs: number) => { const ms = (Number.isFinite(sinceMs) || sinceMs === Infinity) ? sinceMs : 0; @@ -295,7 +328,13 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { const key = usageInFlightKey(ms); const running = usageInFlight.get(key); if (running) return running; - const started = buildUsageReport(ms); + // A paired machine asking for a different day each time would start a whole-store walk per ask, all + // at once. Its scans run one after another; this machine's own are never queued behind them. + let started: ReturnType; + if (calledFromRemote()) { + started = remoteUsageQueue.catch(() => undefined).then(() => buildUsageReport(ms)); + remoteUsageQueue = started; + } else started = buildUsageReport(ms); usageInFlight.set(key, started); // Cleared on completion rather than on a timer: the point is to share one walk, not to cache its // answer — the next refresh must see what is on disk now. @@ -482,6 +521,9 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { cfg.sendError(`Path outside allowed folders: ${p}`); return; } + // openPath RUNS a file it is given; this channel is for folders only. + const isDir = await stat(p).then((s) => s.isDirectory(), () => false); + if (!isDir) { cfg.sendError(`Not a folder: ${p}`); return; } const err = await shell.openPath(p); if (err) cfg.sendError(err); }); @@ -857,20 +899,25 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // rollout store is flat, so this walks all of it. const wantsCodex = list.some((raw) => agentFor((raw as { agentId?: unknown })?.agentId).id === 'codex'); const codexByCwd = wantsCodex ? await indexCodexSessionsByCwd(CODEX_SESSIONS) : null; - return list.map((raw) => { + const out: boolean[] = []; + for (const raw of list) { const it = (raw && typeof raw === 'object' ? raw : {}) as Record; const projectPath = typeof it.projectPath === 'string' ? it.projectPath : ''; const sessionId = typeof it.sessionId === 'string' ? it.sessionId : ''; - if (!sessionId || !projectPath || !isAllowedPath(folders, projectPath)) return true; + if (!sessionId || !projectPath || !isAllowedPath(folders, projectPath)) { out.push(true); continue; } const owner = agentFor(it.agentId).id; - if (owner === 'antigravity') return true; // records no per-tile id — nothing to verify + if (owner === 'antigravity') { out.push(true); continue; } // records no per-tile id — nothing to verify if (owner === 'codex') { - return (codexByCwd?.get(cwdKey(projectPath)) ?? []).some((s) => s.id === sessionId); + out.push((codexByCwd?.get(cwdKey(projectPath)) ?? []).some((s) => s.id === sessionId)); + continue; } + // Awaited (the listing is async so a 200-entry restore list never holds the main thread), and + // still read once per distinct project. let ids = claudeIds.get(projectPath); - if (!ids) { ids = new Set(listSessionIds(projectPath, CLAUDE_PROJECTS)); claudeIds.set(projectPath, ids); } - return ids.has(sessionId); - }); + if (!ids) { ids = new Set(await listSessionIds(projectPath, CLAUDE_PROJECTS)); claudeIds.set(projectPath, ids); } + out.push(ids.has(sessionId)); + } + return out; }); /** * When each saved entry's conversation was last written — its transcript's mtime — answered as a @@ -885,6 +932,14 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { const claudeStats = new Map>(); const wantsCodex = list.some((raw) => agentFor((raw as { agentId?: unknown })?.agentId).id === 'codex'); const codexByCwd = wantsCodex ? await indexCodexSessionsByCwd(CODEX_SESSIONS) : null; + // Claude's per-project stat pass is async (it stats every transcript), so each project is read + // once up front and the answers below stay a plain lookup. + for (const raw of list) { + const it = (raw && typeof raw === 'object' ? raw : {}) as Record; + const projectPath = typeof it.projectPath === 'string' ? it.projectPath : ''; + if (!projectPath || claudeStats.has(projectPath) || agentFor(it.agentId).id !== 'claude' || !isAllowedPath(folders, projectPath)) continue; + claudeStats.set(projectPath, new Map((await listSessionStats(projectPath, CLAUDE_PROJECTS)).map((s) => [s.id, s.mtimeMs]))); + } return list.map((raw): number | null => { const it = (raw && typeof raw === 'object' ? raw : {}) as Record; const projectPath = typeof it.projectPath === 'string' ? it.projectPath : ''; @@ -895,12 +950,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { if (owner === 'codex') { return (codexByCwd?.get(cwdKey(projectPath)) ?? []).find((s) => s.id === sessionId)?.mtimeMs ?? null; } - let stats = claudeStats.get(projectPath); - if (!stats) { - stats = new Map(listSessionStats(projectPath, CLAUDE_PROJECTS).map((s) => [s.id, s.mtimeMs])); - claudeStats.set(projectPath, stats); - } - return stats.get(sessionId) ?? null; + return claudeStats.get(projectPath)?.get(sessionId) ?? null; }); }); // Live session-id drift check (/clear starts a brand-new session id in the same terminal — the @@ -908,11 +958,21 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // sends the tile's timing evidence; this stats the project's session files and adopts a new id only // when unambiguous (pickDriftedSessionId). Claude and Codex have per-file session stores; // Antigravity does not. + /** + * Whether a liveSessionId answer may be recorded on a terminal. The call needs only `observe`, and the + * timing evidence it carries is the caller's, so a paired machine could otherwise point any terminal + * here at any conversation — and every deck reconciling against this machine would follow. Only this + * machine's own window records, and only on a terminal of the project and provider it asked about. + */ + function notablePty(ptyId: unknown, projectPath: string, agentId: string): boolean { + if (calledFromRemote() || typeof ptyId !== 'string' || !ptyId) return false; + return cfg.ptyHost.list().some((s) => s.id === ptyId && s.projectPath === projectPath && s.agentId === agentId); + } invoke('cockpit:liveSessionId', allow('observe'), async (projectPath: string, opts: { currentId: string | null; claimedIds: string[]; openedAtMs: number; sinceMs: number; lastDataAtMs: number; agentId?: AgentId; ptyId?: string }) => { if (!isAllowedPath(effFolders(), String(projectPath))) return null; if (!opts || typeof opts !== 'object') return null; const a = agentFor(opts.agentId).id; - const stats = a === 'claude' ? listSessionStats(String(projectPath), CLAUDE_PROJECTS) + const stats = a === 'claude' ? await listSessionStats(String(projectPath), CLAUDE_PROJECTS) : a === 'codex' ? await listCodexSessionStats(String(projectPath), CODEX_SESSIONS) : null; if (!stats) return null; @@ -927,7 +987,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // Recorded on the pty for the same reason as a drift below: without it this machine keeps // announcing the session with no conversation, and an open of that conversation from another // deck does not find it running and starts a second agent on it. - if (picked && typeof opts.ptyId === 'string' && opts.ptyId && cfg.ptyHost.note(opts.ptyId, { sessionId: picked })) publishSessions(); + if (picked && notablePty(opts.ptyId, String(projectPath), a) && cfg.ptyHost.note(opts.ptyId!, { sessionId: picked })) publishSessions(); return picked; } const drifted = pickDriftedSessionId(stats, { @@ -939,8 +999,8 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { }); // Recorded on the pty as well, so a machine listing what is running here reports the conversation // a tile actually MOVED to (after /clear) rather than the one it happened to open on. - if (drifted && typeof opts.ptyId === 'string' && opts.ptyId) { - if (cfg.ptyHost.note(opts.ptyId, { sessionId: drifted })) publishSessions(); + if (drifted && notablePty(opts.ptyId, String(projectPath), a)) { + if (cfg.ptyHost.note(opts.ptyId!, { sessionId: drifted })) publishSessions(); } return drifted; }); @@ -975,6 +1035,13 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { // Tray attention indicator (Discord-style): the renderer supplies the red-dotted icon once + live needs-you counts. send('tray:alertImage', localOnly, (dataUrl: string) => cfg.tray.setAlertImage(String(dataUrl))); + send('tray:labels', localOnly, (labels: unknown) => { + if (!labels || typeof labels !== 'object') return; + const pick = (k: string): string | undefined => { const v = (labels as Record)[k]; return typeof v === 'string' && v.length <= 80 ? v : undefined; }; + const next: Partial = {}; + for (const k of ['open', 'shutdownIdle', 'shutdownNow', 'cancelShutdown', 'quit', 'waiting', 'overdue'] as const) { const v = pick(k); if (v) next[k] = v; } + cfg.tray.setLabels?.(next as import('../../shared/trayState').TrayLabels); + }); // Partial merge: the cockpit sends {attention, turn} and the deck sends {overdue} independently — // each sender updates only the fields it owns, so one can't zero the other's counts. send('tray:counts', localOnly, (counts: { attention?: number; turn?: number; overdue?: number }) => { @@ -1040,6 +1107,7 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { * Deliberately narrow: it accepts a PNG and nothing else, writes only into the OS temp directory * under the name the existing sweeper already cleans up, and never takes a caller-supplied path. */ + const remoteImageWindow = { startMs: 0, bytes: 0 }; invoke('cockpit:receiveImage', allow('control'), (base64: unknown) => { if (typeof base64 !== 'string' || base64.length > MAX_PASTE_IMAGE_BYTES * 2) return null; let png: Buffer; @@ -1049,6 +1117,12 @@ export function createDeckApi(cfg: DeckApiConfig): DeckApiBundle { const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); if (png.length < PNG_MAGIC.length || !png.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC)) return null; if (png.length > MAX_PASTE_IMAGE_BYTES) return null; + // The sweeper clears these after a day; until then a paired machine could fill the disk at the + // request rate. A screenshot an hour's worth of pasting does not come near this. + const now = Date.now(); + if (now - remoteImageWindow.startMs > 3_600_000) { remoteImageWindow.startMs = now; remoteImageWindow.bytes = 0; } + if (remoteImageWindow.bytes + png.length > REMOTE_IMAGE_BYTES_PER_HOUR) return null; + remoteImageWindow.bytes += png.length; const file = join(tmpdir(), `${PASTE_IMAGE_PREFIX}${randomUUID()}.png`); try { writeFileSync(file, png); } catch { return null; } return file; diff --git a/src/main/claudeTasks.ts b/src/main/claudeTasks.ts index 7b61eb5..04d2ab0 100644 --- a/src/main/claudeTasks.ts +++ b/src/main/claudeTasks.ts @@ -44,7 +44,9 @@ export function readActiveTaskForm( const dir = join(tasksDir, sessionId); let names: string[]; try { - names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(byNumber).slice(0, MAX_FILES); + // The NEWEST files: tasks are numbered in creation order, so the first fifty of a long session's + // list are the oldest, and the task actually in progress was never read once it passed fifty. + names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort(byNumber).slice(-MAX_FILES); } catch { return null; // no task list for this session } diff --git a/src/main/cliExecutable.test.ts b/src/main/cliExecutable.test.ts new file mode 100644 index 0000000..370a655 --- /dev/null +++ b/src/main/cliExecutable.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const { whereOutput } = vi.hoisted(() => ({ whereOutput: vi.fn((): string => '') })); +vi.mock('node:child_process', () => ({ execFileSync: vi.fn(() => Buffer.from(whereOutput())) })); + +import { execFileSync } from 'node:child_process'; +import { resolveAgentCliPath, _clearCliPathCache } from './cliExecutable'; + +let dir: string; +// The per-user install locations are checked after `where`; point them at an empty dir so what is +// installed on the machine running the tests cannot answer for the stub. +const SAVED = { APPDATA: process.env.APPDATA, LOCALAPPDATA: process.env.LOCALAPPDATA, USERPROFILE: process.env.USERPROFILE }; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'devdeck-cli-')); + Object.assign(process.env, { APPDATA: dir, LOCALAPPDATA: dir, USERPROFILE: dir }); + _clearCliPathCache(); + vi.mocked(execFileSync).mockClear(); +}); +afterEach(() => { + for (const [k, v] of Object.entries(SAVED)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); +}); + +// `where` is a synchronous child process (~50 ms of a frozen main process); the Codex usage refresh +// asked it on every poll. +describe('resolveAgentCliPath lookup cache', () => { + it('runs `where` once for repeated asks, and again once the found file is gone', () => { + const exe = join(dir, 'codex.cmd'); + writeFileSync(exe, ''); + whereOutput.mockReturnValue(`${exe}\r\n`); + expect(resolveAgentCliPath('codex', process.env, 'win32')).toBe(exe); + expect(resolveAgentCliPath('codex', process.env, 'win32')).toBe(exe); + expect(execFileSync).toHaveBeenCalledTimes(1); + + rmSync(exe); // uninstalled: the remembered path must not be handed out + whereOutput.mockReturnValue(''); + expect(resolveAgentCliPath('codex', process.env, 'win32')).toBe('codex'); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('never remembers a miss — a CLI installed later is found on the next ask', () => { + whereOutput.mockReturnValue(''); + const env = process.env; + const before = resolveAgentCliPath('claude', env, 'win32'); + resolveAgentCliPath('claude', env, 'win32'); + expect(execFileSync).toHaveBeenCalledTimes(2); + const exe = join(dir, 'claude.exe'); + writeFileSync(exe, ''); + whereOutput.mockReturnValue(exe); + expect(resolveAgentCliPath('claude', env, 'win32')).toBe(exe); + expect(before).not.toBe(exe); + }); + + it('does not cache lookups made with an injected environment or existence check', () => { + const exe = join(dir, 'codex.exe'); + writeFileSync(exe, ''); + whereOutput.mockReturnValue(exe); + const exists = (): boolean => true; + resolveAgentCliPath('codex', process.env, 'win32', exists); + resolveAgentCliPath('codex', process.env, 'win32', exists); + expect(execFileSync).toHaveBeenCalledTimes(2); + }); + + it('is not consulted off Windows', () => { + expect(resolveAgentCliPath('codex', process.env, 'linux')).toBe('codex'); + expect(execFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/cliExecutable.ts b/src/main/cliExecutable.ts index 798553e..c9ae51f 100644 --- a/src/main/cliExecutable.ts +++ b/src/main/cliExecutable.ts @@ -32,6 +32,19 @@ export function resolveAgentCliPath( exists: (path: string) => boolean = existsSync, ): string { if (platform !== 'win32') return providerId; + // Only the production lookup is remembered; an injected env / exists is a test asking a question. + const cacheable = env === process.env && exists === existsSync; + const key = `${providerId}\n${env.PATH ?? env.Path ?? ''}`; + if (cacheable) { + const hit = _found.get(key); + if (hit && Date.now() - hit.at < FOUND_TTL_MS && exists(hit.path)) return hit.path; + } + const found = lookUp(providerId, env, exists); + if (cacheable && found !== providerId) _found.set(key, { path: found, at: Date.now() }); + return found; +} + +function lookUp(providerId: LoginProvider, env: NodeJS.ProcessEnv, exists: (path: string) => boolean): string { try { const found = execFileSync('where', [providerId], { env, windowsHide: true }) .toString().split(/\r?\n/).map((line) => line.trim()).find((line) => /\.(?:cmd|exe)$/i.test(line) && exists(line)); @@ -40,6 +53,19 @@ export function resolveAgentCliPath( return knownWindowsCandidates(providerId, env).find(exists) ?? providerId; } +/** + * Paths already found, briefly. `where` is a synchronous child process — ~50 ms here during which the + * main process does nothing else — and the Codex usage refresh ran it on every poll. Positive results + * only (a CLI installed after DevDeck started must still be found on the next ask), re-checked for + * existence on every hit (an uninstall is noticed at once), keyed by PATH (a changed PATH is a new + * question), and expired after a few minutes so a different install that now comes first on PATH is + * picked up without a restart. + */ +const FOUND_TTL_MS = 5 * 60_000; +const _found = new Map(); +/** Test-only: forget remembered CLI paths. */ +export function _clearCliPathCache(): void { _found.clear(); } + /** Fixed, provider-owned login command for the visible PowerShell setup terminal. */ export function loginPowerShellCommand(providerId: LoginProvider): string { const executable = resolveAgentCliPath(providerId).replace(/'/g, "''"); diff --git a/src/main/codexSessions.test.ts b/src/main/codexSessions.test.ts index bc6e1c5..96a7028 100644 --- a/src/main/codexSessions.test.ts +++ b/src/main/codexSessions.test.ts @@ -199,3 +199,70 @@ describe('the head cache', () => { expect(await listCodexRolloutHeads(dir)).toEqual([]); }); }); + +describe('per-project passes and the shared head cache', () => { + // The cockpit asks one project's question per Codex tile per tick; a pass for one project skips the + // stat of rollouts already known to be another project's. Everything a full pass would find for + // THIS project must still be found. + it('a per-project pass still sees this project\'s new, changed, deleted and late-valid rollouts', async () => { + const mine = writeRollout(`rollout-${OLD_ID}.jsonl`, rollout(OLD_ID, PROJECT)); + writeRollout(`rollout-${OTHER_ID}.jsonl`, rollout(OTHER_ID, OTHER_PROJECT)); + const late = writeRollout(`rollout-${NEW_ID}.jsonl`, ''); // created before its head is written + utimesSync(mine, 1000, 1000); + expect((await listCodexRolloutHeads(dir)).map((h) => h.id).sort()).toEqual([OLD_ID, OTHER_ID]); // the empty one has no head yet + + writeFileSync(late, rollout(NEW_ID, PROJECT)); // the head lands later + utimesSync(late, 3000, 3000); + writeFileSync(mine, rollout(OLD_ID, PROJECT, [user('first'), user('appended')])); // this project's file grows + utimesSync(mine, 2000, 2000); + const extra = '44444444-4444-4444-4444-444444444444'; + writeRollout(`rollout-${extra}.jsonl`, rollout(extra, PROJECT)); // a brand-new one + + const stats = await listCodexSessionStats(PROJECT, dir); + expect(stats.map((s) => s.id).sort()).toEqual([OLD_ID, NEW_ID, extra].sort()); + expect(stats.find((s) => s.id === OLD_ID)?.mtimeMs).toBe(2_000_000); + expect((await listCodexSessionStats(OTHER_PROJECT, dir)).map((s) => s.id)).toEqual([OTHER_ID]); + + rmSync(mine); + expect((await listCodexSessionIds(PROJECT, dir)).sort()).toEqual([NEW_ID, extra].sort()); + }); + + it('a per-project pass agrees with filtering a full pass', async () => { + for (let i = 0; i < 150; i++) { + const id = `a0b1c2d3-0000-0000-0000-${String(i).padStart(12, '0')}`; + const file = writeRollout(`rollout-${id}.jsonl`, rollout(id, i % 3 === 0 ? PROJECT : OTHER_PROJECT)); + utimesSync(file, 1000 + i, 1000 + i); + } + const full = (await listCodexRolloutHeads(dir)).filter((h) => h.cwd === PROJECT).map((h) => h.id).sort(); + expect((await listCodexSessionIds(PROJECT, dir)).sort()).toEqual(full); + _clearCodexHeadCache(); // and from a cold cache, where nothing can be skipped + expect((await listCodexSessionIds(PROJECT, dir)).sort()).toEqual(full); + }); + + it('callers sharing one pass each get their own array', async () => { + writeRollout(`rollout-${OLD_ID}.jsonl`, rollout(OLD_ID, PROJECT)); + writeRollout(`rollout-${NEW_ID}.jsonl`, rollout(NEW_ID, PROJECT)); + const [a, b] = await Promise.all([listCodexRolloutHeads(dir), listCodexRolloutHeads(dir)]); + expect(a).not.toBe(b); + expect(a).toEqual(b); + a.reverse(); + expect(b.map((h) => h.id)).not.toEqual(a.map((h) => h.id)); + }); + + // The deck asks for every project's resume cue on each refresh. Resolving the id used to be a pass + // over the whole store per project; the deck's own index pass has already read every head. + it('resolves a session from the head cache, and follows the rollout as it grows', async () => { + const file = writeRollout(`rollout-${NEW_ID}.jsonl`, rollout(NEW_ID, PROJECT, [user('first ask')])); + await listCodexRolloutHeads(dir); + expect(await lastUserMessageForCodexSession(PROJECT, NEW_ID, dir)).toBe('first ask'); + writeFileSync(file, rollout(NEW_ID, PROJECT, [user('first ask'), user('the newer ask')])); + expect(await lastUserMessageForCodexSession(PROJECT, NEW_ID, dir)).toBe('the newer ask'); + expect(await lastUserMessageForCodexSession(OTHER_PROJECT, NEW_ID, dir)).toBeNull(); // still project-scoped + }); + + it('finds a user message several chunks back, past multi-byte text', async () => { + const filler = JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: '한글 출력 '.repeat(40_000) } }); + writeRollout(`rollout-${NEW_ID}.jsonl`, rollout(NEW_ID, PROJECT, [user('older'), user('깊이 묻힌 요청'), filler, filler, filler])); + expect(await lastUserMessageForCodexSession(PROJECT, NEW_ID, dir)).toBe('깊이 묻힌 요청'); + }); +}); diff --git a/src/main/codexSessions.ts b/src/main/codexSessions.ts index 5401a85..72e2e44 100644 --- a/src/main/codexSessions.ts +++ b/src/main/codexSessions.ts @@ -1,10 +1,11 @@ -import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs'; -import { open, readdir, stat } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { open, readdir, stat, type FileHandle } from 'node:fs/promises'; import { setImmediate as yieldToLoop } from 'node:timers/promises'; import { join } from 'node:path'; import type { SessionMeta } from '../shared/types'; import { codexFirstUserMessage, codexLastUserMessage, codexSessionMeta, codexTailMeta, type CodexTailMeta } from '../shared/codexParse'; import { SESSION_ID_RE, cwdKey } from '../shared/paths'; +import { cachedTailAnswer, findInTail } from './sessions'; const HEAD_BYTES = 64 * 1024; const TAIL_CHUNK_BYTES = 1024 * 1024; @@ -65,59 +66,117 @@ async function rolloutFiles(dir: string): Promise { * seconds during which the main process did nothing else — no IPC replies, no terminal output, no * frames. That pass now costs one stat() per file. */ -interface CachedHead { mtimeMs: number; birthtimeMs: number; size: number; head: CodexRolloutHead | null } +interface CachedHead { + mtimeMs: number; birthtimeMs: number; size: number; head: CodexRolloutHead | null; + /** cwdKey(head.cwd), worked out once — every per-project question compares against it. */ + key: string | null; + /** The store the file was found under, so a lookup by id never answers from a different store. */ + root: string; +} const _headCache = new Map(); /** Test-only: forget every cached head so a fixture rewritten in place is re-read. */ -export function _clearCodexHeadCache(): void { _headCache.clear(); } +export function _clearCodexHeadCache(): void { _headCache.clear(); _resolved.clear(); } /** Files to touch between yields. Big enough that the syscalls dominate, small enough to stay smooth. */ const HEAD_SCAN_BATCH = 64; -/** Every readable rollout's head, whatever project it belongs to (one bounded read per file). */ -export async function listCodexRolloutHeads(dir: string): Promise { +type FileStat = { mtimeMs: number; birthtimeMs: number; size: number }; + +/** + * One pass over the store. With `onlyKey`, the pass answers for ONE project, and skips the stat of + * every rollout whose cached head already names a different one. + * + * Why that is safe: a head is written once and never rewritten (see above), and a rollout's path is + * its timestamp plus its session uuid, so a file known to belong to another project cannot come to + * belong to this one. Files with no usable head yet, files never seen, and this project's own files + * are all still statted (and re-read on any change) exactly as in a full pass. The directory walk is + * still complete, so new files are found and deleted ones still drop out of the cache. + * + * Why it matters: the cockpit asks this once per Codex tile per tick (drift detection), and on the + * store this was reported from that was 5,000 stats — ~160 ms of the four-thread libuv pool, which + * every other file read in the app queues behind — to learn about the few dozen files of one project. + * + * Each batch's stats go out together (in order of results, so the output order is the walk order, + * as before): 5,000 one-at-a-time round-trips were most of a full pass's wall time. + */ +async function scanHeads(dir: string, onlyKey: string | null): Promise { if (!existsSync(dir)) return []; const files = await rolloutFiles(dir); const heads: CodexRolloutHead[] = []; - const seen = new Set(); - let sinceYield = 0; - for (const file of files) { - if (++sinceYield >= HEAD_SCAN_BATCH) { sinceYield = 0; await yieldToLoop(); } - seen.add(file); - let mtimeMs: number, birthtimeMs: number, size: number; - try { ({ mtimeMs, birthtimeMs, size } = await stat(file)); } catch { continue; } - const cached = _headCache.get(file); - // Identity of the bytes we already read: same file, same length, same timestamps. An append - // changes size and mtime, and a replaced file changes birthtime — neither can leave the head - // it was parsed from intact while all three match. - if (cached && cached.size === size && cached.mtimeMs === mtimeMs && cached.birthtimeMs === birthtimeMs) { - if (cached.head) heads.push({ ...cached.head, mtimeMs, birthtimeMs, size }); - continue; - } - const raw = await readHead(file); - const meta = raw === null ? null : codexSessionMeta(raw); - if (!meta || !meta.cwd || !SESSION_ID_RE.test(meta.id)) { - // Remembered as unusable too: a file that is not a rollout stays not a rollout, and re-reading - // 64 KiB from it every pass is the same waste as re-reading a good one. - _headCache.set(file, { mtimeMs, birthtimeMs, size, head: null }); - continue; + const seen = new Set(files); + for (let start = 0; start < files.length; start += HEAD_SCAN_BATCH) { + if (start > 0) await yieldToLoop(); + const batch = files.slice(start, start + HEAD_SCAN_BATCH); + const stats = await Promise.all(batch.map(async (file): Promise => { + const cached = _headCache.get(file); + if (onlyKey !== null && cached?.head && cached.key !== onlyKey) return 'elsewhere'; + try { return await stat(file); } catch { return null; } + })); + for (let i = 0; i < batch.length; i++) { + const file = batch[i]; + const st = stats[i]; + if (st === null || st === 'elsewhere') continue; + const { mtimeMs, birthtimeMs, size } = st; + const cached = _headCache.get(file); + // Identity of the bytes we already read: same file, same length, same timestamps. An append + // changes size and mtime, and a replaced file changes birthtime — neither can leave the head + // it was parsed from intact while all three match. + if (cached && cached.size === size && cached.mtimeMs === mtimeMs && cached.birthtimeMs === birthtimeMs) { + if (cached.head && (onlyKey === null || cached.key === onlyKey)) heads.push({ ...cached.head, mtimeMs, birthtimeMs, size }); + continue; + } + const raw = await readHead(file); + const meta = raw === null ? null : codexSessionMeta(raw); + if (!meta || !meta.cwd || !SESSION_ID_RE.test(meta.id)) { + // Remembered as unusable too: a file that is not a rollout stays not a rollout, and re-reading + // 64 KiB from it every pass is the same waste as re-reading a good one. + _headCache.set(file, { mtimeMs, birthtimeMs, size, head: null, key: null, root: dir }); + continue; + } + const head: CodexRolloutHead = { + file, id: meta.id, cwd: meta.cwd, mtimeMs, birthtimeMs, size, firstMessage: codexFirstUserMessage(raw!), + }; + const key = cwdKey(meta.cwd); + _headCache.set(file, { mtimeMs, birthtimeMs, size, head, key, root: dir }); + if (onlyKey === null || key === onlyKey) heads.push(head); } - const head: CodexRolloutHead = { - file, id: meta.id, cwd: meta.cwd, mtimeMs, birthtimeMs, size, firstMessage: codexFirstUserMessage(raw!), - }; - _headCache.set(file, { mtimeMs, birthtimeMs, size, head }); - heads.push(head); } // Rollouts are deleted too; without this the cache would only ever grow. if (_headCache.size > seen.size) for (const key of [..._headCache.keys()]) if (!seen.has(key)) _headCache.delete(key); return heads; } +/** + * Passes in progress, by store + project. The deck's refresh asks for the whole store twice at once + * (its session index and the usage report), and tiles restored together tick together; each used to + * walk and stat the store on its own. A caller arriving mid-pass now shares it — and gets its own + * copy of the array, because callers sort what they are given. + */ +const _passes = new Map>(); + +async function sharedPass(dir: string, onlyKey: string | null): Promise { + const id = `${onlyKey ?? '*'}\n${dir}`; + let pass = _passes.get(id); + if (!pass) { + pass = scanHeads(dir, onlyKey); + _passes.set(id, pass); + const started = pass; + // Cleared on completion, never on a timer: the point is to share one walk, not to cache its answer. + void started.catch(() => undefined).finally(() => { if (_passes.get(id) === started) _passes.delete(id); }); + } + return (await pass).slice(); +} + +/** Every readable rollout's head, whatever project it belongs to (one bounded read per file). */ +export async function listCodexRolloutHeads(dir: string): Promise { + return sharedPass(dir, null); +} + async function rolloutHeads(projectPath: string, dir: string): Promise { // Match the recorded CWD as a canonical key: a rollout for a similarly named project must never // leak in, while the same project spelled with other separators / drive case still matches. - const key = cwdKey(projectPath); - return (await listCodexRolloutHeads(dir)).filter((h) => cwdKey(h.cwd) === key); + return sharedPass(dir, cwdKey(projectPath)); } /** @@ -171,15 +230,37 @@ export async function listCodexSessionStats(projectPath: string, dir: string): P * only has to be re-validated for existence; the TTL is there for ids that appear later. */ const FILE_TTL_MS = 5 * 60_000; -const _fileCache = new Map(); +const _resolved = new Map(); + +/** + * The rollout the head cache already knows for this id and project, when exactly one does. + * + * The miss path below is a pass over the store, and it was taken per PROJECT: the deck asks for every + * project's resume cue on each refresh, and every five minutes each Codex project's entry expired and + * walked and statted all 5,000 rollouts again — 100 projects meant 100 passes, measured at 18 seconds + * of saturated file I/O on a synthetic store of that size. But the deck's own session index has just + * made that same pass, so the head cache already holds the answer. Two matches (the same session id + * in two files) are left to the walk, which picks one the way it always has. + */ +function knownRollout(dir: string, key: string, id: string): string | null { + let found: string | null = null; + for (const [file, entry] of _headCache) { + if (entry.root !== dir || entry.key !== key || entry.head?.id !== id) continue; + if (found) return null; + found = file; + } + return found; +} async function resolveRolloutFile(projectPath: string, id: string, dir: string, now = Date.now()): Promise { - const key = `${cwdKey(projectPath)}${id}`; - const hit = _fileCache.get(key); + const projectKey = cwdKey(projectPath); + const key = `${projectKey}\u0000${id}`; + const hit = _resolved.get(key); if (hit && now - hit.at < FILE_TTL_MS && existsSync(hit.file)) return hit.file; - const file = (await rolloutHeads(projectPath, dir)).find((head) => head.id === id)?.file; - if (!file) { _fileCache.delete(key); return null; } - _fileCache.set(key, { file, at: now }); + const known = knownRollout(dir, projectKey, id); + const file = known && existsSync(known) ? known : (await rolloutHeads(projectPath, dir)).find((head) => head.id === id)?.file; + if (!file) { _resolved.delete(key); return null; } + _resolved.set(key, { file, at: now }); return file; } @@ -204,15 +285,16 @@ export async function readCodexSessionMeta(projectPath: string, id: string, dir: if (!file) return emptyCodexMeta(); let size: number; let mtimeMs: number; - try { ({ size, mtimeMs } = statSync(file)); } catch { return emptyCodexMeta(); } + try { ({ size, mtimeMs } = await stat(file)); } catch { return emptyCodexMeta(); } const cached = _metaCache.get(file); if (cached && cached.mtimeMs === mtimeMs) return cached.value; - let fd: number | null = null; + // Async: this was a 2 MB readSync on every tick of a working Codex tile (its mtime moves each turn). + let handle: FileHandle | null = null; try { const length = Math.min(SUMMARY_TAIL_BYTES, size); - fd = openSync(file, 'r'); + handle = await open(file, 'r'); const buffer = Buffer.alloc(length); - const bytesRead = readSync(fd, buffer, 0, length, size - length); + const { bytesRead } = await handle.read(buffer, 0, length, size - length); const text = buffer.toString('utf8', 0, bytesRead); // Drop the leading partial line unless we read from the very start of the file. const newline = text.indexOf('\n'); @@ -230,45 +312,19 @@ export async function readCodexSessionMeta(projectPath: string, id: string, dir: } catch { return emptyCodexMeta(); } finally { - if (fd !== null) { - try { closeSync(fd); } catch { /* file disappeared */ } - } + await handle?.close().catch(() => { /* file disappeared */ }); } } /** * Find the last user message without trusting an id as a path component. We locate a matching, - * metadata-validated rollout first, then scan its final 8 MiB backward in bounded chunks. + * metadata-validated rollout first, then scan its final 8 MiB backward in bounded chunks — async, + * searching each read's new lines only, and answered from cache while the rollout is unchanged + * (see findInTail / cachedTailAnswer). */ export async function lastUserMessageForCodexSession(projectPath: string, id: string, dir: string): Promise { if (!SESSION_ID_RE.test(id)) return null; const file = await resolveRolloutFile(projectPath, id, dir); // cached: the deck asks this per project if (!file) return null; - - let fd: number | null = null; - try { - fd = openSync(file, 'r'); - const size = statSync(file).size; - let position = size; - let accumulated = Buffer.alloc(0); - while (position > 0 && size - position < TAIL_MAX_BYTES) { - const length = Math.min(TAIL_CHUNK_BYTES, position); - position -= length; - const chunk = Buffer.alloc(length); - const bytesRead = readSync(fd, chunk, 0, length, position); - accumulated = Buffer.concat([chunk.subarray(0, bytesRead), accumulated]); - const text = accumulated.toString('utf8'); - const firstNewline = text.indexOf('\n'); - const completeLines = position === 0 ? text : firstNewline < 0 ? '' : text.slice(firstNewline + 1); - const message = codexLastUserMessage(completeLines); - if (message) return message; - } - return null; - } catch { - return null; - } finally { - if (fd !== null) { - try { closeSync(fd); } catch { /* file disappeared */ } - } - } + return cachedTailAnswer(file, (handle, size) => findInTail(handle, size, TAIL_MAX_BYTES, TAIL_CHUNK_BYTES, codexLastUserMessage)); } diff --git a/src/main/codexUsageScan.test.ts b/src/main/codexUsageScan.test.ts index 14c4739..f42eeaa 100644 --- a/src/main/codexUsageScan.test.ts +++ b/src/main/codexUsageScan.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { appendFileSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { scanCodexUsage } from './codexUsageScan'; @@ -159,3 +159,65 @@ describe('scanCodexUsage', () => { expect(yields).toBeGreaterThan(1); }); }); + +describe('the index between scans', () => { + // Every scan used to read, validate, deep-clone, re-serialize and rewrite the whole index + // synchronously, changed or not (~80 ms on a 5,000-rollout store). Between scans it now lives in + // memory; the file is written only when something changed, and read again when it is not ours. + const nextTotal = { input_tokens: 150, cached_input_tokens: 70, cache_write_input_tokens: 0, output_tokens: 15 }; + const appendTurn = (file: string): void => appendFileSync(file, token(nextTotal, nextTotal, '2026-08-08T10:03:00.000Z') + '\n'); + const pause = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)); + + it('does not rewrite the index when nothing changed', async () => { + writeFileSync(rolloutPath(), firstRollout().join('\n') + '\n'); + await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }); + const before = statSync(cachePath).mtimeMs; + await pause(); + const again = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }); + expect(again.global.input).toBe(40); + expect(statSync(cachePath).mtimeMs).toBe(before); + }); + + it('writes the index after an append, and later scans resume from it without recounting', async () => { + const file = rolloutPath(); + writeFileSync(file, firstRollout().join('\n') + '\n'); + await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }); + appendTurn(file); + const grown = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }); + expect(grown.global).toEqual({ input: 80, cacheRead: 70, cacheWrite: 0, output: 15 }); + const saved = JSON.parse(readFileSync(cachePath, 'utf8')) as { files: { offset: number }[] }; + expect(saved.files[0].offset).toBe(statSync(file).size); + let bytes = 0; + for (let i = 0; i < 2; i++) { + const report = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity, onBytesRead: (n) => { bytes += n; } }); + expect(report.global).toEqual({ input: 80, cacheRead: 70, cacheWrite: 0, output: 15 }); + } + expect(bytes).toBe(0); + }); + + it('honours an index file replaced by someone else since the last scan', async () => { + writeFileSync(rolloutPath(), firstRollout().join('\n') + '\n'); + await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }); + const saved = JSON.parse(readFileSync(cachePath, 'utf8')) as { files: { buckets: { totals: { input: number } }[] }[] }; + saved.files[0].buckets[0].totals.input = 999; // still a valid index — just different numbers + await pause(); + writeFileSync(cachePath, JSON.stringify(saved)); + const report = await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }); + expect(report.global.input).toBe(999); + }); + + it('two scans at once each count an append once, and leave a readable index behind', async () => { + const file = rolloutPath(); + writeFileSync(file, firstRollout().join('\n') + '\n'); + await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }); + appendTurn(file); + const [a, b] = await Promise.all([ + scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }), + scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity }), + ]); + expect(a.global.input).toBe(80); + expect(b.global.input).toBe(80); + expect((await scanCodexUsage({ sessionsDir, cachePath, sinceMs: Infinity })).global.input).toBe(80); + expect(() => JSON.parse(readFileSync(cachePath, 'utf8'))).not.toThrow(); + }); +}); diff --git a/src/main/codexUsageScan.ts b/src/main/codexUsageScan.ts index 822a93b..23fbeea 100644 --- a/src/main/codexUsageScan.ts +++ b/src/main/codexUsageScan.ts @@ -1,4 +1,5 @@ -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, writeFileSync } from 'node:fs'; +import { closeSync, existsSync, openSync, readFileSync, readSync } from 'node:fs'; +import { mkdir, rename, stat, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import { basename, cwdKey } from '../shared/paths'; import { @@ -114,11 +115,65 @@ function loadIndex(cachePath: string): CodexUsageIndex { } } -function saveIndex(cachePath: string, index: CodexUsageIndex): void { - mkdirSync(dirname(cachePath), { recursive: true }); - const temp = `${cachePath}.tmp`; - writeFileSync(temp, JSON.stringify(index), 'utf8'); - renameSync(temp, cachePath); +/** + * The index as the last scan left it, per cache path, plus the size and mtime of the file that scan + * wrote. + * + * Every scan used to read the whole index back from disk, validate every entry, deep-clone every + * entry through JSON, then stringify and write all of it again — all synchronous, back to back, for + * every rollout on the machine, on every deck refresh and window focus. With 5,000 rollouts that is a + * 4.5 MB file and a ~80 ms stretch in which the main process answered nothing, even when not one + * rollout had changed. The index on disk is only there to survive a restart; between scans the one + * in memory is the same thing. It is still read from disk when there is none in memory yet, or when + * the file is no longer the one this process wrote (so an index replaced underneath us is honoured). + */ +interface LiveIndex { files: IndexedFile[]; written: { size: number; mtimeMs: number } | null } +const _live = new Map(); + +async function previousFiles(cachePath: string): Promise { + const live = _live.get(cachePath); + if (live?.written) { + try { + const st = await stat(cachePath); + if (st.size === live.written.size && st.mtimeMs === live.written.mtimeMs) return live.files; + } catch { /* gone: fall through to the same answer a fresh read gives */ } + } + // Stat BEFORE reading, so a write landing in between makes the next scan read again, not trust this. + const st = await stat(cachePath).catch(() => null); + const { files } = loadIndex(cachePath); + _live.set(cachePath, { files, written: st ? { size: st.size, mtimeMs: st.mtimeMs } : null }); + return files; +} + +/** Already read to the end of the file as it is now — exactly when updateFile would read nothing. */ +function caughtUp(file: IndexedFile, head: CodexRolloutHead): boolean { + return file.size === head.size && file.mtimeMs === head.mtimeMs && file.offset === head.size; +} + +/** + * Saves in progress, by path, chained so two scans finishing together never write the same temp + * file at once; the later scan's save lands last, as it did when both were synchronous. + */ +const _saving = new Map>(); + +function saveIndex(cachePath: string, index: CodexUsageIndex): Promise { + const run = async (): Promise => { + try { + await mkdir(dirname(cachePath), { recursive: true }); + const temp = `${cachePath}.tmp`; + await writeFile(temp, JSON.stringify(index), 'utf8'); + await rename(temp, cachePath); + const st = await stat(cachePath); + _live.set(cachePath, { files: index.files, written: { size: st.size, mtimeMs: st.mtimeMs } }); + } catch { + // Unwritable cache: the scan's answer stands, and the next scan reads from disk again. + _live.delete(cachePath); + } + }; + const next = (_saving.get(cachePath) ?? Promise.resolve()).then(run); + _saving.set(cachePath, next); + void next.finally(() => { if (_saving.get(cachePath) === next) _saving.delete(cachePath); }); + return next; } function freshFile(head: CodexRolloutHead): IndexedFile { @@ -297,15 +352,23 @@ function reportFromFiles(files: IndexedFile[], sinceMs: number, exists: (path: s export async function scanCodexUsage(options: ScanCodexUsageOptions): Promise { const heads = await listCodexRolloutHeads(options.sessionsDir); if (!heads.length) return emptyProviderUsage('codex'); - const previous = loadIndex(options.cachePath); - const previousByFile = new Map(previous.files.map((file) => [file.file, file])); + const previous = await previousFiles(options.cachePath); + const previousByFile = new Map(previous.map((file) => [file.file, file])); const files: IndexedFile[] = []; + let changed = previous.length !== heads.length; for (const head of heads) { const old = previousByFile.get(head.file); + if (old && resumable(old, head) && caughtUp(old, head)) { + // Nothing to read, so nothing to change: the entry is shared as-is instead of deep-cloned. + // Entries are never mutated once a scan has published them — only fresh clones are folded into. + files.push(old); + continue; + } const file = resumable(old, head) ? cloneFile(old!) : freshFile(head); await updateFile(file, head, options); files.push(file); + changed = true; } - saveIndex(options.cachePath, { version: INDEX_VERSION, files }); + if (changed) await saveIndex(options.cachePath, { version: INDEX_VERSION, files }); return reportFromFiles(files, options.sinceMs, options.exists ?? existsSync); } diff --git a/src/main/createProject.ts b/src/main/createProject.ts index 52e4946..0db1449 100644 --- a/src/main/createProject.ts +++ b/src/main/createProject.ts @@ -23,7 +23,9 @@ export interface CreateDeps { function defaultGitInit(cwd: string): boolean { try { - execFileSync('git', ['init'], { cwd, stdio: 'ignore', windowsHide: true }); + // Bounded: this runs on the main thread, and a git stalled by a network drive or an antivirus + // scan would otherwise freeze the whole app for as long as it takes. + execFileSync('git', ['init'], { cwd, stdio: 'ignore', windowsHide: true, timeout: 15_000, killSignal: 'SIGKILL' }); return true; } catch { return false; diff --git a/src/main/ipc.cockpit.test.ts b/src/main/ipc.cockpit.test.ts index 5bc4198..3ada73b 100644 --- a/src/main/ipc.cockpit.test.ts +++ b/src/main/ipc.cockpit.test.ts @@ -384,13 +384,12 @@ describe('cockpit:conversationsLastUsed', () => { // had just booted, none of them ever finished. describe('projects:list', () => { it('shares the build already under way instead of starting another', async () => { + // Each caller gets its own answer (the store entries are laid over the shared build at the end, so + // a pin changed meanwhile is not lost); that the BUILD runs once is counted in + // api/deckApi.projects.test.ts. Here: concurrent asks agree, and a later ask still answers. const list = handlers.get('projects:list')!; - const first = list(null); - const second = list(null); - expect(second).toBe(first); - await first; - const third = list(null); - expect(third).not.toBe(first); // once it has landed, the next ask builds fresh - await third; + const [first, second] = await Promise.all([list(null), list(null)]); + expect(second).toEqual(first); + expect(await list(null)).toEqual(first); }); }); diff --git a/src/main/ipc.guard.test.ts b/src/main/ipc.guard.test.ts index 4bb5519..4040077 100644 --- a/src/main/ipc.guard.test.ts +++ b/src/main/ipc.guard.test.ts @@ -115,23 +115,23 @@ describe('project store-setter path guards', () => { const outside = join(process.cwd(), 'elsewhere', 'proj'); const inside = join(ALLOWED_ROOT, 'proj'); - it('ignores writes to a path outside the allowed folders', () => { + it('ignores writes to a path outside the allowed folders', async () => { for (const s of Object.values(storeSpies)) s.mockClear(); - expect(() => handlers.get('project:setNote')!(null, outside, 'x')).toThrow('allowed folders'); + await expect(Promise.resolve().then(() => handlers.get('project:setNote')!(null, outside, 'x'))).rejects.toThrow('allowed folders'); expect(() => handlers.get('project:setTodos')!(null, outside, [])).toThrow('allowed folders'); - expect(() => handlers.get('project:setPinned')!(null, outside, true)).toThrow('allowed folders'); - expect(() => handlers.get('project:setHidden')!(null, outside, true)).toThrow('allowed folders'); + await expect(Promise.resolve().then(() => handlers.get('project:setPinned')!(null, outside, true))).rejects.toThrow('allowed folders'); + await expect(Promise.resolve().then(() => handlers.get('project:setHidden')!(null, outside, true))).rejects.toThrow('allowed folders'); expect(storeSpies.setNote).not.toHaveBeenCalled(); expect(storeSpies.setTodos).not.toHaveBeenCalled(); expect(storeSpies.setPinned).not.toHaveBeenCalled(); expect(storeSpies.setHidden).not.toHaveBeenCalled(); }); - it('allows writes to a project under an allowed folder', () => { + it('allows writes to a project under an allowed folder', async () => { for (const s of Object.values(storeSpies)) s.mockClear(); - handlers.get('project:setNote')!(null, inside, 'hi'); - handlers.get('project:setPinned')!(null, inside, true); - handlers.get('project:setHidden')!(null, inside, true); + await handlers.get('project:setNote')!(null, inside, 'hi'); + await handlers.get('project:setPinned')!(null, inside, true); + await handlers.get('project:setHidden')!(null, inside, true); expect(storeSpies.setNote).toHaveBeenCalledWith(inside, 'hi'); expect(storeSpies.setPinned).toHaveBeenCalledWith(inside, true); expect(storeSpies.setHidden).toHaveBeenCalledWith(inside, true); diff --git a/src/main/launcher.test.ts b/src/main/launcher.test.ts index ffb26e4..e25c049 100644 --- a/src/main/launcher.test.ts +++ b/src/main/launcher.test.ts @@ -71,8 +71,17 @@ describe('editorSpec', () => { expect(editorSpec('/g/a', 'darwin')).toEqual({ command: 'code', args: ['/g/a'], shell: false }); expect(editorSpec('/g/a', 'linux')).toEqual({ command: 'code', args: ['/g/a'], shell: false }); }); - it('windows: quoted path through a shell (code is code.cmd)', () => { - expect(editorSpec('C:\\g\\a', 'win32')).toEqual({ command: 'code "C:\\g\\a"', args: [], shell: true }); + it('windows: code.cmd through cmd.exe, the path escaped and passed verbatim (no shell expansion)', () => { + const spec = editorSpec('C:\\g\\a', 'win32', 'cmd.exe'); + expect(spec).toMatchObject({ command: 'cmd.exe', shell: false, verbatim: true }); + expect(spec.args.slice(0, 3)).toEqual(['/d', '/s', '/c']); + expect(spec.args[3]).toContain('code.cmd'); + expect(spec.args[3]).toContain('C:\\g\\a'); + }); + it('windows: a %VAR% or & in the folder name reaches code literally', () => { + const line = editorSpec('C:\\g\\%TEMP% & x', 'win32', 'cmd.exe').args[3]; + expect(line).not.toMatch(/(^|[^^])%TEMP/); + expect(line).not.toMatch(/(^|[^^])&/); }); }); diff --git a/src/main/launcher.ts b/src/main/launcher.ts index 251b134..7484a62 100644 --- a/src/main/launcher.ts +++ b/src/main/launcher.ts @@ -3,6 +3,7 @@ import { promisify } from 'node:util'; import { existsSync } from 'node:fs'; import { win32 as pathWin32 } from 'node:path'; import { buildWtArgs, type WtTab } from '../shared/wtArgs'; +import { cliInvocation } from './aiSummary'; import { buildMacLaunch, buildLinuxLaunch, type LaunchCmd } from '../shared/posixLaunch'; export type SpawnFn = (file: string, args: string[]) => void; @@ -139,16 +140,21 @@ function defaultSpawn(onError?: (msg: string) => void): SpawnFn { }; } -export interface EditorSpec { command: string; args: string[]; shell: boolean; } +export interface EditorSpec { command: string; args: string[]; shell: boolean; verbatim?: boolean } /** * How to launch VS Code at a directory. On POSIX `code` is a plain script — pass the * dir as an args-array entry (no shell = injection-safe). On Windows `code` is - * `code.cmd`, which Node can only run through a shell, so quote the path (Windows - * paths cannot contain `"`, so the quoting is safe). + * `code.cmd`, which only cmd.exe can run. It used to go through `shell: true` as + * `code ""`, and cmd expanded what was inside the quotes: a folder named + * `%TEMP%` opened the temp directory. The path is now escaped for cmd (twice — the + * shim forwards it through %*) and passed verbatim. */ -export function editorSpec(dir: string, platform: NodeJS.Platform = process.platform): EditorSpec { - if (platform === 'win32') return { command: `code "${dir}"`, args: [], shell: true }; +export function editorSpec(dir: string, platform: NodeJS.Platform = process.platform, comspec: string = process.env.ComSpec || 'cmd.exe'): EditorSpec { + if (platform === 'win32') { + const run = cliInvocation('code.cmd', [dir], 'win32', comspec); + return { command: run.file, args: run.args, shell: false, verbatim: true }; + } return { command: 'code', args: [dir], shell: false }; } @@ -162,7 +168,7 @@ export interface EditorOptions { export function openInEditor(dir: string, opts: EditorOptions = {}): void { const spec = editorSpec(dir, opts.platform ?? process.platform); if (opts.spawnFn) { opts.spawnFn(spec.command, spec.args, spec.shell); return; } - const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore', shell: spec.shell, windowsHide: false }); + const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore', shell: spec.shell, windowsHide: false, windowsVerbatimArguments: spec.verbatim === true }); child.on('error', (err) => { console.error('DevDeck: failed to open editor', err); opts.onError?.(`Failed to open editor (is the \`code\` CLI on your PATH?): ${(err as Error).message}`); diff --git a/src/main/projects.ts b/src/main/projects.ts index 6411805..7fc3ea7 100644 --- a/src/main/projects.ts +++ b/src/main/projects.ts @@ -12,6 +12,8 @@ export interface BuildDeps { sessions: (projectPath: string) => Promise; /** Reads the cue through the provider that OWNS the session — never the globally selected one. */ resumeCue: (projectPath: string, session: ProjectSession) => Promise; + /** How many sessions the project has in all, when it differs from the (capped) list above. */ + sessionCount?: (projectPath: string) => Promise; getEntry: (path: string) => StoreEntry; } @@ -41,16 +43,28 @@ async function mapLimit(items: T[], limit: number, fn: (item: T) => Promis return out; } -export async function buildProjectList(deps: BuildDeps): Promise { +/** The fields of a deck row that come from the user's own store entry rather than from disk and git. */ +type EntryFields = 'note' | 'pinned' | 'hidden' | 'lastOpened' | 'todos' | 'todosRevision' | 'noteRevision'; +/** A deck row before the store entry is laid over it: everything git and the session stores said. */ +export type GatheredProject = Omit; + +/** + * The expensive half of the deck list: git and the session stores, for every scanned project. + * + * Split from the store entries (below) so it can be SHARED: one refresh, a window-focus refresh on top + * of it and a paired machine polling the same deck all used to run every project's git commands and + * session reads side by side. What they share has to be the same for all of them, and the store entry + * is not — a pin toggled while a gather is running must show in the list that answers after it. + */ +export async function gatherProjects(deps: Omit): Promise { const raw = await deps.scan(); - const models = await mapLimit(raw, ENRICH_CONCURRENCY, - async (r): Promise => { + return mapLimit(raw, ENRICH_CONCURRENCY, + async (r): Promise => { const git = await deps.git(r.path); const sessions = await deps.sessions(r.path); const cueText = sessions[0] ? await deps.resumeCue(r.path, sessions[0]) : null; const lastSessionMs = sessions[0]?.mtimeMs ?? null; const activityMs = maxMs(git.lastCommitMs, lastSessionMs); - const entry = deps.getEntry(r.path); return { path: r.path, name: r.name, @@ -61,24 +75,58 @@ export async function buildProjectList(deps: BuildDeps): Promise sessions.length)) : sessions.length, agentIds: providerOrderFromSessions(sessions), activityMs, stale: classifyStaleness(activityMs, deps.nowMs, deps.thresholds), + resumeCue: cueText ? ({ kind: 'lastMessage', text: cueText } satisfies ResumeCue) : null, + repoUrl: git.repoUrl, + }; + }); +} + +/** + * Lay each project's store entry over what was gathered, as it is NOW, and sort (pins first). + * Builds new rows — the gathered ones may be shared with another caller — with the keys in the order + * the deck has always received them. + */ +export function withEntries(gathered: GatheredProject[], getEntry: BuildDeps['getEntry']): ProjectViewModel[] { + return gathered + .map((g): ProjectViewModel => { + const entry = getEntry(g.path); + return { + path: g.path, + name: g.name, + branch: g.branch, + uncommitted: g.uncommitted, + ahead: g.ahead, + lastCommitMs: g.lastCommitMs, + lastSubject: g.lastSubject, + lastSessionMs: g.lastSessionMs, + sessions: g.sessions, + sessionCount: g.sessionCount, + agentIds: g.agentIds, + activityMs: g.activityMs, + stale: g.stale, note: entry.note, pinned: entry.pinned, hidden: entry.hidden, lastOpened: entry.lastOpened, - resumeCue: cueText ? ({ kind: 'lastMessage', text: cueText } satisfies ResumeCue) : null, - repoUrl: git.repoUrl, + resumeCue: g.resumeCue, + repoUrl: g.repoUrl, todos: entry.todos, todosRevision: entry.todosRevision ?? 0, + noteRevision: entry.noteRevision ?? 0, }; - }); - - return models + }) .sort((a, b) => { if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; return (b.activityMs ?? -Infinity) - (a.activityMs ?? -Infinity); }); } + +export async function buildProjectList(deps: BuildDeps): Promise { + return withEntries(await gatherProjects(deps), deps.getEntry); +} diff --git a/src/main/sessionMeta.test.ts b/src/main/sessionMeta.test.ts index 4eaf985..84be4a2 100644 --- a/src/main/sessionMeta.test.ts +++ b/src/main/sessionMeta.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { readClaudeSessionMeta, emptySessionMeta, _cachedTimestampCount } from './sessionMeta'; import { encodeProjectPath } from '../shared/paths'; +import { activeMsFromTimestamps } from '../shared/usage'; describe('readClaudeSessionMeta', () => { it('rejects a traversal sessionId — no path escape (returns an empty meta)', async () => { @@ -113,6 +114,29 @@ describe('readClaudeSessionMeta over an append-only log', () => { expect(await read()).toMatchObject({ model: 'claude-opus-5', contextTokens: 42 }); }); + // Active time is tallied as the log is read forward instead of re-sorting every timestamp each tick. + // Whatever order lines land in — including one far back, before most of the log — + // it must equal the batch answer over the whole log. + it('keeps active time exact across resumed reads, even for a line far out of order', async () => { + const t0 = Date.UTC(2026, 7, 4, 9); + const stamps: number[] = []; + const line = (ms: number) => { stamps.push(ms); return JSON.stringify({ type: 'user', timestamp: new Date(ms).toISOString(), message: { content: 'x' } }); }; + const lines: string[] = []; + for (let i = 0; i < 1500; i++) lines.push(line(t0 + i * 170_000 - (i % 13 === 0 ? 200_000 : 0))); + writeFileSync(file, lines.join('\n') + '\n'); + let tick = Date.now(); + // Each append gets its own mtime: two landing in the same millisecond are, rightly, a cache hit. + const grow = (extra: string) => { appendFileSync(file, extra); tick += 5000; utimesSync(file, new Date(tick), new Date(tick)); }; + expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps)); + grow(line(t0 + 1500 * 170_000) + '\n'); + expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps)); + grow(line(t0 + 1500 * 170_000 - 90_000) + '\n'); // slightly late + expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps)); + grow(line(t0 + 10_000) + '\n'); // days before most of the log (a clock set back) + expect((await read()).activeMs).toBe(activeMsFromTimestamps(stamps)); + expect(_cachedTimestampCount(file)).toBe(stamps.length); + }); + it('handles a multi-byte character split across a read boundary', async () => { // 4 MiB read chunk: pad the first line past it so the next line's CJK text straddles the boundary. const pad = 'x'.repeat(4 * 1024 * 1024); diff --git a/src/main/sessionMeta.ts b/src/main/sessionMeta.ts index f453df2..6d220fe 100644 --- a/src/main/sessionMeta.ts +++ b/src/main/sessionMeta.ts @@ -7,6 +7,7 @@ import { type ParsedSessionMeta, type SessionMetaState, } from '../shared/sessionMeta'; import { isValidSessionId } from './sessions'; +import { cloneActiveTally, emptyActiveTally, tallyActiveSince, tallyStamp, type ActiveTally } from '../shared/usage'; /** Parsed meta plus the log's mtime — the cache key for the AI summary and the task-staleness reference. */ export type ClaudeSessionMeta = ParsedSessionMeta & { mtimeMs: number }; @@ -24,10 +25,18 @@ const NEWLINE = 0x0a; * Per-session parse state, resumed across reads. `offset` is the byte just past the last COMPLETE line * consumed so far, so an append-only log is only ever read forward — the cockpit's 30s tick on an * active session costs the few KB that turn wrote, not the whole log again. + * + * Active time rides in its own `tally` rather than in `state.timestamps`. The shared parser collects + * every line's timestamp and its finalize copies and sorts them all, so a tick on a live 550 MB session + * (~50,000 lines) did that — plus a copy of the whole array to resume into — for the sake of the few + * lines the turn added, every tile, every tick. Each read now moves the timestamps it parsed into the + * tally (kept in order, the sum kept as it goes) and empties the array, so a tick costs the lines it + * added plus one copy of the tally to fold into — no sort. */ -const _metaCache = new Map(); -/** Test-only: how many line timestamps the cached state for this log holds (each line counted once). */ -export function _cachedTimestampCount(file: string): number { return _metaCache.get(file)?.state.timestamps.length ?? 0; } +interface MetaCacheEntry { mtimeMs: number; offset: number; state: SessionMetaState; tally: ActiveTally; meta: ClaudeSessionMeta } +const _metaCache = new Map(); +/** Test-only: how many line timestamps the cached state for this log has counted (each line once). */ +export function _cachedTimestampCount(file: string): number { return _metaCache.get(file)?.tally.sorted.length ?? 0; } /** * Fold a log's bytes from `start` into `state`, stopping at the last complete line. @@ -54,7 +63,7 @@ export function _cachedTimestampCount(file: string): number { return _metaCache. * Reading the same bytes still takes the same time. The difference is that everything else keeps * running while it happens, which is the entire complaint. */ -async function foldFrom(handle: FileHandle, start: number, size: number, state: SessionMetaState): Promise { +async function foldFrom(handle: FileHandle, start: number, size: number, state: SessionMetaState, tally: ActiveTally): Promise { let pos = start; let carry = Buffer.alloc(0); // bytes after the last newline seen — an incomplete line while (pos < size) { @@ -67,8 +76,11 @@ async function foldFrom(handle: FileHandle, start: number, size: number, state: const nl = data.lastIndexOf(NEWLINE); if (nl < 0) { carry = Buffer.from(data); continue; } // one line longer than a chunk — keep reading advanceSessionMeta(state, data.subarray(0, nl).toString('utf8')); + // Every line timestamp this chunk produced goes into the tally; the parser's own array stays empty. + for (const ts of state.timestamps) tallyStamp(tally, ts); + state.timestamps.length = 0; carry = Buffer.from(data.subarray(nl + 1)); - // Parsing a 4 MB chunk is itself long enough to drop frames; hand the loop back between them. + // Parsing a chunk is itself long enough to drop frames; hand the loop back between them. await yieldToLoop(); } return pos - carry.length; // the trailing partial line is NOT consumed — it may still be being written @@ -104,20 +116,21 @@ async function readMetaFile(file: string): Promise { // shrank was rewritten, not appended to, and the state carried over would describe a file that is // no longer there — reparse from zero. const resume = cached && size >= cached.offset; - // A copy, so a read that fails partway leaves the cached state exactly as it was committed. + // Copies, so a read that fails partway leaves the cached state exactly as it was committed. const state = resume ? copyState(cached.state) : emptySessionMetaState(); + const tally = resume ? cloneActiveTally(cached.tally) : emptyActiveTally(); let handle: FileHandle; try { handle = await open(file, 'r'); } catch { return emptySessionMeta(); } let offset: number; try { - offset = await foldFrom(handle, resume ? cached.offset : 0, size, state); + offset = await foldFrom(handle, resume ? cached.offset : 0, size, state, tally); } catch { return emptySessionMeta(); // leave the cache alone: a later read can still resume from it } finally { await handle.close().catch(() => { /* already gone */ }); } - const meta = { ...finalizeSessionMeta(state), mtimeMs }; - _metaCache.set(file, { mtimeMs, offset, state, meta }); + const meta = { ...finalizeSessionMeta(state), activeMs: tallyActiveSince(tally, -Infinity), mtimeMs }; + _metaCache.set(file, { mtimeMs, offset, state, tally, meta }); return meta; } diff --git a/src/main/sessionScan.ts b/src/main/sessionScan.ts index 1f2b669..18c518b 100644 --- a/src/main/sessionScan.ts +++ b/src/main/sessionScan.ts @@ -31,11 +31,15 @@ export interface SessionScanDeps { perProject: Partial Promise>>; /** Flat stores (Codex, Antigravity): indexed once, then looked up by canonical cwd key. */ indexed: Partial Map | Promise>>>; + /** Per-project stores: how many sessions there are, without reading any of them. */ + perProjectCount?: Partial Promise>>; } export interface ProjectSessionScan { /** Sessions for one project from EVERY installed provider, newest-first, each tagged with its owner. */ sessions(projectPath: string, limit?: number): Promise; + /** How many sessions the project has across every installed provider. */ + count(projectPath: string): Promise; } /** @@ -71,5 +75,15 @@ export function makeProjectSessionScan(deps: SessionScanDeps): ProjectSessionSca } return mergeProjectSessions(byProvider, limit); }, + async count(projectPath) { + const key = cwdKey(projectPath); + let total = 0; + for (const agentId of deps.installed) { + const counter = deps.perProjectCount?.[agentId]; + if (counter) { try { total += await counter(projectPath); } catch { /* skip this provider */ } } + else if (deps.indexed[agentId]) total += (await indexFor(agentId)).get(key)?.length ?? 0; + } + return total; + }, }; } diff --git a/src/main/sessions.test.ts b/src/main/sessions.test.ts index b812081..b84cc1b 100644 --- a/src/main/sessions.test.ts +++ b/src/main/sessions.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs'; +import { appendFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { listSessions, lastUserMessageForSession } from './sessions'; +import { open } from 'node:fs/promises'; +import { listSessions, lastUserMessageForSession, listSessionIds, listSessionStats, findInTail } from './sessions'; +import { lastUserMessage } from '../shared/sessionParse'; let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'devdeck-sess-')); }); @@ -98,4 +100,90 @@ describe('lastUserMessageForSession', async () => { writeFileSync(join(d, `${id}.jsonl`), lines.join('\n')); expect(await lastUserMessageForSession('C:\\g\\big', id, root)).toBe(target); }); + + // The cue is cached per file on (size, mtime) — the deck asks for it every refresh — so it has to + // move the moment the session does. + it('answers again after the session is appended to', async () => { + const d = join(root, 'C--g-grow'); + mkdirSync(d, { recursive: true }); + const id = 'a0b1c2d3-e4f5-6789-abcd-ef0123456789'; + const file = join(d, `${id}.jsonl`); + writeFileSync(file, userLine('first ask') + '\n'); + expect(await lastUserMessageForSession('C:\\g\\grow', id, root)).toBe('first ask'); + expect(await lastUserMessageForSession('C:\\g\\grow', id, root)).toBe('first ask'); + appendFileSync(file, userLine('second ask') + '\n'); + expect(await lastUserMessageForSession('C:\\g\\grow', id, root)).toBe('second ask'); + }); +}); + +describe('findInTail', () => { + /** The loop findInTail replaced: re-decode and re-search the whole accumulated tail on every read. */ + async function reference(file: string, maxBytes: number, chunk: number): Promise { + const handle = await open(file, 'r'); + try { + const size = (await handle.stat()).size; + let pos = size; + let acc = Buffer.alloc(0); + while (pos > 0 && size - pos < maxBytes) { + const len = Math.min(chunk, pos); + pos -= len; + const buf = Buffer.alloc(len); + await handle.read(buf, 0, len, pos); + acc = Buffer.concat([buf, acc]); + const text = acc.toString('utf8'); + const nl = text.indexOf('\n'); + const body = pos === 0 ? text : nl < 0 ? '' : text.slice(nl + 1); + const found = lastUserMessage(body); + if (found) return found; + } + return null; + } finally { await handle.close(); } + } + + it('gives the same answer as re-searching the whole tail, for any chunk size and cap', async () => { + let seed = 1; + const rnd = (): number => (seed = (seed * 16807) % 2147483647) / 2147483647; + const assistant = (n: number) => JSON.stringify({ type: 'assistant', message: { content: '가나다 abc '.repeat(n) } }); + for (let trial = 0; trial < 40; trial++) { + const lines: string[] = []; + const count = 1 + Math.floor(rnd() * 30); + for (let i = 0; i < count; i++) { + lines.push(rnd() < 0.15 ? userLine(`ask ${trial}-${i} 한글`) : assistant(Math.floor(rnd() * 60))); + } + const file = join(root, `t${trial}.jsonl`); + writeFileSync(file, lines.join('\n') + (rnd() < 0.5 ? '\n' : '')); + for (const chunk of [7, 64, 333, 4096]) { + for (const maxBytes of [50, 1000, 1 << 30]) { + const handle = await open(file, 'r'); + try { + const size = (await handle.stat()).size; + const got = await findInTail(handle, size, maxBytes, chunk, lastUserMessage); + expect(got, `trial ${trial} chunk ${chunk} cap ${maxBytes}`).toBe(await reference(file, maxBytes, chunk)); + } finally { await handle.close(); } + } + } + } + }); +}); + +describe('listSessionIds / listSessionStats', () => { + it('lists valid session files newest-first with their stats, and nothing for a missing dir', async () => { + const d = join(root, 'C--g-stats'); + mkdirSync(d, { recursive: true }); + const a = 'a0b1c2d3-e4f5-6789-abcd-ef0123456780'; + const b = 'a0b1c2d3-e4f5-6789-abcd-ef0123456781'; + writeFileSync(join(d, `${a}.jsonl`), userLine('a')); + writeFileSync(join(d, `${b}.jsonl`), userLine('b')); + writeFileSync(join(d, '$(evil).jsonl'), userLine('bad')); + writeFileSync(join(d, `${a}.txt`), 'not a session'); + utimesSync(join(d, `${a}.jsonl`), 1000, 1000); + utimesSync(join(d, `${b}.jsonl`), 2000, 2000); + expect(await listSessionIds('C:\\g\\stats', root)).toEqual([b, a]); + const stats = await listSessionStats('C:\\g\\stats', root); + expect(stats.map((s) => s.id).sort()).toEqual([a, b]); + expect(stats.find((s) => s.id === a)).toMatchObject({ mtimeMs: 1_000_000 }); + expect(stats[0].birthtimeMs).toBeGreaterThan(0); + expect(await listSessionIds('C:\\g\\none', root)).toEqual([]); + expect(await listSessionStats('C:\\g\\none', root)).toEqual([]); + }); }); diff --git a/src/main/sessions.ts b/src/main/sessions.ts index a23411c..90a4c09 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -1,5 +1,5 @@ -import { readdirSync, statSync, existsSync } from 'node:fs'; -import { readdir, stat, open, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { readdir, stat, open, readFile, type FileHandle } from 'node:fs/promises'; import { join } from 'node:path'; import { encodeProjectPath, SESSION_ID_RE, isValidSessionId } from '../shared/paths'; import { firstUserMessage, lastUserMessage } from '../shared/sessionParse'; @@ -69,63 +69,67 @@ export async function listSessions( return top; } +interface SessionFileStat { id: string; mtimeMs: number; birthtimeMs: number } + /** - * ALL of a project's on-disk session ids, mtime-desc — cheap (no head-read for first messages, no - * limit), for the cockpit restore resolver which must recognize an older-but-valid saved id as still - * existing (listSessions caps at 5, which would hide it and force a wrong newest fallback). + * The project's session files with their stat, in directory order. + * + * Async on purpose. This runs on the cockpit's slow tick for EVERY open tile (drift detection) and for + * the restore list's existence check, and the old readdirSync + statSync-per-file form held the main + * thread for the whole listing each time — ~1.5 ms for a 150-session project on this machine, times + * eleven tiles, every tick, and much worse on a slow or cloud-synced disk. The stats now go out + * together, so the wall time is about one round-trip rather than one per file. */ -export function listSessionIds(projectPath: string, claudeProjectsDir: string): string[] { +async function sessionFileStats(projectPath: string, claudeProjectsDir: string): Promise { const dir = join(claudeProjectsDir, encodeProjectPath(projectPath)); - if (!existsSync(dir)) return []; let names: string[]; try { - names = readdirSync(dir); + names = await readdir(dir); } catch { - return []; + return []; // no session dir for this project (or unreadable) — what the old existsSync guard answered } - const rows: { id: string; mtimeMs: number }[] = []; - for (const name of names) { - if (!name.endsWith('.jsonl')) continue; - const id = name.slice(0, -'.jsonl'.length); - if (!SESSION_ID_RE.test(id)) continue; + const ids = names + .filter((name) => name.endsWith('.jsonl')) + .map((name) => name.slice(0, -'.jsonl'.length)) + .filter((id) => SESSION_ID_RE.test(id)); + const rows = await Promise.all(ids.map(async (id): Promise => { try { - rows.push({ id, mtimeMs: statSync(join(dir, name)).mtimeMs }); + const st = await stat(join(dir, id + '.jsonl')); + return { id, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }; } catch { - continue; + return null; // vanished between the listing and the stat } - } - rows.sort((a, b) => b.mtimeMs - a.mtimeMs); + })); + return rows.filter((row): row is SessionFileStat => row !== null); +} + +/** + * ALL of a project's on-disk session ids, mtime-desc — cheap (no head-read for first messages, no + * limit), for the cockpit restore resolver which must recognize an older-but-valid saved id as still + * existing (listSessions caps at 5, which would hide it and force a wrong newest fallback). + */ +/** How many sessions a project has: one directory listing, no stat and no reads. */ +export async function countSessionFiles(projectPath: string, claudeProjectsDir: string): Promise { + try { + const names = await readdir(join(claudeProjectsDir, encodeProjectPath(projectPath))); + return names.filter((name) => name.endsWith('.jsonl') && SESSION_ID_RE.test(name.slice(0, -'.jsonl'.length))).length; + } catch { return 0; } +} + +export async function listSessionIds(projectPath: string, claudeProjectsDir: string): Promise { + const rows = await sessionFileStats(projectPath, claudeProjectsDir); + rows.sort((a, b) => b.mtimeMs - a.mtimeMs); // stable: equal mtimes keep directory order, as before return rows.map((r) => r.id); } /** * Every on-disk session's id + mtime + birthtime for one project — the live drift detector's input * (pickDriftedSessionId): birthtime tells "was this file born after the tile opened" (/clear creates - * a brand-new file), mtime tells "was it written since the last check". Sync like listSessionIds - * (single project dir, called per live session on a slow tick). + * a brand-new file), mtime tells "was it written since the last check". Deliberately not cached: + * "written since the last check" is the whole question, and an answer from a moment ago is wrong. */ -export function listSessionStats(projectPath: string, claudeProjectsDir: string): { id: string; mtimeMs: number; birthtimeMs: number }[] { - const dir = join(claudeProjectsDir, encodeProjectPath(projectPath)); - if (!existsSync(dir)) return []; - let names: string[]; - try { - names = readdirSync(dir); - } catch { - return []; - } - const rows: { id: string; mtimeMs: number; birthtimeMs: number }[] = []; - for (const name of names) { - if (!name.endsWith('.jsonl')) continue; - const id = name.slice(0, -'.jsonl'.length); - if (!SESSION_ID_RE.test(id)) continue; - try { - const st = statSync(join(dir, name)); - rows.push({ id, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }); - } catch { - continue; - } - } - return rows; +export function listSessionStats(projectPath: string, claudeProjectsDir: string): Promise { + return sessionFileStats(projectPath, claudeProjectsDir); } /** @@ -140,31 +144,76 @@ export async function lastUserMessageForSession( ): Promise { if (!SESSION_ID_RE.test(sessionId)) return null; const file = join(claudeProjectsDir, encodeProjectPath(projectPath), sessionId + '.jsonl'); - let handle; - try { - handle = await open(file, 'r'); - } catch { - return null; + return cachedTailAnswer(file, (handle, size) => findInTail(handle, size, TAIL_MAX, TAIL_CHUNK, lastUserMessage)); +} + +/** + * Search a file's tail backward for the LAST line `find` recognizes, `chunkBytes` per read, at most + * `maxBytes` from the end. `find` gets a run of complete lines and answers with the last match in it. + * + * Each read hands `find` ONLY the lines that read completed. The loop this replaces re-decoded and + * re-parsed the whole accumulated tail on every step — 1 MB, then 2 MB, … then 8 MB: ~36 MB of + * decoding and JSON.parse to search 8 MB — and the Codex copy of it did that with readSync, holding + * the main thread ~45 ms per call on a rollout whose last prompt was far back. Searching only the new + * lines gives the same answer: the lines already searched held no match (or we would have returned), + * so the last match overall is the last match among the new ones. + * + * `carry` is the leading partial line, whose start is in bytes not read yet; it rides along until the + * read that reaches its beginning. Cutting at a newline BYTE never splits a multi-byte character. + */ +export async function findInTail( + handle: FileHandle, size: number, maxBytes: number, chunkBytes: number, + find: (completeLines: string) => string | null, +): Promise { + let pos = size; + let carry = Buffer.alloc(0); + while (pos > 0 && size - pos < maxBytes) { + const length = Math.min(chunkBytes, pos); + pos -= length; + const chunk = Buffer.alloc(length); + const { bytesRead } = await handle.read(chunk, 0, length, pos); + const read = chunk.subarray(0, bytesRead); + const data = carry.length ? Buffer.concat([read, carry]) : read; + if (pos === 0) return find(data.toString('utf8')); // reached the start: the first line is whole too + const newline = data.indexOf(0x0a); + if (newline < 0) { carry = data; continue; } // one line longer than everything read so far + const found = find(data.toString('utf8', newline + 1)); + if (found) return found; + carry = data.subarray(0, newline + 1); } + return null; +} + +/** + * Answers already found, by file. The deck asks for every project's resume cue on each refresh — every + * ~45 s and on every window focus — and a session nobody is typing in can only give the answer it gave + * last time. Keyed on size AND mtime: an append changes both. + */ +const _tailAnswers = new Map(); +/** One entry per session ever asked about — a handful per project. This only bounds a pathological case. */ +const TAIL_ANSWERS_MAX = 2000; +/** Test-only: forget cached tail answers. */ +export function _clearTailAnswerCache(): void { _tailAnswers.clear(); } + +/** + * `read` the file, or answer from the cache when it has not changed since the last read. Shared by + * the Claude and Codex resume-cue readers, which differ only in how a user line is recognized. + */ +export async function cachedTailAnswer(file: string, read: (handle: FileHandle, size: number) => Promise): Promise { + let size: number; + let mtimeMs: number; + try { ({ size, mtimeMs } = await stat(file)); } catch { return null; } + const hit = _tailAnswers.get(file); + if (hit && hit.size === size && hit.mtimeMs === mtimeMs) return hit.value; + let handle: FileHandle; + try { handle = await open(file, 'r'); } catch { return null; } try { - const size = (await handle.stat()).size; - let pos = size; - let acc = Buffer.alloc(0); - while (pos > 0 && size - pos < TAIL_MAX) { - const readLen = Math.min(TAIL_CHUNK, pos); - pos -= readLen; - const buf = Buffer.alloc(readLen); - await handle.read(buf, 0, readLen, pos); - acc = Buffer.concat([buf, acc]); // contiguous byte range [pos, size) — safe to decode whole - const text = acc.toString('utf8'); - // Drop the leading partial line until we've read the very start of the file. - const body = pos === 0 ? text : text.slice(text.indexOf('\n') + 1); - const found = lastUserMessage(body); - if (found) return found; - } - return null; + const value = await read(handle, size); + if (_tailAnswers.size >= TAIL_ANSWERS_MAX) _tailAnswers.clear(); + _tailAnswers.set(file, { size, mtimeMs, value }); + return value; } catch { - return null; + return null; // not remembered: a failed read says nothing about the file } finally { await handle.close().catch(() => { /* already gone */ }); } diff --git a/src/main/store.test.ts b/src/main/store.test.ts index c822e36..709d304 100644 --- a/src/main/store.test.ts +++ b/src/main/store.test.ts @@ -43,10 +43,22 @@ describe('Store', () => { it('returns a default entry for an unknown project', () => { const s = new Store(file); expect(s.get('C:\\g\\x')).toEqual({ - note: '', pinned: false, hidden: false, lastOpened: null, todos: [], todosRevision: 0, + note: '', pinned: false, hidden: false, lastOpened: null, todos: [], todosRevision: 0, noteRevision: 0, }); }); + it('saves a note only against the revision it was read at', () => { + const s = new Store(file); + expect(s.saveNote('note-proj', 'first', 0)).toEqual({ ok: true, note: 'first', revision: 1 }); + // Another device saved in the meantime: the stale save is refused and told what is there now. + expect(s.saveNote('note-proj', 'mine', 0)).toEqual({ ok: false, note: 'first', revision: 1 }); + expect(s.get('note-proj').note).toBe('first'); + // An older device's unversioned write still moves the revision, so a versioned saver notices it. + s.setNote('note-proj', 'legacy'); + expect(s.saveNote('note-proj', 'mine', 1)).toMatchObject({ ok: false, note: 'legacy', revision: 2 }); + expect(s.saveNote('note-proj', 'mine', 2)).toEqual({ ok: true, note: 'mine', revision: 3 }); + }); + it('persists todos across instances and sanitizes junk on read', () => { const s1 = new Store(file); const good = { id: 'a', text: 'ship v2', done: false, due: '2026-07-04', createdAt: '2026-07-01T00:00:00Z' }; diff --git a/src/main/store.ts b/src/main/store.ts index c307467..604439f 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, existsSync, renameSync, copyFileSync } fro import { resolve } from 'node:path'; import { randomUUID } from 'node:crypto'; import { hostname } from 'node:os'; -import type { StoreEntry, Folder } from '../shared/types'; +import type { StoreEntry, Folder, NoteSaveResult } from '../shared/types'; import { sanitizePersistedList, type PersistedSession } from '../shared/cockpitPersist'; import { sanitizeTodos, type Todo, type TodoSaveResult } from '../shared/tasks'; import { sanitizeWindowBounds, type WindowBounds } from '../shared/windowBounds'; @@ -114,7 +114,8 @@ export class Store { get(path: string): StoreEntry { const e = { ...EMPTY, ...this.state.projects[path] }; - return { ...e, todos: sanitizeTodos(e.todos), todosRevision: Number.isSafeInteger(e.todosRevision) && e.todosRevision! >= 0 ? e.todosRevision : 0 }; // never hand out unvalidated on-disk todos + const rev = (v: unknown): number => (Number.isSafeInteger(v) && (v as number) >= 0 ? v as number : 0); + return { ...e, todos: sanitizeTodos(e.todos), todosRevision: rev(e.todosRevision), noteRevision: rev(e.noteRevision) }; // never hand out unvalidated on-disk todos } private mutate(path: string, patch: Partial): void { @@ -297,7 +298,26 @@ export class Store { } - setNote(path: string, note: string): void { this.mutate(path, { note }); } + /** The unversioned write older paired machines still send; it moves the revision so newer savers notice. */ + setNote(path: string, note: string): void { + const current = this.get(path); + if (current.note === note) return; + this.mutate(path, { note, noteRevision: Math.min((current.noteRevision ?? 0) + 1, Number.MAX_SAFE_INTEGER) }); + } + /** + * Save a note only if nobody else has saved one since the caller read it. Two devices editing the + * same project's note used to be last-write-wins: the second blur silently erased the first note. + */ + saveNote(path: string, note: string, expectedRevision: number): NoteSaveResult { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new Error('NOTE_INVALID_REVISION'); + const current = this.get(path); + const revision = current.noteRevision ?? 0; + if (revision !== expectedRevision) return { ok: false, note: current.note, revision }; + if (note === current.note) return { ok: true, note, revision }; + if (revision >= Number.MAX_SAFE_INTEGER) throw new Error('NOTE_REVISION_EXHAUSTED'); + this.mutate(path, { note, noteRevision: revision + 1 }); + return { ok: true, note, revision: revision + 1 }; + } getTodos(path: string): Todo[] { return this.get(path).todos; } /** Compare and persist without yielding: local and Link callers share this Store. */ saveTodos(path: string, todos: unknown, expectedRevision: number): TodoSaveResult { diff --git a/src/main/tray.ts b/src/main/tray.ts index eba9d7d..3f19b25 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,6 +1,6 @@ import { app, Tray, Menu, nativeImage, type BrowserWindow, type NativeImage, type MenuItemConstructorOptions } from 'electron'; import { join } from 'node:path'; -import { trayState, shutdownMenuShape, type TrayAlertMode, type TrayCounts } from '../shared/trayState'; +import { trayState, shutdownMenuShape, DEFAULT_TRAY_LABELS, type TrayAlertMode, type TrayCounts, type TrayLabels } from '../shared/trayState'; import type { ShutdownPhase } from '../shared/shutdownIdle'; export type { TrayAlertMode } from '../shared/trayState'; @@ -11,6 +11,8 @@ export interface TrayController { applyCounts(counts: TrayCounts, mode: TrayAlertMode): void; /** Re-render the context menu for the idle-shutdown phase (checkbox / cancel item). */ setShutdownPhase(phase: ShutdownPhase): void; + /** Words for the menu and tooltip, in the language the window is showing. */ + setLabels(labels: TrayLabels): void; } export interface TrayShutdownHooks { toggle(): void; now(): void; cancel(): void } @@ -22,18 +24,20 @@ export function setupTray(win: BrowserWindow, shutdownHooks?: TrayShutdownHooks) tray.setToolTip('DevDeck'); let shutdownPhase: ShutdownPhase | null = shutdownHooks ? 'disarmed' : null; + let labels: TrayLabels = DEFAULT_TRAY_LABELS; + let last: { counts: TrayCounts; mode: TrayAlertMode } | null = null; const rebuildMenu = (): void => { const items: MenuItemConstructorOptions[] = [ - { label: 'Open DevDeck', click: () => { win.show(); win.focus(); } }, + { label: labels.open, click: () => { win.show(); win.focus(); } }, ]; const shape = shutdownMenuShape(shutdownPhase); if (shape.length) items.push({ type: 'separator' }); for (const it of shape) { - if (it.key === 'toggle') items.push({ label: 'Shut down PC when idle', type: 'checkbox', checked: it.checked, click: () => shutdownHooks?.toggle() }); - else if (it.key === 'now') items.push({ label: 'Shut down PC now', click: () => shutdownHooks?.now() }); - else items.push({ label: 'Cancel shutdown', click: () => shutdownHooks?.cancel() }); + if (it.key === 'toggle') items.push({ label: labels.shutdownIdle, type: 'checkbox', checked: it.checked, click: () => shutdownHooks?.toggle() }); + else if (it.key === 'now') items.push({ label: labels.shutdownNow, click: () => shutdownHooks?.now() }); + else items.push({ label: labels.cancelShutdown, click: () => shutdownHooks?.cancel() }); } - items.push({ type: 'separator' }, { label: 'Quit', click: () => { (app as unknown as { isQuitting?: boolean }).isQuitting = true; app.quit(); } }); + items.push({ type: 'separator' }, { label: labels.quit, click: () => { (app as unknown as { isQuitting?: boolean }).isQuitting = true; app.quit(); } }); tray.setContextMenu(Menu.buildFromTemplate(items)); }; rebuildMenu(); @@ -52,10 +56,16 @@ export function setupTray(win: BrowserWindow, shutdownHooks?: TrayShutdownHooks) try { const img = nativeImage.createFromDataURL(dataUrl); if (!img.isEmpty()) alertIcon = img; } catch { /* ignore */ } }, applyCounts(counts, mode): void { - const s = trayState(counts, mode); + last = { counts, mode }; + const s = trayState(counts, mode, labels); tray.setImage(s.red > 0 && alertIcon ? alertIcon : normalIcon); tray.setToolTip(s.tooltip); }, + setLabels(next): void { + labels = { ...DEFAULT_TRAY_LABELS, ...next }; + rebuildMenu(); + if (last) { const s = trayState(last.counts, last.mode, labels); tray.setToolTip(s.tooltip); } + }, setShutdownPhase(phase): void { if (!shutdownHooks || phase === shutdownPhase) return; shutdownPhase = phase; diff --git a/src/main/usageScan.test.ts b/src/main/usageScan.test.ts index 3cc44ed..ef404f8 100644 --- a/src/main/usageScan.test.ts +++ b/src/main/usageScan.test.ts @@ -192,13 +192,13 @@ describe('scanUsage', () => { const d = join(root, `C--g-${proj}`); mkdirSync(d, { recursive: true }); const f = join(d, 's.jsonl'); - // ~150 unique-timestamp lines ⇒ a ~2.6KB DIGEST (stamps dominate) — the shrunk budget fits two. + // ~150 unique-timestamp lines ⇒ a ~1.6KB DIGEST (its retained timestamps dominate) — the shrunk budget fits two. const lines = Array.from({ length: 150 }, (_v, i) => asst('claude-opus-4-8', { input_tokens: 1 }, `2026-06-01T10:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}.000Z`)); writeFileSync(f, lines.join('\n')); return f; }; - _setCacheBudget(6 * 1024); // room for two ~2.6KB digests, not three + _setCacheBudget(4 * 1024); // room for two ~1.6KB digests, not three const [fa, fb, fc] = [mk('lru-a'), mk('lru-b'), mk('lru-c')]; await scanUsage([{ path: 'C:\\g\\lru-a', name: 'a' }], root, Infinity); await scanUsage([{ path: 'C:\\g\\lru-b', name: 'b' }], root, Infinity); @@ -213,13 +213,13 @@ describe('scanUsage', () => { const d = join(root, `C--g-${proj}`); mkdirSync(d, { recursive: true }); const f = join(d, 's.jsonl'); - // ~150 unique-timestamp lines ⇒ a ~2.6KB DIGEST (stamps dominate) — the shrunk budget fits two. + // ~150 unique-timestamp lines ⇒ a ~1.6KB DIGEST (its retained timestamps dominate) — the shrunk budget fits two. const lines = Array.from({ length: 150 }, (_v, i) => asst('claude-opus-4-8', { input_tokens: 1 }, `2026-06-01T10:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}.000Z`)); writeFileSync(f, lines.join('\n')); return f; }; - _setCacheBudget(6 * 1024); + _setCacheBudget(4 * 1024); const [fa, fb, fc] = [mk('fifo-a'), mk('fifo-b'), mk('fifo-c')]; await scanUsage([{ path: 'C:\\g\\fifo-a', name: 'a' }], root, Infinity); await scanUsage([{ path: 'C:\\g\\fifo-b', name: 'b' }], root, Infinity); @@ -288,4 +288,35 @@ describe('scanUsage', () => { expect(jun.global.input).toBe(7); expect(jun.daily.map((b) => b.day)).toEqual(['2026-06-01']); }); + + // Active time is no longer recomputed from every timestamp on every scan; it is kept as a tally that + // a resumed fold adds to. Whatever order lines arrive in, the answer must be what a fresh parse of + // the whole file gives — including a line that lands far back, before most of what was counted. + it('keeps active time and session counts exact across resumes, even for a line far out of order', async () => { + const d = join(root, 'C--g-tally'); + mkdirSync(d, { recursive: true }); + const f = join(d, 's.jsonl'); + const at = (ms: number) => new Date(ms).toISOString(); + const t0 = Date.UTC(2026, 5, 1, 9); + const line = (ms: number) => JSON.stringify({ type: 'user', timestamp: at(ms), message: { content: 'x' } }); + // 1,500 lines a minute or so apart over three days, a few slightly out of order. + const body: string[] = []; + for (let i = 0; i < 1500; i++) body.push(line(t0 + i * 170_000 - (i % 17 === 0 ? 240_000 : 0))); + writeFileSync(f, body.join('\n') + '\n'); + const repo = [{ path: 'C:\\g\\tally', name: 'tally' }]; + const ranges = [Infinity, Date.UTC(2026, 5, 2), Date.UTC(2026, 5, 4)]; + const fresh = async () => { _clearFileCache(); return Promise.all(ranges.map((s) => scanUsage(repo, root, s))); }; + const resumed = () => Promise.all(ranges.map((s) => scanUsage(repo, root, s))); + const pick = (rs: Awaited>) => rs.map((r) => [r.activeMs, r.sessions]); + + await resumed(); + appendFileSync(f, line(t0 + 1500 * 170_000) + '\n'); // in order + expect(pick(await resumed())).toEqual(pick(await fresh())); + appendFileSync(f, line(t0 + 1500 * 170_000 - 120_000) + '\n'); // a little late + expect(pick(await resumed())).toEqual(pick(await fresh())); + appendFileSync(f, line(t0 + 30_000) + '\n'); // days before most of the log (a clock set back) + const after = await resumed(); + expect(pick(after)).toEqual(pick(await fresh())); + expect(after[0].activeMs).toBeGreaterThan(0); + }); }); diff --git a/src/main/usageScan.ts b/src/main/usageScan.ts index 0d0edb6..cc44bdd 100644 --- a/src/main/usageScan.ts +++ b/src/main/usageScan.ts @@ -6,23 +6,32 @@ import { encodeProjectPath } from '../shared/paths'; /** ASCII line feed — transcripts are newline-delimited JSON. */ const NEWLINE = 0x0a; -import { emptyTotals, addUsage, addTotals, estimateCost, activeMsFromTimestamps, priceFor, SYNTHETIC_MODEL, type UsageTotals, type RawUsage } from '../shared/usage'; +import { + emptyTotals, addUsage, addTotals, estimateCost, priceFor, SYNTHETIC_MODEL, + emptyActiveTally, cloneActiveTally, tallyStamp, tallyActiveSince, tallyHasStampSince, + type UsageTotals, type RawUsage, type ActiveTally, +} from '../shared/usage'; import type { LocalDailyUsage, LocalModelUsage, LocalProjectUsage, ProviderUsageSlice } from '../shared/localUsage'; -// Cache: filepath -> per-file DIGEST — (day × model) usage rollups + message timestamps — NOT the raw +// Cache: filepath -> per-file DIGEST — (day × model) usage rollups + an active-time tally — NOT the raw // text. History: caching every file's full text forever ballooned the main process to several GB and // crashed it (V8 OOM, v1.12.2); the follow-up "don't retain files over 5MB" fix then aged badly as the // user's transcripts grew — 56 files totalling ~2GB fell over that cap and were RE-READ AND RE-PARSED // from disk on every deck refresh (~45s), which is exactly the "everything got slower" complaint. A -// digest is a few KB even for a multi-hundred-MB transcript (it scales with turn count, not bytes), so -// EVERY file can stay cached: an unchanged mtime costs one stat() and zero parsing, for any size. -// sinceMs filtering happens at day granularity, so filtering the digest gives identical results. +// digest is a few KB even for a multi-hundred-MB transcript, so EVERY file can stay cached: an +// unchanged mtime costs one stat() and zero parsing, for any size. sinceMs filtering happens at day +// granularity, so filtering the digest gives identical results. +// +// (Size scales with line count: the active-time tally keeps one number per timestamped line — ~8 +// bytes, ~400 KB for a 550 MB transcript. What it no longer does is what every scan used to: copy and +// sort ALL of those timestamps, for every transcript on the machine, changed or not, on every refresh +// — the tally keeps them in order and keeps the sums per day, so a scan only reads them; see +// ActiveTally. The parallel day-per-stamp array is gone too, halving it.) interface DigestEntry { dayMs: number; day: string; model: string; totals: UsageTotals; webSearch: number; webFetch: number; unknown: boolean } interface FileDigest { mtimeMs: number; entries: DigestEntry[]; // (day × model) rollups, synthetic lines already excluded - stamps: number[]; // every line's timestamp (user + assistant + tool) for active-time gaps - stampDayMs: number[]; // parallel to stamps: that line's UTC day start, for the sinceMs day filter + active: ActiveTally; // every line's timestamp (user + assistant + tool), as active time per day bytes: number; // estimated in-memory size, for the total-cache budget /** * How far into the file this digest has read, and what the file looked like then. @@ -47,8 +56,9 @@ let _cacheBudget = MAX_CACHE_TOTAL_BYTES; let _cacheBytes = 0; const _fileCache = new Map(); // Map iteration order = insertion order = LRU order -function digestBytes(entries: number, stamps: number): number { - return 200 + entries * 160 + stamps * 16; // rough JS-object overhead estimate — only the budget uses it +function digestBytes(entries: number, active: ActiveTally): number { + // rough JS-object overhead estimate — only the budget uses it + return 200 + entries * 160 + active.sorted.length * 8 + active.byDay.size * 40; } function cacheDelete(path: string): void { const e = _fileCache.get(path); @@ -96,7 +106,8 @@ function sumModelCost(byModel: Map): number | null { /** A digest holding nothing, ready to be folded into. */ function emptyDigest(mtimeMs: number, size: number, birthtimeMs: number): FileDigest { - return { mtimeMs, entries: [], stamps: [], stampDayMs: [], bytes: digestBytes(0, 0), offset: 0, size, birthtimeMs }; + const active = emptyActiveTally(); + return { mtimeMs, entries: [], active, bytes: digestBytes(0, active), offset: 0, size, birthtimeMs }; } /** @@ -136,17 +147,18 @@ function resumable(previous: FileDigest | undefined, size: number, birthtimeMs: /** Fold ONE transcript line into a rollup. The only place a raw record is interpreted. */ function foldLine( line: string, fileMs: number, - rollup: Map, stamps: number[], stampDayMs: number[], + rollup: Map, active: ActiveTally, ): void { if (!line.trim()) return; let o: { type?: string; timestamp?: string; message?: { model?: string; usage?: RawUsage & { server_tool_use?: { web_search_requests?: number; web_fetch_requests?: number } } } }; try { o = JSON.parse(line); } catch { return; } const day = dayKey(o.timestamp, fileMs); const dayMs = new Date(day + 'T00:00:00.000Z').getTime(); - // Collect every line's timestamp (user + assistant + tool) so gaps reflect real wall-clock activity. + // Count every line's timestamp (user + assistant + tool) so gaps reflect real wall-clock activity. + // Its tally day is the UTC day of the same instant — the day `dayMs` names for that line. if (o.timestamp) { const ms = new Date(o.timestamp).getTime(); - if (!Number.isNaN(ms)) { stamps.push(ms); stampDayMs.push(dayMs); } + if (!Number.isNaN(ms)) tallyStamp(active, ms); } const u = o.message?.usage; if (o.type !== 'assistant' || !u) return; @@ -175,15 +187,15 @@ async function foldDigest( // the cache until restart (200,100 tokens became 400,100). A read that fails halfway likewise left // its partial sums behind. With copies, the cache only changes when a whole fold is committed. const rollup = new Map(base.entries.map((e) => [e.day + ' ' + e.model, cloneEntry(e)])); - const stamps = [...base.stamps]; - const stampDayMs = [...base.stampDayMs]; let offset = resume ? base.offset : 0; if (offset >= size) { const settled = { ...base, mtimeMs, size, birthtimeMs, offset }; return { durable: settled, view: settled }; } + // Copied only here, past the early return: a file with nothing new to read costs no copy at all. + const active = cloneActiveTally(base.active); - const consume = (line: string): void => foldLine(line, fileMs, rollup, stamps, stampDayMs); + const consume = (line: string): void => foldLine(line, fileMs, rollup, active); let trailing = Buffer.alloc(0); @@ -206,23 +218,22 @@ async function foldDigest( } const entries = [...rollup.values()]; const durable: FileDigest = { - mtimeMs, entries, stamps, stampDayMs, - bytes: digestBytes(entries.length, stamps.length), offset, size, birthtimeMs, + mtimeMs, entries, active, + bytes: digestBytes(entries.length, active), offset, size, birthtimeMs, }; if (trailing.length === 0) return { durable, view: durable }; // The file does not end in a newline. Fold that last line into a SEPARATE view so the numbers // include it, and leave `durable` short of it: the next fold resumes at the boundary and reads // that line again, whole, whether it grew in the meantime or turned out to be all there was. const viewRollup = new Map(entries.map((e) => [e.day + ' ' + e.model, cloneEntry(e)])); - const viewStamps = [...stamps]; - const viewStampDayMs = [...stampDayMs]; - foldLine(trailing.toString('utf8'), fileMs, viewRollup, viewStamps, viewStampDayMs); + const viewActive = cloneActiveTally(active); + foldLine(trailing.toString('utf8'), fileMs, viewRollup, viewActive); const viewEntries = [...viewRollup.values()]; return { durable, view: { - mtimeMs, entries: viewEntries, stamps: viewStamps, stampDayMs: viewStampDayMs, - bytes: digestBytes(viewEntries.length, viewStamps.length), offset, size, birthtimeMs, + mtimeMs, entries: viewEntries, active: viewActive, + bytes: digestBytes(viewEntries.length, viewActive), offset, size, birthtimeMs, }, }; } @@ -278,12 +289,16 @@ export async function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sin if (existsSync(dir)) { let files: string[] = []; try { files = (await readdir(dir)).filter((f) => f.endsWith('.jsonl')); } catch { files = []; } - for (const f of files) { + // A project's stats go out together: with every digest cached, these round-trips ARE the scan, + // and one at a time they were most of its wall time. Folding stays in file order. + const stats = await Promise.all(files.map((f) => stat(join(dir, f)).catch(() => null))); + for (const [i, f] of files.entries()) { const full = join(dir, f); - let fileMs = Date.now(); - let size = 0; - let birthtimeMs = 0; - try { ({ mtimeMs: fileMs, size, birthtimeMs } = await stat(full)); } catch { /* keep defaults */ } + const st = stats[i]; + // An unreadable stat keeps the old defaults: "now", empty, unknown birth. + const fileMs = st ? st.mtimeMs : Date.now(); + const size = st ? st.size : 0; + const birthtimeMs = st ? st.birthtimeMs : 0; const digest = await digestFor(full, fileMs, size, birthtimeMs); if (!digest) continue; // unreadable — skip, don't poison the cache let contributed = false; @@ -301,12 +316,12 @@ export async function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sin webSearch += e.webSearch; webFetch += e.webFetch; } - const stampsInRange = sinceMs === Infinity ? digest.stamps : digest.stamps.filter((_, i) => digest.stampDayMs[i] >= sinceMs); - projActiveMs += activeMsFromTimestamps(stampsInRange); + const fromDayMs = sinceMs === Infinity ? -Infinity : sinceMs; + projActiveMs += tallyActiveSince(digest.active, fromDayMs); // Count a session only when it had activity in the selected range (an in-range usage entry, or // any in-range message). Counting every file regardless of range let long-dead projects render // as 0-value rows and inflated the summary's range-scoped "sessions" stat with lifetime files. - if (contributed || stampsInRange.length > 0) projSessions++; + if (contributed || tallyHasStampSince(digest.active, fromDayMs)) projSessions++; } } diff --git a/src/shared/trayState.ts b/src/shared/trayState.ts index 3a31f91..6aa5155 100644 --- a/src/shared/trayState.ts +++ b/src/shared/trayState.ts @@ -4,17 +4,23 @@ import type { ShutdownPhase } from './shutdownIdle'; export type TrayAlertMode = 'off' | 'attention' | 'all'; export interface TrayCounts { attention: number; turn: number; overdue: number; } +/** The tray's words, in the app's language — the main process has no translator, so the window sends them. */ +export interface TrayLabels { open: string; shutdownIdle: string; shutdownNow: string; cancelShutdown: string; quit: string; waiting: string; overdue: string } +export const DEFAULT_TRAY_LABELS: TrayLabels = { + open: 'Open DevDeck', shutdownIdle: 'Shut down PC when idle', shutdownNow: 'Shut down PC now', cancelShutdown: 'Cancel shutdown', quit: 'Quit', + waiting: '{n} waiting', overdue: '{n} overdue', +}; /** * `red` drives the red-dot alert icon: agent needs-you counts only, gated by the user's alert mode. * Overdue TASKS never redden the tray (a deadline is not an agent waiting on input) but always show * in the tooltip — including with alerts 'off', since 'off' disables the dot, not information. */ -export function trayState(counts: TrayCounts, mode: TrayAlertMode): { red: number; tooltip: string } { +export function trayState(counts: TrayCounts, mode: TrayAlertMode, labels: TrayLabels = DEFAULT_TRAY_LABELS): { red: number; tooltip: string } { const red = mode === 'off' ? 0 : mode === 'all' ? counts.attention + counts.turn : counts.attention; const parts: string[] = []; - if (red > 0) parts.push(`${red} waiting`); - if (counts.overdue > 0) parts.push(`${counts.overdue} overdue`); + if (red > 0) parts.push(labels.waiting.replace('{n}', String(red))); + if (counts.overdue > 0) parts.push(labels.overdue.replace('{n}', String(counts.overdue))); return { red, tooltip: parts.length ? `DevDeck — ${parts.join(' · ')}` : 'DevDeck' }; } diff --git a/src/shared/types.ts b/src/shared/types.ts index e6ce563..d877cff 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -97,8 +97,13 @@ export interface StoreEntry { todos: Todo[]; /** Missing in old state files / hosts. New stores expose zero initially. */ todosRevision?: number; + /** Bumped on every note write, so a save can tell it is about to replace another device's note. */ + noteRevision?: number; } +/** Outcome of a versioned note save: on a conflict, the note and revision that are there now. */ +export interface NoteSaveResult { ok: boolean; note: string; revision: number } + export interface ResumeCue { kind: 'lastMessage'; // 'todos' reserved for future structured harvesting text: string; @@ -129,6 +134,8 @@ export interface ProjectViewModel { todos: Todo[]; /** Missing in old state files / hosts. New stores expose zero initially. */ todosRevision?: number; + /** Missing from older hosts, whose notes then save without conflict detection. */ + noteRevision?: number; } export interface RecentCommit { diff --git a/src/shared/usage.test.ts b/src/shared/usage.test.ts index 1e8e76c..2888cd1 100644 --- a/src/shared/usage.test.ts +++ b/src/shared/usage.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { emptyTotals, addUsage, estimateCost, MODEL_PRICING, priceFor, SONNET5_ROLLOFF_MS, activeMsFromTimestamps, formatDuration, ACTIVE_GAP_CAP_MS, + emptyActiveTally, cloneActiveTally, tallyStamp, tallyActiveSince, tallyHasStampSince, priceForProvider, normalizeCodexTotals, estimateProviderCost, } from './usage'; @@ -148,6 +149,48 @@ describe('activeMsFromTimestamps', () => { }); }); +describe('ActiveTally (incremental active time)', () => { + const DAY = 86_400_000; + const dayOf = (ms: number) => Math.floor(ms / DAY) * DAY; + + it('matches the batch sum for every "since day" — in order, out of order, repeated, very late', () => { + let seed = 7; + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + for (let trial = 0; trial < 200; trial++) { + const stamps: number[] = []; + let clock = Date.UTC(2026, 5, 1) + Math.floor(rnd() * DAY); + const n = Math.floor(rnd() * 400); + for (let i = 0; i < n; i++) { + const r = rnd(); + clock += r < 0.05 ? Math.floor(rnd() * DAY) : Math.floor(rnd() * 7 * 60_000); // idle gaps, day changes + // ~10% arrive a little late, ~1% days late (a clock set back), some exactly repeated + const late = r > 0.99 ? Math.floor(rnd() * 3 * DAY) : r > 0.9 ? Math.floor(rnd() * 10 * 60_000) : 0; + stamps.push(r > 0.97 && stamps.length ? stamps[stamps.length - 1] : clock - late); + } + const t = emptyActiveTally(); + for (const s of stamps) tallyStamp(t, s); + expect(t.sorted).toEqual([...stamps].sort((x, y) => x - y)); + expect(tallyActiveSince(t, -Infinity)).toBe(activeMsFromTimestamps(stamps)); + for (const since of [...new Set(stamps.map(dayOf))]) { + const inRange = stamps.filter((s) => dayOf(s) >= since); + expect(tallyActiveSince(t, since), `trial ${trial} since ${since}`).toBe(activeMsFromTimestamps(inRange)); + expect(tallyHasStampSince(t, since)).toBe(inRange.length > 0); + } + expect(tallyHasStampSince(t, dayOf(Math.max(...stamps, 0)) + DAY)).toBe(false); + } + }); + + it('a clone is independent of the original', () => { + const t = emptyActiveTally(); + tallyStamp(t, 1_000); + const c = cloneActiveTally(t); + tallyStamp(c, 61_000); + expect(tallyActiveSince(t, -Infinity)).toBe(0); + expect(tallyActiveSince(c, -Infinity)).toBe(60_000); + expect(t.sorted).toEqual([1_000]); + }); +}); + describe('formatDuration', () => { it('formats hours and minutes', () => { expect(formatDuration((14 * 60 + 9) * 60_000)).toBe('14h 9m'); diff --git a/src/shared/usage.ts b/src/shared/usage.ts index 2fea6c6..b149ee5 100644 --- a/src/shared/usage.ts +++ b/src/shared/usage.ts @@ -154,6 +154,86 @@ export function activeMsFromTimestamps(timestampsMs: number[]): number { return active; } +const DAY_MS = 86_400_000; + +/** + * Active time kept up to date one timestamp at a time — the incremental form of + * activeMsFromTimestamps, for logs that are read forward a piece at a time. + * + * The batch form wants every timestamp in one array, which it copies and sorts on each call. Session + * logs only grow, so callers held every timestamp and re-sorted the lot on every refresh: the usage + * scan for every transcript on the machine every ~45 s (unchanged ones included), the cockpit for + * every live tile every tick. Here the timestamps are kept IN ORDER as they arrive, and the sum is + * kept per day as they do, so answering is a walk over the days and adding a line touches only its + * neighbours. + * + * Exact, not an estimate. A gap is credited to the UTC day of its EARLIER timestamp, so "active time + * on or after day D" — the only range question asked — is the sum over days from D on: the + * timestamps from D on are a suffix of the sorted list, and the gaps inside that suffix are exactly + * the ones whose earlier end is in it. Inserting t between neighbours a < b swaps gap(a,b) for + * gap(a,t) + gap(t,b). Real logs do arrive out of order (subagent lines interleave — ~10% of lines on + * this machine, never more than 25 places back), so the insert is almost always at or near the end. + * + * Every timestamp is still kept, deliberately: dropping old ones would leave a late line with no + * known neighbours, and a clock set back by a day would make EVERY new line one of those. + */ +export interface ActiveTally { + /** Every timestamp so far, ascending. */ + sorted: number[]; + /** Active ms by UTC day start of each counted gap's earlier timestamp. */ + byDay: Map; +} + +export function emptyActiveTally(): ActiveTally { + return { sorted: [], byDay: new Map() }; +} + +export function cloneActiveTally(t: ActiveTally): ActiveTally { + return { sorted: t.sorted.slice(), byDay: new Map(t.byDay) }; +} + +function creditGap(t: ActiveTally, from: number, to: number, sign: 1 | -1): void { + const gap = to - from; + if (!(gap > 0 && gap <= ACTIVE_GAP_CAP_MS)) return; + const day = Math.floor(from / DAY_MS) * DAY_MS; + t.byDay.set(day, (t.byDay.get(day) ?? 0) + sign * gap); +} + +/** Count one more timestamp, wherever it falls. */ +export function tallyStamp(t: ActiveTally, ms: number): void { + const { sorted } = t; + const n = sorted.length; + if (n === 0 || ms >= sorted[n - 1]) { + if (n > 0) creditGap(t, sorted[n - 1], ms, 1); + sorted.push(ms); + return; + } + let lo = 0; + let hi = n; + while (lo < hi) { const mid = (lo + hi) >> 1; if (sorted[mid] <= ms) lo = mid + 1; else hi = mid; } + const after = sorted[lo]; + if (lo > 0) { + const before = sorted[lo - 1]; + creditGap(t, before, after, -1); + creditGap(t, before, ms, 1); + } + creditGap(t, ms, after, 1); + sorted.splice(lo, 0, ms); +} + +/** Active ms over the timestamps whose UTC day starts at or after `fromDayMs` (-Infinity = all). */ +export function tallyActiveSince(t: ActiveTally, fromDayMs: number): number { + let sum = 0; + for (const [day, ms] of t.byDay) if (day >= fromDayMs) sum += ms; + return sum; +} + +/** Whether any tallied timestamp's UTC day starts at or after `fromDayMs`. */ +export function tallyHasStampSince(t: ActiveTally, fromDayMs: number): boolean { + const n = t.sorted.length; + return n > 0 && Math.floor(t.sorted[n - 1] / DAY_MS) * DAY_MS >= fromDayMs; +} + /** Human-friendly duration: "14h 9m", "45m", "0m". Floors to whole minutes. */ export function formatDuration(ms: number): string { const totalMin = Math.floor(Math.max(0, ms) / 60000); diff --git a/src/shared/wtArgs.test.ts b/src/shared/wtArgs.test.ts index 414907e..7148bec 100644 --- a/src/shared/wtArgs.test.ts +++ b/src/shared/wtArgs.test.ts @@ -26,3 +26,12 @@ describe('buildWtArgs', () => { expect(() => buildWtArgs([], 'pwsh')).toThrow(); }); }); + +describe('buildWtArgs semicolons', () => { + it('escapes ; so wt does not split a folder or title into another subcommand', () => { + const args = buildWtArgs([{ name: 'a;b', dir: 'C:/x;y', command: 'claude' }], 'pwsh'); + expect(args).toContain('a\\;b'); + expect(args).toContain('C:/x\\;y'); + expect(args.filter((a) => a === ';')).toHaveLength(0); + }); +}); diff --git a/src/shared/wtArgs.ts b/src/shared/wtArgs.ts index e5c4111..ac149dc 100644 --- a/src/shared/wtArgs.ts +++ b/src/shared/wtArgs.ts @@ -4,13 +4,19 @@ export interface WtTab { command: string; } +/** + * wt splits its whole command line on `;` into separate subcommands — a folder or title containing + * one opened a broken tab plus a stray command. `\;` is wt's escape for a literal semicolon. + */ +function wtLiteral(value: string): string { return value.replace(/;/g, '\\;'); } + /** Build `wt` args opening each tab in the CURRENT window (-w 0), each running its own command. */ export function buildWtArgs(tabs: WtTab[], shell: string): string[] { if (tabs.length === 0) throw new Error('buildWtArgs: no tabs'); const args: string[] = ['-w', '0']; tabs.forEach((t, i) => { if (i > 0) args.push(';'); - args.push('new-tab', '--title', t.name, '-d', t.dir, shell, '-NoExit', '-Command', t.command); + args.push('new-tab', '--title', wtLiteral(t.name), '-d', wtLiteral(t.dir), shell, '-NoExit', '-Command', wtLiteral(t.command)); }); return args; } From 25b783e17efe5fa85db91af318cf497696c54490 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 16:17:22 -0700 Subject: [PATCH 7/8] perf+fix(renderer): spinners on a tick, not a CSS animation; versioned notes; i18n and a11y - A running CSS animation keeps Chromium producing frames whether or not a step is due. A/B in one app run, 12 sessions with 6 working: CSS spinner at 10 steps/s 16.0% of a core, 4 steps/s 12.3%, 1 step/s 9.0%; a 250 ms JS tick setting a custom property 5.5%, nothing turning 4.6%. The spinner now turns on that tick (paused when unfocused, hidden or reduced-motion); the shipped build measured 3.6% in the same scenario. - Note editor saves against the revision it read; on a conflict both notes are shown to reconcile, never one erased. Max length shown on the box. - Owed remote closes survive an app restart. - Hard-coded English (project menu, checkboxes, chart title) localized; shutdown history dates use the app language; per-project checkbox names, aria-pressed toggles, calendar day labels, keyboard edit of task text. - qa: the update-button scenario retries Enter on the trust prompt; the spinner check verifies the spinner actually turns rather than that a CSS animation exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta (cherry picked from commit 5c45a1b1ee0ff71273260052329f6ae349c37e96) --- qa/close.mjs | 8 ++++++-- qa/screenshot.mjs | 3 ++- src/preload/preload.ts | 3 +++ src/renderer/charts.ts | 4 +++- src/renderer/cockpitView.ts | 29 +++++++++++++++++++++++++++- src/renderer/global.d.ts | 3 +++ src/renderer/locales/en.json | 14 ++++++++++++++ src/renderer/locales/ja.json | 14 ++++++++++++++ src/renderer/locales/ko.json | 14 ++++++++++++++ src/renderer/locales/zh.json | 14 ++++++++++++++ src/renderer/machineDeck.ts | 11 +++++++++++ src/renderer/main.ts | 5 +++++ src/renderer/nextView.ts | 11 +++++++++-- src/renderer/projectsView.ts | 31 ++++++++++++++++++++++++++---- src/renderer/shell.ts | 24 +++++++++++++++-------- src/renderer/shell/shell.css | 12 +++--------- src/renderer/shutdown.ts | 5 +++-- src/renderer/styles.css | 4 +--- src/shared/closeTombstones.test.ts | 6 ++++++ src/shared/closeTombstones.ts | 5 +++++ 20 files changed, 187 insertions(+), 33 deletions(-) diff --git a/qa/close.mjs b/qa/close.mjs index 6a59961..b4b429e 100644 --- a/qa/close.mjs +++ b/qa/close.mjs @@ -264,8 +264,12 @@ try { const u1 = await host.win.evaluate(async () => (await window.devdeck.cockpit.liveSessions()).find((s) => s.label === 'qa-U1')?.id); const screenHas = (text) => host.win.evaluate((t) => [...(document.querySelector('.ck-term.show .xterm-rows')?.children ?? [])].some((r) => (r.textContent ?? '').includes(t)), text); await waitFor(() => screenHas('No, exit'), 20000); - await host.win.evaluate((id) => window.devdeck.cockpit.input(id, '\r'), u1); // trust prompt: "No, exit" -> PowerShell - await waitFor(() => screenHas('PS '), 15000); + // Trust prompt: "No, exit" -> PowerShell. The prompt can be drawn a moment before it takes input, so + // Enter is sent again if the shell has not appeared; a spare Enter at a PowerShell prompt is harmless. + for (let attempt = 0; attempt < 3; attempt++) { + await host.win.evaluate((id) => window.devdeck.cockpit.input(id, '\r'), u1); + if (await waitFor(() => screenHas('PS '), 6000)) break; + } await host.win.evaluate((id) => window.devdeck.cockpit.input(id, "Write-Host ' >> bypass permissions on Update installed · Restart to apply'\r"), u1); const appeared = await waitFor(() => host.win.evaluate(() => !!document.querySelector('#ck-header .ck-update')), 8000); check('update.buttonAppears', appeared, appeared ? undefined : await host.win.evaluate(() => [...(document.querySelector('.ck-term.show .xterm-rows')?.children ?? [])].map((r) => r.textContent).filter((t) => t && t.trim()).slice(-6))); diff --git a/qa/screenshot.mjs b/qa/screenshot.mjs index 2c45420..d34f218 100644 --- a/qa/screenshot.mjs +++ b/qa/screenshot.mjs @@ -999,7 +999,8 @@ const liveRowControls = await win.evaluate(async () => { triggersVisible: wraps.every((wrap) => (wrap.querySelector('.shell-session-actions')?.getClientRects().length ?? 0) > 0), liveActions: actionsOf(wraps[0]), pinnedActions: actionsOf(wraps.find((wrap) => wrap.querySelector('[data-pinned="true"]'))), - workingAnimated: getComputedStyle(working).animationName === 'shell-spin', + // The spinner is turned by a tick (not a CSS animation): it must actually move between two looks. + workingAnimated: await (async () => { const a = getComputedStyle(working).rotate; await new Promise((r) => setTimeout(r, 700)); return getComputedStyle(working).rotate !== a; })(), distinctShapes: new Set(shapes).size === shapes.length, summaryShown: !!document.querySelector('.activity-working')?.parentElement?.querySelector('.shell-entity-copy em:not(.hidden)')?.textContent, }; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index d185b25..4cafe23 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld('devdeck', { listProjects: () => ipcRenderer.invoke('projects:list'), projectMemory: (path: string, fresh?: boolean) => ipcRenderer.invoke('project:memory', path, fresh === true), setNote: (path: string, note: string) => ipcRenderer.invoke('project:setNote', path, note), + saveNote: (path: string, note: string, revision: number) => ipcRenderer.invoke('project:saveNote', path, note, revision), setTodos: (path: string, todos: unknown, revision: number) => ipcRenderer.invoke('project:saveTodos', path, todos, revision), setPinned: (path: string, pinned: boolean) => ipcRenderer.invoke('project:setPinned', path, pinned), setHidden: (path: string, hidden: boolean) => ipcRenderer.invoke('project:setHidden', path, hidden), @@ -156,6 +157,7 @@ contextBridge.exposeInMainWorld('devdeck', { listProjects: () => ipcRenderer.invoke('link:call', machineId, 'projects:list', []), projectMemory: (path: string, fresh?: boolean) => ipcRenderer.invoke('link:call', machineId, 'project:memory', [path, fresh === true]), setNote: (path: string, note: string) => ipcRenderer.invoke('link:call', machineId, 'project:setNote', [path, note]), + saveNote: (path: string, note: string, revision: number) => ipcRenderer.invoke('link:call', machineId, 'project:saveNote', [path, note, revision]), setTodos: (path: string, todos: unknown, revision: number) => ipcRenderer.invoke('link:call', machineId, 'project:saveTodos', [path, todos, revision]), setPinned: (path: string, pinned: boolean) => ipcRenderer.invoke('link:call', machineId, 'project:setPinned', [path, pinned]), setHidden: (path: string, hidden: boolean) => ipcRenderer.invoke('link:call', machineId, 'project:setHidden', [path, hidden]), @@ -186,4 +188,5 @@ contextBridge.exposeInMainWorld('devdeck', { setContextWindow: (w: number) => ipcRenderer.invoke('settings:setContextWindow', w), setTrayCounts: (counts: { attention?: number; turn?: number; overdue?: number }) => ipcRenderer.send('tray:counts', counts), setTrayAlertImage: (dataUrl: string) => ipcRenderer.send('tray:alertImage', dataUrl), + setTrayLabels: (labels: import('../shared/trayState').TrayLabels) => ipcRenderer.send('tray:labels', labels), }); diff --git a/src/renderer/charts.ts b/src/renderer/charts.ts index 1aedc02..b851eb0 100644 --- a/src/renderer/charts.ts +++ b/src/renderer/charts.ts @@ -1,3 +1,5 @@ +import { tr } from './i18n-runtime'; + const NS = 'http://www.w3.org/2000/svg'; /** A simple bar chart. values: [{label, value}], height in px. @@ -13,7 +15,7 @@ export function barChart(values: { label: string; value: number }[], height = 80 svg.setAttribute('role', 'img'); const svgTitle = document.createElementNS(NS, 'title'); - svgTitle.textContent = 'Daily token usage bar chart'; + svgTitle.textContent = tr('usage.chart_title'); svg.appendChild(svgTitle); values.forEach((v, i) => { diff --git a/src/renderer/cockpitView.ts b/src/renderer/cockpitView.ts index 900dca2..f02ff5f 100644 --- a/src/renderer/cockpitView.ts +++ b/src/renderer/cockpitView.ts @@ -247,6 +247,7 @@ export function mountCockpit(): void { // did not come back. Repainting before that would ask the machine for screens it no longer has. // Closes owed to that machine go first: they were pressed while it was unreachable, and the // sessions they name are still running there until they arrive. + loadOwedCloses(); onMachineConnected((machineId) => { void deliverPendingCloses(machineId).then(() => pullMachineSessions(machineId)).then(() => resyncMachineTiles(machineId)); }); // A machine coming or going is exactly when a size another view was holding this pty down to stops // being true — drop the caps and let the pane have its terminals back. @@ -2190,6 +2191,30 @@ function removeTile(l: Live): void { /** Closes owed to a machine that could not be reached when they were pressed. */ const owedCloseNames = new Map(); +/** + * Owed closes outlive this app. Kept only in memory, quitting DevDeck before the other machine came + * back forgot them: the session went on running there and was adopted straight back on the next + * connect. Per-viewer state, so the browser storage of this window is the right place for it. + */ +const OWED_CLOSES_KEY = 'devdeck.owedCloses'; +function saveOwedCloses(): void { + try { + const owed = closedTiles.allPending().map((p) => ({ ...p, name: owedCloseNames.get(p.id) ?? '' })); + if (owed.length) localStorage.setItem(OWED_CLOSES_KEY, JSON.stringify(owed)); else localStorage.removeItem(OWED_CLOSES_KEY); + } catch { /* storage unavailable: the close is still owed for this run */ } +} +function loadOwedCloses(): void { + try { + const raw = JSON.parse(localStorage.getItem(OWED_CLOSES_KEY) ?? '[]') as unknown; + if (!Array.isArray(raw)) return; + for (const item of raw.slice(0, 200)) { + if (!item || typeof item.machineId !== 'string' || typeof item.id !== 'string' || item.machineId === LOCAL_MACHINE_ID) continue; + closedTiles.add(item.machineId, item.id, true); + if (typeof item.name === 'string' && item.name) owedCloseNames.set(item.id, item.name); + } + } catch { /* unreadable: nothing owed */ } +} + /** * Ask the machine running a remote tile to close it, and act on the answer. * @@ -2202,15 +2227,17 @@ async function deliverClose(machineId: string, id: string, name: string): Promis let result: import('../shared/types').CloseResult; try { result = await window.devdeck.cockpit.closeSession(id); } catch (err) { result = { ok: false, reason: 'error', error: String(err) }; } window.devdeck.logDiagnostic(`close ${id.slice(-24)} on ${machineName(machineId)}: ${result.ok ? 'ok' : result.reason}`, result.ok ? 'info' : 'warn', 'link'); - if (result.ok) { closedTiles.settle(machineId, id); owedCloseNames.delete(id); return true; } + if (result.ok) { closedTiles.settle(machineId, id); owedCloseNames.delete(id); saveOwedCloses(); return true; } if (result.reason === 'offline') { closedTiles.markPending(machineId, id); if (!owedCloseNames.has(id)) toast(tr('cockpit.close_pending', { name, machine: machineName(machineId) })); owedCloseNames.set(id, name); + saveOwedCloses(); return false; } closedTiles.remove(machineId, id); owedCloseNames.delete(id); + saveOwedCloses(); toast(result.reason === 'denied' ? tr('cockpit.close_denied', { name, machine: machineName(machineId) }) : tr('cockpit.close_failed', { name, machine: machineName(machineId) })); diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 39b066f..e4b1e53 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -6,6 +6,7 @@ declare global { listProjects(): Promise; projectMemory(path: string, fresh?: boolean): Promise; setNote(path: string, note: string): Promise; + saveNote(path: string, note: string, revision: number): Promise; setTodos(path: string, todos: import('../shared/tasks').Todo[], revision: number): Promise; setPinned(path: string, pinned: boolean): Promise; setHidden(path: string, hidden: boolean): Promise; @@ -45,6 +46,7 @@ declare global { listProjects(): Promise; projectMemory(path: string, fresh?: boolean): Promise; setNote(path: string, note: string): Promise; + saveNote(path: string, note: string, revision: number): Promise; setTodos(path: string, todos: import('../shared/tasks').Todo[], revision: number): Promise; setPinned(path: string, pinned: boolean): Promise; setHidden(path: string, hidden: boolean): Promise; @@ -72,6 +74,7 @@ declare global { setContextWindow(w: number): Promise; setTrayCounts(counts: { attention?: number; turn?: number; overdue?: number }): void; setTrayAlertImage(dataUrl: string): void; + setTrayLabels(labels: import('../shared/trayState').TrayLabels): void; setBaseDir(dir: string): Promise; getFolders(): Promise; /** `kind` omitted ⇒ auto-detect (a `.git` in the folder makes it a single project). */ diff --git a/src/renderer/locales/en.json b/src/renderer/locales/en.json index 0009c55..dee3acb 100644 --- a/src/renderer/locales/en.json +++ b/src/renderer/locales/en.json @@ -23,6 +23,15 @@ "proj.view_cards": "Cards", "proj.view_list": "List", "app.open_selected": "Open selected", + "proj.note_conflict": "This note was changed on another device. Both versions are shown — keep what you want and save again.", + "proj.note_mine": "written here", + "tray.open": "Open DevDeck", + "tray.shutdown_idle": "Shut down PC when idle", + "tray.shutdown_now": "Shut down PC now", + "tray.cancel_shutdown": "Cancel shutdown", + "tray.quit": "Quit", + "tray.waiting": "{n} waiting", + "tray.overdue": "{n} overdue", "app.refresh": "Refresh", "app.refresh_hint": "Refresh — reloads the deck and redraws open terminals", "nav.projects": "Projects", @@ -99,6 +108,11 @@ "tasks.show_done": "Show done", "tasks.done_section": "Done", "tasks.view_list": "List", + "projects.more_options": "More options for {name}", + "projects.select_named": "Select {name}", + "usage.chart_title": "Daily token usage", + "tasks.edit_hint": "press Enter to edit", + "tasks.cal_open_n": "{n} open tasks", "tasks.view_calendar": "Calendar", "tasks.today": "Today", "tasks.prev_month": "Previous month", diff --git a/src/renderer/locales/ja.json b/src/renderer/locales/ja.json index a5d5935..e3c7833 100644 --- a/src/renderer/locales/ja.json +++ b/src/renderer/locales/ja.json @@ -23,6 +23,15 @@ "proj.view_cards": "カード", "proj.view_list": "リスト", "app.open_selected": "選択を開く", + "proj.note_conflict": "このメモは別のデバイスで変更されました。両方の内容を表示しています — 必要な形に整えて再度保存してください。", + "proj.note_mine": "このデバイスで書いた内容", + "tray.open": "DevDeck を開く", + "tray.shutdown_idle": "アイドル時に PC をシャットダウン", + "tray.shutdown_now": "今すぐ PC をシャットダウン", + "tray.cancel_shutdown": "シャットダウンを取り消す", + "tray.quit": "終了", + "tray.waiting": "{n} 件待機中", + "tray.overdue": "{n} 件期限切れ", "app.refresh": "更新", "app.refresh_hint": "更新 — デッキを読み直し、開いているターミナルを描き直します", "nav.projects": "プロジェクト", @@ -100,6 +109,11 @@ "tasks.show_done": "完了を表示", "tasks.done_section": "完了", "tasks.view_list": "リスト", + "projects.more_options": "{name} のその他のオプション", + "projects.select_named": "{name} を選択", + "usage.chart_title": "日別トークン使用量", + "tasks.edit_hint": "Enter で編集", + "tasks.cal_open_n": "未完了タスク {n} 件", "tasks.view_calendar": "カレンダー", "tasks.today": "今日", "tasks.prev_month": "前の月", diff --git a/src/renderer/locales/ko.json b/src/renderer/locales/ko.json index 840388b..788660b 100644 --- a/src/renderer/locales/ko.json +++ b/src/renderer/locales/ko.json @@ -23,6 +23,15 @@ "proj.view_cards": "카드", "proj.view_list": "리스트", "app.open_selected": "선택 열기", + "proj.note_conflict": "다른 기기에서 이 메모가 바뀌었습니다. 두 내용을 함께 보여 드리니 원하는 대로 정리한 뒤 다시 저장하세요.", + "proj.note_mine": "이 기기에서 쓴 내용", + "tray.open": "DevDeck 열기", + "tray.shutdown_idle": "유휴 시 PC 끄기", + "tray.shutdown_now": "지금 PC 끄기", + "tray.cancel_shutdown": "종료 취소", + "tray.quit": "종료", + "tray.waiting": "{n}개 대기 중", + "tray.overdue": "{n}개 기한 지남", "app.refresh": "새로고침", "app.refresh_hint": "새로고침 — 덱을 다시 읽고 열린 터미널을 다시 그립니다", "nav.projects": "프로젝트", @@ -100,6 +109,11 @@ "tasks.show_done": "완료 표시", "tasks.done_section": "완료", "tasks.view_list": "목록", + "projects.more_options": "{name} 추가 옵션", + "projects.select_named": "{name} 선택", + "usage.chart_title": "일별 토큰 사용량", + "tasks.edit_hint": "Enter를 눌러 수정", + "tasks.cal_open_n": "진행 중 작업 {n}개", "tasks.view_calendar": "달력", "tasks.today": "오늘", "tasks.prev_month": "이전 달", diff --git a/src/renderer/locales/zh.json b/src/renderer/locales/zh.json index 8b84d34..1de5467 100644 --- a/src/renderer/locales/zh.json +++ b/src/renderer/locales/zh.json @@ -23,6 +23,15 @@ "proj.view_cards": "卡片", "proj.view_list": "列表", "app.open_selected": "打开选中", + "proj.note_conflict": "此备注已在另一台设备上更改。两个版本都已显示——请保留需要的内容后再次保存。", + "proj.note_mine": "在此设备上写的内容", + "tray.open": "打开 DevDeck", + "tray.shutdown_idle": "空闲时关闭电脑", + "tray.shutdown_now": "立即关闭电脑", + "tray.cancel_shutdown": "取消关机", + "tray.quit": "退出", + "tray.waiting": "{n} 个等待中", + "tray.overdue": "{n} 个已逾期", "app.refresh": "刷新", "app.refresh_hint": "刷新 — 重新载入面板并重绘已打开的终端", "nav.projects": "项目", @@ -100,6 +109,11 @@ "tasks.show_done": "显示已完成", "tasks.done_section": "已完成", "tasks.view_list": "列表", + "projects.more_options": "{name} 的更多选项", + "projects.select_named": "选择 {name}", + "usage.chart_title": "每日令牌用量", + "tasks.edit_hint": "按 Enter 编辑", + "tasks.cal_open_n": "{n} 个未完成任务", "tasks.view_calendar": "日历", "tasks.today": "今天", "tasks.prev_month": "上个月", diff --git a/src/renderer/machineDeck.ts b/src/renderer/machineDeck.ts index 3be934e..c6d2477 100644 --- a/src/renderer/machineDeck.ts +++ b/src/renderer/machineDeck.ts @@ -27,6 +27,8 @@ export interface DeckFacade { */ projectMemory: (path: string, fresh?: boolean) => Promise; setNote: (path: string, note: string) => Promise; + /** Versioned note save; against a host too old to version notes it falls back to a plain write. */ + saveNote: (path: string, note: string, revision: number) => Promise; setTodos: typeof window.devdeck.setTodos; setPinned: (path: string, pinned: boolean) => Promise; setHidden: (path: string, hidden: boolean) => Promise; @@ -61,6 +63,7 @@ export function deckFor(machineId: string): DeckFacade { listProjects: () => window.devdeck.listProjects(), projectMemory: (path, fresh) => window.devdeck.projectMemory(path, fresh), setNote: (path, note) => window.devdeck.setNote(path, note), + saveNote: (path, note, revision) => window.devdeck.saveNote(path, note, revision), setTodos: (path, todos, revision) => window.devdeck.setTodos(path, todos, revision), setPinned: (path, pinned) => window.devdeck.setPinned(path, pinned), setHidden: (path, hidden) => window.devdeck.setHidden(path, hidden), @@ -73,6 +76,14 @@ export function deckFor(machineId: string): DeckFacade { listProjects: () => remote.listProjects(), projectMemory: (path, fresh) => remote.projectMemory(path, fresh), setNote: (path, note) => remote.setNote(path, note), + saveNote: async (path, note, revision) => { + try { return await remote.saveNote(path, note, revision); } + catch (error) { + if (!/no such method/.test(error instanceof Error ? error.message : String(error))) throw error; + await remote.setNote(path, note); + return { ok: true, note, revision }; + } + }, setTodos: (path, todos, revision) => remote.setTodos(path, todos, revision), setPinned: (path, pinned) => remote.setPinned(path, pinned), setHidden: (path, hidden) => remote.setHidden(path, hidden), diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 7e27797..2883487 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -91,6 +91,11 @@ window.devdeck.onUpdate(renderUpdate); function applyStaticLabels(): void { document.documentElement.lang = currentLang(); + // The tray menu and tooltip are drawn by the main process, which has no translator of its own. + window.devdeck.setTrayLabels({ + open: tr('tray.open'), shutdownIdle: tr('tray.shutdown_idle'), shutdownNow: tr('tray.shutdown_now'), + cancelShutdown: tr('tray.cancel_shutdown'), quit: tr('tray.quit'), waiting: tr('tray.waiting'), overdue: tr('tray.overdue'), + }); const openSelected = document.querySelector('#open-selected')!; openSelected.replaceChildren(createIcon('play'), document.createTextNode(tr('app.open_selected'))); const newProject = document.querySelector('#new-project')!; diff --git a/src/renderer/nextView.ts b/src/renderer/nextView.ts index 5015ac8..be1d5f4 100644 --- a/src/renderer/nextView.ts +++ b/src/renderer/nextView.ts @@ -137,6 +137,9 @@ function taskRow(it: TaskWithProject, now: number): HTMLElement { const text = document.createElement('span'); text.className = 'tk-text'; text.textContent = todo.text; text.title = todo.text; text.addEventListener('dblclick', () => startEdit(text, projectPath, todo)); + // Double-click was the only way in. Focusable, with Enter / F2, so a keyboard can edit a task too. + text.tabIndex = 0; text.setAttribute('role', 'button'); text.setAttribute('aria-label', `${todo.text} — ${tr('tasks.edit_hint')}`); + text.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === 'F2') { e.preventDefault(); startEdit(text, projectPath, todo); } }); const due = document.createElement('button'); due.className = 'tk-due' + (todo.due ? ' ' + BUCKET_CLASS[classifyDue(todo.due, now)] : ' tk-due-empty'); due.textContent = todo.due ? dueLabel(todo.due, now) : '+' + tr('tasks.due'); @@ -362,8 +365,8 @@ function render(): void { function viewToggle(): HTMLElement { const wrap = document.createElement('div'); wrap.className = 'tk-viewtoggle'; for (const [mode, key] of [['list', 'tasks.view_list'], ['calendar', 'tasks.view_calendar']] as ['list' | 'calendar', string][]) { - const b = document.createElement('button'); b.className = 'tk-vt' + (boardView === mode ? ' on' : ''); - b.textContent = tr(key); + const b = document.createElement('button'); b.type = 'button'; b.className = 'tk-vt' + (boardView === mode ? ' on' : ''); + b.textContent = tr(key); b.setAttribute('aria-pressed', String(boardView === mode)); b.addEventListener('click', () => { if (boardView !== mode) { boardView = mode; render(); } }); wrap.appendChild(b); } @@ -415,6 +418,10 @@ function dayCell(cell: DayCell): HTMLElement { const el = document.createElement('button'); el.className = 'cal-cell' + (cell.inMonth ? '' : ' out') + (cell.isToday ? ' today' : '') + (cell.dateStr === calSelected ? ' sel' : ''); const n = document.createElement('span'); n.className = 'cal-day-n'; n.textContent = String(cell.day); el.appendChild(n); + // A bare day number and a dot is all a screen reader got; say the date and what is due. + const date = new Date(`${cell.dateStr}T00:00:00`).toLocaleDateString(localeTag(), { weekday: 'short', month: 'long', day: 'numeric' }); + el.setAttribute('aria-label', cell.openCount > 0 ? `${date}, ${tr('tasks.cal_open_n', { n: cell.openCount })}` : date); + el.setAttribute('aria-pressed', String(cell.dateStr === calSelected)); if (cell.openCount > 0) { const m = document.createElement('span'); m.className = 'cal-mark' + (cell.overdue ? ' overdue' : ''); m.textContent = `${cell.overdue ? '🔴' : '●'}${cell.openCount}`; diff --git a/src/renderer/projectsView.ts b/src/renderer/projectsView.ts index c82d7d1..d5e4158 100644 --- a/src/renderer/projectsView.ts +++ b/src/renderer/projectsView.ts @@ -228,6 +228,12 @@ function truncateCue(text: string, max = 60): string { // element (see makeCard) — when true, fall back to the plain ghost here instead of // repeating the same cue text a second time. Defaults to false so callers that don't // render `.card-cue` (list view has none) keep the original ghost-cue behavior. +/** Both notes, the one already saved first, so the person can keep what they want and save again. */ +function mergeConflictingNotes(theirs: string, mine: string): string { + if (!theirs.trim()) return mine; + return `${theirs}\n\n— ${tr('proj.note_mine')} —\n${mine}`; +} + function makeNote(p: ProjectViewModel, suppressCue = false): HTMLElement { const wrap = document.createElement('div'); const showRead = () => { @@ -254,6 +260,7 @@ function makeNote(p: ProjectViewModel, suppressCue = false): HTMLElement { wrap.replaceChildren(); const ta = document.createElement('textarea'); ta.className = 'note-edit'; ta.rows = 2; ta.value = prefill ?? p.note; ta.placeholder = tr('proj.next_todo_ph'); + ta.maxLength = 10_000; // the machine keeps at most this much; past it the text was cut off silently let cancelling = false; ta.addEventListener('keydown', (e) => { if (e.key === 'Escape') { @@ -268,7 +275,21 @@ function makeNote(p: ProjectViewModel, suppressCue = false): HTMLElement { if (ta.disabled) return; if (!cancelling && ta.value !== p.note) { ta.disabled = true; - try { await api.setNote(p.path, ta.value); p.note = ta.value; } + try { + const saved = await api.saveNote(p.path, ta.value, p.noteRevision ?? 0); + if (!saved.ok) { + // Another device saved this note since it was read. Overwriting would erase that note + // without anyone seeing it, so both are put in front of the person to reconcile. + p.noteRevision = saved.revision; + p.note = saved.note; + ta.value = mergeConflictingNotes(saved.note, ta.value); + ta.disabled = false; + toast(tr('proj.note_conflict')); + if (ta.isConnected) ta.focus(); + return; + } + p.note = saved.note; p.noteRevision = saved.revision; + } catch (error) { reportSaveError(error); ta.disabled = false; @@ -383,7 +404,7 @@ function makeMenuWrap(p: ProjectViewModel): HTMLElement { const menuWrap = document.createElement('div'); menuWrap.className = 'menu-wrap'; const menuBtn = document.createElement('button'); menuBtn.className = 'iconbtn'; menuBtn.appendChild(createIcon('more')); - menuBtn.setAttribute('aria-label', 'more options'); + menuBtn.setAttribute('aria-label', tr('projects.more_options', { name: p.name })); menuBtn.setAttribute('aria-haspopup', 'menu'); menuBtn.setAttribute('aria-expanded', 'false'); @@ -459,7 +480,9 @@ function makeCard(p: ProjectViewModel, render: () => void, live: '' | 'attention gitLine.appendChild(commitSpan); const foot = document.createElement('div'); foot.className = 'cardfoot'; - const check = document.createElement('input'); check.type = 'checkbox'; check.checked = selected.has(p.path); check.setAttribute('aria-label', 'select'); + // Named per project: a screen reader moving through a column of identical "select" boxes cannot tell + // which project it is about to include. + const check = document.createElement('input'); check.type = 'checkbox'; check.checked = selected.has(p.path); check.setAttribute('aria-label', tr('projects.select_named', { name: p.name })); check.addEventListener('change', () => { check.checked ? selected.add(p.path) : selected.delete(p.path); card.classList.toggle('selected', check.checked); @@ -518,7 +541,7 @@ function makeRow(p: ProjectViewModel, live: '' | 'attention' | 'working' = ''): row.dataset.projectPath = p.path; row.tabIndex = -1; - const check = document.createElement('input'); check.type = 'checkbox'; check.className = 'prow-check'; check.checked = selected.has(p.path); check.setAttribute('aria-label', 'select'); + const check = document.createElement('input'); check.type = 'checkbox'; check.className = 'prow-check'; check.checked = selected.has(p.path); check.setAttribute('aria-label', tr('projects.select_named', { name: p.name })); check.addEventListener('change', () => { check.checked ? selected.add(p.path) : selected.delete(p.path); row.classList.toggle('selected', check.checked); diff --git a/src/renderer/shell.ts b/src/renderer/shell.ts index d6e110f..f0ab35e 100644 --- a/src/renderer/shell.ts +++ b/src/renderer/shell.ts @@ -102,14 +102,22 @@ export function mountShell(options: { onGroupClose?(group: ShellSessionGroup): void; onRestoreAll(): void; }): ShellController { - // The working spinners stop while another window has the focus. Any running animation makes - // Chromium draw the whole window every step — measured at about half a percent of a core per frame a - // second, the same for one spinner as for six — and nobody is watching the turn of a spinner in a - // window they are not in. The working shape and colour stay, so the state still reads at a glance. - const syncFocus = (): void => { document.documentElement.classList.toggle('app-unfocused', !document.hasFocus()); }; - window.addEventListener('focus', syncFocus); - window.addEventListener('blur', syncFocus); - syncFocus(); + // The working spinners are turned by this tick, not by a CSS animation. A running CSS animation keeps + // Chromium producing frames whether or not a step is due, so even a slow stepped spinner cost a + // steady share of a core: measured with twelve sessions, six of them working — CSS at 10 steps/s + // 16.0%, at 4 steps/s 12.3%, at 1 step/s 9.0%; this tick at 4 steps/s 5.5%; nothing turning 4.6%. + // It pauses while the window is unfocused or hidden, and for reduced motion; the working shape and + // colour stay either way. Driven through a custom property so a row that stops working (a diamond + // has its own rotation) is not left turned. + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + let spinStep = 0; + setInterval(() => { + if (document.hidden || !document.hasFocus() || reducedMotion?.matches) return; + const spinners = document.querySelectorAll('.shell-signal.signal-spinner, .ck-spin'); + if (!spinners.length) return; + spinStep = (spinStep + 1) % 4; + for (const el of Array.from(spinners)) el.style.setProperty('--spin', `${spinStep * 90}deg`); + }, 250); const sidebar = document.getElementById('app-sidebar')!; const collapse = document.getElementById('shell-collapse') as HTMLButtonElement; const quickOpen = document.getElementById('shell-quick-open') as HTMLInputElement; diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index 7561442..80844e5 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -160,16 +160,10 @@ .shell-signal.signal-square { border-radius: 1px; background: var(--dd-text-faint); } .shell-signal.signal-spinner { border: 1.6px solid var(--dd-working); border-right-color: transparent; background: transparent; - /* Stepped, not smooth, and slow. A continuous rotation is composited at the display's refresh rate - for as long as it runs — measured at two thirds of the whole app's CPU while one agent worked. - Each step still redraws the whole window, at a cost that does not depend on how many spinners - are turning (measured: one spinner 12.2%, six 13.1% of a core, all frozen 6.8%), so what matters - is steps per second: a quarter turn four times a second ticks visibly alive at 4 frames instead - of 10. It stops while the window is not focused (see mountShell). */ - animation: shell-spin 1s steps(4, end) infinite; + /* Turned a quarter at a time by a tick in mountShell, not by a CSS animation — see there for the + measurements. A continuous rotation was once two thirds of the whole app's CPU. */ + rotate: var(--spin, 0deg); } -.app-unfocused .shell-signal.signal-spinner { animation-play-state: paused; } -@keyframes shell-spin { to { rotate: 360deg; } } @media (prefers-reduced-motion: reduce) { .shell-signal.signal-spinner { animation: none; border-right-color: var(--dd-canvas); } } diff --git a/src/renderer/shutdown.ts b/src/renderer/shutdown.ts index 62bd196..56615ab 100644 --- a/src/renderer/shutdown.ts +++ b/src/renderer/shutdown.ts @@ -1,4 +1,4 @@ -import { tr } from './i18n-runtime'; +import { tr, localeTag } from './i18n-runtime'; import { toast } from './loadError'; import type { ShutdownStatus } from '../main/shutdownScheduler'; import { createIcon, type IconName } from './icons'; @@ -14,7 +14,8 @@ let menuEl: HTMLElement; let countdownTimer: ReturnType | undefined; function fmtTime(ms: number): string { - return new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + // In the app's language, not the OS's: a Korean UI was showing English month names on an English Windows. + return new Date(ms).toLocaleString(localeTag(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } function applyStatus(s: ShutdownStatus): void { diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 3dc7282..24b5d05 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -592,9 +592,7 @@ button.primary:hover { background: var(--accent-hover); } .rail-badge.hidden { display: none; } .ck-ind { width: 16px; display: flex; align-items: center; justify-content: center; font-size: 12px; } .ck-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--neutral); } -.ck-spin { width: 10px; height: 10px; border: 1.6px solid #3fb950; border-right-color: transparent; border-radius: 50%; animation: ck-spin 1s steps(4, end) infinite; } /* stepped and slow for the same reason as .shell-signal.signal-spinner */ -.app-unfocused .ck-spin { animation-play-state: paused; } -@keyframes ck-spin { to { transform: rotate(360deg); } } +.ck-spin { width: 10px; height: 10px; border: 1.6px solid #3fb950; border-right-color: transparent; border-radius: 50%; rotate: var(--spin, 0deg); } /* turned by the same tick as .shell-signal.signal-spinner (mountShell) */ .ck-row.act-turn .ck-dot { background: var(--accent); } .ck-row.act-idle .ck-dot { background: var(--neutral); opacity: .55; } .ck-row.act-exited .ck-dot { background: transparent; border: 1.5px solid var(--text-faint); } diff --git a/src/shared/closeTombstones.test.ts b/src/shared/closeTombstones.test.ts index 46b6045..8b90f91 100644 --- a/src/shared/closeTombstones.test.ts +++ b/src/shared/closeTombstones.test.ts @@ -36,6 +36,12 @@ describe('CloseTombstones', () => { expect(t.has('m1', 'link:m1:p#1')).toBe(false); }); + it('lists every owed close across machines', () => { + const t = new CloseTombstones(); + t.add('m1', 'a', true); t.add('m2', 'b', true); t.add('m2', 'c'); + expect(t.allPending()).toEqual([{ machineId: 'm1', id: 'a' }, { machineId: 'm2', id: 'b' }]); + }); + it('forgets a refused close so the session can be shown again', () => { const t = new CloseTombstones(); t.add('m1', 'x', true); diff --git a/src/shared/closeTombstones.ts b/src/shared/closeTombstones.ts index 420e601..7be2c81 100644 --- a/src/shared/closeTombstones.ts +++ b/src/shared/closeTombstones.ts @@ -43,6 +43,11 @@ export class CloseTombstones { .filter(([, entry]) => entry.pending).map(([id]) => id); } + /** Every close still owed, on any machine — what has to survive a restart of this app. */ + allPending(): { machineId: string; id: string }[] { + return [...this.byMachine].flatMap(([machineId, ids]) => [...ids].filter(([, e]) => e.pending).map(([id]) => ({ machineId, id }))); + } + /** Forget a close entirely — it was refused, so the session is legitimately still there. */ remove(machineId: string, id: string): void { const ids = this.byMachine.get(machineId); From 57d6d4077ef62564427056c89452851f1bf2df47 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Fri, 16 Oct 2026 16:34:30 -0700 Subject: [PATCH 8/8] release: 1.38.0 notes, docs and version Release notes, README highlight and compatibility, state-consistency contract for notes and session close, backlog (note concurrency done), qa:close documented as a supervised harness. Gates on this commit's tree: tsc 0, vitest 1420 passed, check:docs, qa:maintenance, qa:resilience, qa:session-sync, qa:audit (0 violations), qa (0 console/page errors); supervised qa:close (22/22) and qa:link (0 failing checks). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YTT8PjCsRNq19Rc53mSSta --- README.md | 4 ++-- docs/backlog.md | 5 ++++- docs/quality.md | 2 +- docs/releases/1.38.0.md | 36 ++++++++++++++++++++++++++++++++++++ docs/state-consistency.md | 4 +++- package-lock.json | 4 ++-- package.json | 2 +- 7 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 docs/releases/1.38.0.md diff --git a/README.md b/README.md index 8169482..4521a7a 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.37.9** adds confirmed session renames and task conflict protection ([release notes](docs/releases/1.37.9.md)). Use 1.37.9 on both machines for the new write protocol. See [the maintenance review](docs/quality-review-2026-09-08.md) and [session synchronization review](docs/session-sync-review-2026-09-08.md). +> **1.38.0** makes closing a session reliable across paired PCs, fixes stale terminal drawing, adds **Apply update** for Claude Code on Windows, protects notes from being overwritten by another device, and cuts background CPU use ([release notes](docs/releases/1.38.0.md)). Use 1.38.0 on both machines for confirmed remote close and versioned notes; 1.37.9 added confirmed session renames and task conflict protection ([notes](docs/releases/1.37.9.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 @@ -83,7 +83,7 @@ Both machines must be reachable, for example on the same LAN, through an existin | Application settings | Belong to the PC whose Settings screen you are using. | | Terminal screen | The host provides bounded recent output for reconstruction, not an unlimited durable archive. | -**Compatibility:** confirmed titles and task editing need compatible 1.37.9-or-newer builds on both ends. The app checks title-version support and refuses unversioned writes rather than silently treating a viewer-only rename as shared success. Titles of disconnected or ended sessions cannot be remotely committed. +**Compatibility:** confirmed titles and task editing need compatible 1.37.9-or-newer builds on both ends; confirmed remote close and versioned notes need 1.38.0 on both ends (with an older peer they fall back to the previous behavior). The app checks title-version support and refuses unversioned writes rather than silently treating a viewer-only rename as shared success. Titles of disconnected or ended sessions cannot be remotely committed. Reconnecting reads the host's current title; it must not send an old cached title back. Clearing a custom title is an explicit change to the default project name, not an invitation to restore the old name. See [state ownership and consistency](docs/state-consistency.md) for the implementation contract and remaining gaps. diff --git a/docs/backlog.md b/docs/backlog.md index 7e843d1..3940602 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -12,9 +12,12 @@ Updated 2026-09-08. The 1.37.9 maintenance work was released on 2026-10-15. Impl | P2 | Persistent terminal history | Separate screen reconstruction from archive; bounded retention, deletion/export, redaction and visible gaps | | P2 | Task operation API | Task-ID mutations after revision-checked baseline; explicit same-task conflicts and concurrent create/edit/delete/clear-completed checks | | P1 | Remaining shared-metadata migrations | Apply the [ownership contract](state-consistency.md) to notes, project flags and Link settings; explicit failure/conflict UI and multi-observer QA | -| P2 | Note concurrency | Notes remain last-write-wins across devices; add revision checks while preserving drafts | | P2 | Focused module extraction | Terminal lifecycle/rendering boundaries protected by regression tests; no rewrite solely for file length | +## Implemented in 1.38.0 + +Reliable session close across paired PCs (answered remote close, offline closes delivered on reconnect, tombstones against re-adoption, `closed` exits), DOM terminal rendering, Apply update for Claude Code, versioned notes, Windows process-tree kill, Link pre-auth/notify/event hardening, scan performance and spinner CPU reduction, and the supervised `qa:close` harness. See the [release notes](releases/1.38.0.md). + ## Implemented in 1.37.9 Revision-checked task writes, legacy refusal, conflict review with retained attempted change, settings save rollback and inline validation, js-yaml/Vitest advisory fixes, Node24 CI, maintenance regression gates, fail-closed performance preconditions, and current documentation/maintenance configuration. See the candidate review for evidence and limits. diff --git a/docs/quality.md b/docs/quality.md index f36be88..00ba5c2 100644 --- a/docs/quality.md +++ b/docs/quality.md @@ -28,7 +28,7 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res ## Manual harnesses -`qa:link` is a supervised two-app harness that may start installed provider CLIs. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. It has not become a deterministic CI performance gate; that conversion remains a separate task. +`qa:link` is a supervised two-app harness that may start installed provider CLIs. `qa:close` is a supervised two-app harness for the session close lifecycle (remote, offline, refused and bulk close, adoption focus, restart, Apply update); it starts the installed `claude` CLI in a throwaway repository and sends no prompts. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. It has not become a deterministic CI performance gate; that conversion remains a separate task. Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation. diff --git a/docs/releases/1.38.0.md b/docs/releases/1.38.0.md new file mode 100644 index 0000000..a0dc25c --- /dev/null +++ b/docs/releases/1.38.0.md @@ -0,0 +1,36 @@ +# DevDeck 1.38.0 + +## Closing sessions + +- A session closed on one PC disappears from every paired PC. It no longer stays behind as an "exited" tile that was saved and reopened as a previous session — or started again by an update restart. +- Closing several sessions at once no longer brings some of them back as dead tiles. +- Closing a session on another PC now waits for that PC's answer. If the PC is offline, the close is delivered when it reconnects (also after restarting DevDeck). If this device may not control that PC's sessions, the tile comes back with an explanation instead of looking closed while the agent keeps running. +- Closing a session on Windows also ends what the agent started in the background (MCP servers, hidden dev servers). +- A renderer crash no longer stops every running agent; the window reloads and picks the sessions back up. + +## Terminals + +- Terminals draw with the DOM renderer. Under the WebGL renderer, Claude Code's screen could keep stale cells — old status lines stacked down the screen and earlier text showing through gaps — until the window was minimized and restored. +- When Claude Code shows "Update installed · Restart to apply", the session header offers **Apply update**. It restarts the session on the new version and keeps the conversation; during a running turn it waits for the turn to finish. (Claude Code's own `/restart` is not available on Windows.) +- A session started from another PC no longer takes the screen or keyboard focus. +- "+ New session" on a remote session opens on that PC. Opening from the task board or the project memory window opens on the machine those projects belong to. +- Resizing the window no longer marks every idle session as working. + +## Data safety + +- Notes are saved with a revision. If another device saved the note first, both versions are shown so nothing is overwritten unseen. Older paired builds keep working without the check. +- Usage totals could double when two scans read the same growing transcript at once; they no longer do. +- The project card shows the real number of sessions instead of stopping at 5. + +## Performance + +- Working-session spinners cost roughly a quarter of the CPU they did (measured 16.0% → 3.6% of one core with 12 sessions, 6 working) and stop while DevDeck is not focused. +- Codex session lookups and usage scans no longer stall the app (100 projects: 18.8 s → 0.3 s; longest stall 84–99 ms → 6–11 ms). + +## Security and robustness + +- Paired machines: stricter checks for unauthenticated connections, request types and rate limits, event forgery, audit-log flooding, removed machines reconnecting, and remote writes to paths that are not real projects. +- Opening a folder in VS Code no longer expands `%VAR%` in its name; `;` in folder names no longer breaks Windows Terminal tabs. +- Electron 43.7, no open dependency advisories. + +Remote close confirmation and versioned notes need 1.38.0 on both PCs to take effect; with an older peer DevDeck falls back to the previous behavior. diff --git a/docs/state-consistency.md b/docs/state-consistency.md index 780ab68..fe2dc07 100644 --- a/docs/state-consistency.md +++ b/docs/state-consistency.md @@ -15,7 +15,9 @@ Related paths treated cached session labels as authority when attaching, used ba | Session title | Main process of the machine running the terminal | Permission + terminal incarnation + expected label revision; persist before publishing/acknowledging | All renderers, including the host, consume the owner snapshot. Drafts are separate. | | Saved session membership and pins | Each viewer | Local persistence; overlay host-owned titles when reconciling local live sessions | Never send cached titles back merely because a viewer attached or restarted. | | Project task list | Main process owning the project | Expected task revision; reject conflicts; no silent retry | Display returned snapshot; preserve unsaved attempted change. | -| Project notes/flags | Project-owning machine | Existing last-write-wins behavior | Not yet covered by title/task CAS guarantees. | +| Project notes | Project-owning machine | Expected note revision (`project:saveNote`, 1.38.0); legacy `setNote` still accepted and bumps the revision | On conflict show both the saved note and the attempted one; nothing is overwritten unseen. | +| Project flags (pin/hide) | Project-owning machine | Last-write-wins; remote writes only to scanned projects | Not yet covered by CAS guarantees. | +| Session membership (close) | Machine running the terminal | `cockpit:closeSession` answers ok/offline/denied; closes owed to an offline machine are kept (also across restarts) and delivered on reconnect | Closed ids are tombstoned until the machine stops listing them; an exit carries `closed`/`closedBy` so every deck removes the tile. | | Settings | Current PC | Await write; rollback or retain invalid draft with explicit feedback | No success state before persistence. Link settings still need broader failure coverage. | | Terminal input/output | Host terminal | Ordered byte stream; do not turn every keystroke into a storage transaction | Screen replay is bounded and is not an archival transcript. | diff --git a/package-lock.json b/package-lock.json index 2487478..7b73efd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devdeck", - "version": "1.37.9", + "version": "1.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devdeck", - "version": "1.37.9", + "version": "1.38.0", "license": "MIT", "dependencies": { "@fontsource-variable/geist": "5.3.0", diff --git a/package.json b/package.json index ab85b6c..700178c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devdeck", - "version": "1.37.9", + "version": "1.38.0", "description": "Project command deck — at-a-glance state + Claude Code, Codex, and Antigravity resume", "main": "dist/main/main.js", "type": "commonjs",