diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 335e21593..c59bebcaf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,5 +33,10 @@ jobs:
- name: Test security rules
run: npm run test:security-rules
- - name: Test
- run: npm test
+ - name: Test (unit with coverage floor, client)
+ run: npm run test:ci
+
+ # The coordinator's prompt-delivery path against a real pty. Gated by
+ # env locally because it takes ~20s and needs a working shell.
+ - name: Test (coordinator against a real pty)
+ run: npm run test:coordinator-pty
diff --git a/PRIVACY.md b/PRIVACY.md
index 4226f6635..c6a2f9992 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -54,8 +54,8 @@ Unlike the AI CLIs above, the following network activity is initiated by Paralle
- **Inline code Q&A** — by default, the inline Q&A feature uses the Claude Code CLI as a local subprocess. The prompt sent to that CLI includes your question, a fixed instruction, and the selection you asked about — either a code snippet with file path and line range, or a markdown snippet from the in-app plan viewer with its source heading and an approximate position within the document. When you enable the optional [MiniMax](https://www.minimax.io/) provider in Settings, MiniMax is called directly by Parallel Code rather than as a subprocess: Parallel Code itself makes an HTTPS request to `api.minimax.io` using your API key, with the same kind of selected context in the request body. Your MiniMax API key is held in the main process in memory only and is not written to disk. Data sent to MiniMax is governed by MiniMax's own privacy policy, not this one.
- **Remote Access (mobile monitoring)** — when you start it from Settings, Parallel Code runs a local HTTP and WebSocket listener bound to all of your machine's network interfaces, so devices on your LAN — or any device that can reach an address shown by the app — can connect. No traffic is routed through infrastructure operated by the Parallel Code project.
- **Transport.** Traffic is unencrypted HTTP, and the access token is included in the URL (e.g. in the QR code). **Treat that URL as a credential.** Anything that captures the URL has the token until you restart Remote Access — including a photo of the QR code (which may be auto-backed up by your phone), mobile browser history or cross-device sync, clipboard managers, screen-sharing or screen-recording tools, and corporate TLS-inspection proxies or appliances that log HTTP URLs. Treat the network it runs on as trusted (a private LAN or a Tailscale tailnet), and stop the server when you're done.
- - **Tokens.** A fresh bearer token is generated each time the server starts and is not persisted by the desktop app; previous tokens become invalid on restart. The mobile client stores the token it receives in its browser `localStorage` so the same device can reconnect without rescanning; the token persists there in plaintext until you clear browser data on the device or restart Remote Access.
- - **Capabilities.** An authenticated mobile client is **read-only** — it can see the list of running agents and their recent terminal output, but cannot send input or stop agents.
+ - **Tokens.** A fresh bearer token is generated each time the server starts and is not persisted by the desktop app; previous tokens become invalid on restart. The mobile client stores the token it receives in its browser `localStorage` so the same device can reconnect without rescanning; the token persists there in plaintext until you clear browser data on the device or restart Remote Access. Pairing mints a second token that the phone stores the same way; it, too, is invalidated by a restart and the phone must pair again.
+ - **Capabilities.** A client holding only the URL token is **read-only** — it can see the list of running agents, their recent terminal output, and task notes, but cannot send input, stop agents, edit notes, or create tasks. Typing into agents, editing notes, and creating tasks require **pairing**: entering a short-lived 6-digit code shown on the desktop, which mints a separate paired token on that phone. Resizing and stopping agents are never available to a phone.
- **"Tailscale-like" detection.** Parallel Code labels an interface as Tailscale-like when it finds a non-internal IPv4 address beginning with `100.`; addresses starting with `172.` (often used by Docker bridges) are excluded from the displayed "WiFi" URL. The check is heuristic and does not verify the interface is actually Tailscale, so only treat that option as Tailscale if you know the address belongs to your tailnet. If your host has a VPN, virtualisation bridge, or unusual NIC, the URL shown may be reachable from a wider network than your LAN.
- **Over Tailscale.** Traffic is carried by your tailnet — typically a direct WireGuard connection between your devices, but Tailscale's coordination service and (when direct connection is not possible) DERP relays may be involved per [Tailscale's network architecture](https://tailscale.com/kb/1257/connection-types). How Tailscale handles that traffic is governed by Tailscale's own policies, not this one.
- **Sub-task coordinator (MCP)** — when sub-tasks run under a coordinator agent, Parallel Code starts a local token-protected HTTP/WebSocket server so sub-task agents can call back into the app (e.g. to signal completion). No traffic from this feature passes through infrastructure operated by the Parallel Code project.
diff --git a/electron/remote/notes-route.test.ts b/electron/remote/notes-route.test.ts
index 1b7d1b1f9..3a48ec35e 100644
--- a/electron/remote/notes-route.test.ts
+++ b/electron/remote/notes-route.test.ts
@@ -27,6 +27,7 @@ let port = 0;
let mobileToken = '';
let coordinatorToken = '';
let subtaskToken = '';
+let generatePin: () => { pin: string; expiresAt: number };
let stop: () => Promise;
let getTaskNotes: Mock<(taskId: string) => Promise>;
let setTaskNotes: Mock<(taskId: string, notes: string) => Promise>;
@@ -47,9 +48,18 @@ async function start(extra: Partial = {}): Promise {
mobileToken = srv.mobileToken;
coordinatorToken = srv.token;
subtaskToken = srv.subtaskToken;
+ generatePin = srv.generatePairingPin;
stop = srv.stop;
}
+/** Elevate the mobile token to a paired one via the desktop PIN. */
+async function pair(): Promise {
+ const { pin } = generatePin();
+ const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } });
+ expect(res.status).toBe(201);
+ return (res.json as { token: string }).token;
+}
+
beforeEach(() => {
getTaskNotes = vi.fn(async (_taskId: string) => 'stored notes');
setTaskNotes = vi.fn(async (_taskId: string, _notes: string) => {});
@@ -160,9 +170,9 @@ describe('GET/PUT notes — malformed and dangerous task ids', () => {
describe('PUT /api/mobile/notes/:taskId', () => {
beforeEach(() => start());
- it('saves notes for a mobile token', async () => {
+ it('saves notes for a paired token', async () => {
const res = await request('PUT', '/api/mobile/notes/task-1', {
- token: mobileToken,
+ token: await pair(),
body: { notes: 'hello world' },
});
expect(res.status).toBe(200);
@@ -170,6 +180,16 @@ describe('PUT /api/mobile/notes/:taskId', () => {
expect(setTaskNotes).toHaveBeenCalledWith('task-1', 'hello world');
});
+ it('returns 403 for the read-only mobile token (writes need pairing)', async () => {
+ const res = await request('PUT', '/api/mobile/notes/task-1', {
+ token: mobileToken,
+ body: { notes: 'hello world' },
+ });
+ expect(res.status).toBe(403);
+ expect(res.json).toEqual({ error: 'pairing required' });
+ expect(setTaskNotes).not.toHaveBeenCalled();
+ });
+
it('returns 403 for a coordinator token', async () => {
const res = await request('PUT', '/api/mobile/notes/task-1', {
token: coordinatorToken,
@@ -181,7 +201,7 @@ describe('PUT /api/mobile/notes/:taskId', () => {
it('rejects a non-string notes body with 400', async () => {
const res = await request('PUT', '/api/mobile/notes/task-1', {
- token: mobileToken,
+ token: await pair(),
body: { notes: 123 },
});
expect(res.status).toBe(400);
@@ -190,7 +210,7 @@ describe('PUT /api/mobile/notes/:taskId', () => {
it('rejects notes larger than 100 KB with 400', async () => {
const res = await request('PUT', '/api/mobile/notes/task-1', {
- token: mobileToken,
+ token: await pair(),
body: { notes: 'x'.repeat(100 * 1024 + 1) },
});
expect(res.status).toBe(400);
@@ -200,7 +220,7 @@ describe('PUT /api/mobile/notes/:taskId', () => {
it('accepts multi-line notes up to the limit', async () => {
const notes = 'line\n'.repeat(1000);
const res = await request('PUT', '/api/mobile/notes/task-1', {
- token: mobileToken,
+ token: await pair(),
body: { notes },
});
expect(res.status).toBe(200);
@@ -218,7 +238,7 @@ describe('notes route without a renderer bridge', () => {
it('returns 503 for PUT when notes are unavailable', async () => {
const res = await request('PUT', '/api/mobile/notes/task-1', {
- token: mobileToken,
+ token: await pair(),
body: { notes: 'x' },
});
expect(res.status).toBe(503);
diff --git a/electron/remote/server-ws.test.ts b/electron/remote/server-ws.test.ts
index 2fde6a2a5..5b78b35b0 100644
--- a/electron/remote/server-ws.test.ts
+++ b/electron/remote/server-ws.test.ts
@@ -1,9 +1,14 @@
// WebSocket-level access control for the remote server.
-// Mobile clients may type into agent terminals (input) but must not be able
-// to resize the PTY or kill agents.
+// The QR-code (mobile) token only watches: it can subscribe to output but
+// must not type, resize, or kill. Typing needs the paired token (PIN entered
+// on the phone); resize and kill stay coordinator-only. Browser pages from
+// any other origin are refused at the upgrade, before any token is seen.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import WebSocket from 'ws';
+import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
vi.mock('../ipc/pty.js', () => ({
writeToAgent: vi.fn(),
@@ -19,13 +24,26 @@ vi.mock('../ipc/pty.js', () => ({
}));
const pty = await import('../ipc/pty.js');
-const { startRemoteServer } = await import('./server.js');
+const { startRemoteServer, isBrowserOriginAllowed, buildRemoteCsp } = await import('./server.js');
let port = 0;
let coordinatorToken = '';
let mobileToken = '';
+let generatePin: () => { pin: string; expiresAt: number };
let stop: () => Promise;
+/** Elevate the mobile token to a paired one via the desktop PIN. */
+async function pair(): Promise {
+ const { pin } = generatePin();
+ const res = await fetch(`http://127.0.0.1:${port}/api/pair/verify`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${mobileToken}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pin }),
+ });
+ expect(res.status).toBe(201);
+ return ((await res.json()) as { token: string }).token;
+}
+
beforeEach(async () => {
const srv = await startRemoteServer({
port: 0,
@@ -38,6 +56,7 @@ beforeEach(async () => {
port = srv.port;
coordinatorToken = srv.token;
mobileToken = srv.mobileToken;
+ generatePin = srv.generatePairingPin;
stop = srv.stop;
vi.clearAllMocks();
});
@@ -47,9 +66,9 @@ afterEach(async () => {
});
/** Connect and authenticate; resolves once the server replies (agents list). */
-function connectAndAuth(token: string): Promise {
+function connectAndAuth(token: string, headers?: Record): Promise {
return new Promise((resolve, reject) => {
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers });
ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token })));
ws.once('message', () => resolve(ws));
ws.on('close', (code) => reject(new Error(`closed before auth ack: ${code}`)));
@@ -70,27 +89,22 @@ function waitForClose(ws: WebSocket): Promise {
}
describe('mobile token over WebSocket', () => {
- it('forwards input to the agent PTY', async () => {
+ it('authenticates and can subscribe to agent output', async () => {
const ws = await connectAndAuth(mobileToken);
- ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'hi' }));
+ ws.send(JSON.stringify({ type: 'subscribe', agentId: 'agent-1' }));
await vi.waitFor(() => {
- expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'hi');
+ expect(pty.subscribeToAgent).toHaveBeenCalledWith('agent-1', expect.any(Function));
});
expect(ws.readyState).toBe(WebSocket.OPEN);
ws.close();
});
- it('silently drops oversized input (>4096 chars) without closing', async () => {
+ it('rejects input with 4003 (pairing required) and never reaches the PTY', async () => {
const ws = await connectAndAuth(mobileToken);
- ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'x'.repeat(4097) }));
- // Probe with a valid message to ensure the oversized one was processed first
- ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'ok' }));
- await vi.waitFor(() => {
- expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'ok');
- });
- expect(pty.writeToAgent).not.toHaveBeenCalledWith('agent-1', 'x'.repeat(4097));
- expect(ws.readyState).toBe(WebSocket.OPEN);
- ws.close();
+ const closed = waitForClose(ws);
+ ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'rm -rf ~' }));
+ expect(await closed).toBe(4003);
+ expect(pty.writeToAgent).not.toHaveBeenCalled();
});
it('rejects resize with 4003 and does not resize the PTY', async () => {
@@ -110,6 +124,200 @@ describe('mobile token over WebSocket', () => {
});
});
+describe('paired token over WebSocket', () => {
+ it('forwards input to the agent PTY', async () => {
+ const ws = await connectAndAuth(await pair());
+ ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'hi' }));
+ await vi.waitFor(() => {
+ expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'hi');
+ });
+ expect(ws.readyState).toBe(WebSocket.OPEN);
+ ws.close();
+ });
+
+ it('silently drops oversized input (>4096 chars) without closing', async () => {
+ const ws = await connectAndAuth(await pair());
+ ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'x'.repeat(4097) }));
+ // Probe with a valid message to ensure the oversized one was processed first
+ ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'ok' }));
+ await vi.waitFor(() => {
+ expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'ok');
+ });
+ expect(pty.writeToAgent).not.toHaveBeenCalledWith('agent-1', 'x'.repeat(4097));
+ expect(ws.readyState).toBe(WebSocket.OPEN);
+ ws.close();
+ });
+
+ it('still cannot resize or kill (4003)', async () => {
+ const paired = await pair();
+ const ws1 = await connectAndAuth(paired);
+ const closed1 = waitForClose(ws1);
+ ws1.send(JSON.stringify({ type: 'resize', agentId: 'agent-1', cols: 80, rows: 24 }));
+ expect(await closed1).toBe(4003);
+
+ const ws2 = await connectAndAuth(paired);
+ const closed2 = waitForClose(ws2);
+ ws2.send(JSON.stringify({ type: 'kill', agentId: 'agent-1' }));
+ expect(await closed2).toBe(4003);
+
+ expect(pty.resizeAgent).not.toHaveBeenCalled();
+ expect(pty.killAgent).not.toHaveBeenCalled();
+ });
+
+ it('a paired token from a stopped server is refused (4001)', async () => {
+ const paired = await pair();
+ await stop();
+ const srv = await startRemoteServer({
+ port: 0,
+ host: '127.0.0.1',
+ staticDir: '/nonexistent',
+ getTaskName: (id) => id,
+ getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }),
+ getCoordinator: () => null,
+ });
+ port = srv.port;
+ stop = srv.stop;
+ await expect(connectAndAuth(paired)).rejects.toThrow('4001');
+ });
+});
+
+describe('browser Origin on the WebSocket upgrade', () => {
+ /** Attempt an upgrade with the given headers; resolves with the HTTP status when refused. */
+ function upgradeStatus(headers: Record): Promise {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers });
+ ws.on('unexpected-response', (_req, res) => {
+ resolve(res.statusCode ?? 0);
+ res.resume();
+ ws.terminate();
+ });
+ ws.on('open', () => {
+ resolve('open');
+ ws.close();
+ });
+ ws.on('error', (err) => {
+ // ws also emits 'error' after 'unexpected-response'; only surface it
+ // when nothing else resolved first.
+ reject(err);
+ });
+ });
+ }
+
+ it('refuses an Origin that does not match the Host (403)', async () => {
+ expect(await upgradeStatus({ Origin: 'http://evil.example' })).toBe(403);
+ });
+
+ it('refuses an opaque "null" Origin (403)', async () => {
+ expect(await upgradeStatus({ Origin: 'null' })).toBe(403);
+ });
+
+ it('accepts the origin this server itself serves', async () => {
+ expect(await upgradeStatus({ Origin: `http://127.0.0.1:${port}` })).toBe('open');
+ });
+
+ it('accepts non-browser clients that send no Origin', async () => {
+ expect(await upgradeStatus({})).toBe('open');
+ });
+
+ it('a cross-origin page with a valid token still cannot type', async () => {
+ await expect(connectAndAuth(await pair(), { Origin: 'http://evil.example' })).rejects.toThrow();
+ expect(pty.writeToAgent).not.toHaveBeenCalled();
+ });
+});
+
+describe('browser Origin on HTTP API routes', () => {
+ it('refuses a cross-origin fetch even with a valid token (403)', async () => {
+ const res = await fetch(`http://127.0.0.1:${port}/api/agents`, {
+ headers: { Authorization: `Bearer ${coordinatorToken}`, Origin: 'http://evil.example' },
+ });
+ expect(res.status).toBe(403);
+ });
+
+ it('serves a same-origin fetch', async () => {
+ const res = await fetch(`http://127.0.0.1:${port}/api/agents`, {
+ headers: {
+ Authorization: `Bearer ${coordinatorToken}`,
+ Origin: `http://127.0.0.1:${port}`,
+ },
+ });
+ expect(res.status).toBe(200);
+ });
+});
+
+describe('isBrowserOriginAllowed', () => {
+ it('allows requests without an Origin (non-browser clients)', () => {
+ expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777' })).toBe(true);
+ });
+
+ it('requires the Origin host to equal the Host header, case-insensitively', () => {
+ expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777', origin: 'http://10.0.0.2:7777' })).toBe(
+ true,
+ );
+ expect(
+ isBrowserOriginAllowed({ host: 'Desktop.local:7777', origin: 'http://desktop.local:7777' }),
+ ).toBe(true);
+ expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777', origin: 'http://10.0.0.2:7778' })).toBe(
+ false,
+ );
+ expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777', origin: 'http://attacker.test' })).toBe(
+ false,
+ );
+ });
+
+ it('refuses opaque, malformed, and non-http origins', () => {
+ expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'null' })).toBe(false);
+ expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'not a url' })).toBe(false);
+ expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'file://a:1' })).toBe(false);
+ // Node joins duplicate Origin headers with ", " — not a URL, so refused.
+ expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'http://a:1, http://b:1' })).toBe(false);
+ });
+
+ it('refuses an Origin when the request carries no Host', () => {
+ expect(isBrowserOriginAllowed({ origin: 'http://a:1' })).toBe(false);
+ });
+});
+
+describe('buildRemoteCsp', () => {
+ it('pins scripts to the bundle and the socket to the requested host', () => {
+ const csp = buildRemoteCsp('10.0.0.2:7777');
+ expect(csp).toContain("script-src 'self';");
+ expect(csp).toContain("connect-src 'self' ws://10.0.0.2:7777 wss://10.0.0.2:7777");
+ expect(csp).toContain("object-src 'none'");
+ expect(csp).toContain("frame-ancestors 'none'");
+ expect(csp).not.toContain('unsafe-eval');
+ });
+
+ it('drops a Host that could inject directives', () => {
+ const csp = buildRemoteCsp("x; script-src 'unsafe-inline'");
+ expect(csp).toContain("connect-src 'self';");
+ expect(csp).not.toContain('x; script-src');
+ expect(buildRemoteCsp(undefined)).toContain("connect-src 'self';");
+ });
+
+ it('is sent with the mobile SPA static responses', async () => {
+ await stop();
+ const staticDir = mkdtempSync(join(tmpdir(), 'pc-remote-static-'));
+ writeFileSync(join(staticDir, 'index.html'), 'x');
+ const srv = await startRemoteServer({
+ port: 0,
+ host: '127.0.0.1',
+ staticDir,
+ getTaskName: (id) => id,
+ getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }),
+ getCoordinator: () => null,
+ });
+ port = srv.port;
+ stop = async () => {
+ await srv.stop();
+ rmSync(staticDir, { recursive: true, force: true });
+ };
+ const res = await fetch(`http://127.0.0.1:${port}/`);
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-security-policy')).toBe(buildRemoteCsp(`127.0.0.1:${port}`));
+ expect(res.headers.get('x-frame-options')).toBe('DENY');
+ });
+});
+
describe('unauthenticated WebSocket clients', () => {
function connectRaw(): Promise {
return new Promise((resolve, reject) => {
diff --git a/electron/remote/server.ts b/electron/remote/server.ts
index 85ac9691d..66745074c 100644
--- a/electron/remote/server.ts
+++ b/electron/remote/server.ts
@@ -82,6 +82,68 @@ export function getMCPLogs(): MCPLogEntry[] {
return mcpLogs.slice();
}
+interface OriginHeaders {
+ origin?: string;
+ host?: string;
+}
+
+/**
+ * Browser clients send an `Origin` header on WebSocket upgrades and on
+ * cross-site fetches; non-browser clients (the MCP coordinator client, curl)
+ * send none. A page this server itself served has an Origin whose host equals
+ * the request's `Host`; any other Origin is some other site — a tab open on
+ * the LAN, a page that guessed the desktop's address — using the user's
+ * browser to reach the server. WebSockets are exempt from the same-origin
+ * policy, so without this a foreign page could at least open a socket and
+ * probe. (Auth still needs the token, which lives in the SPA's own
+ * localStorage and is unreadable cross-origin; that, not this check, is what
+ * defeats DNS rebinding.) No Origin is allowed: the token still gates every
+ * API route and socket.
+ */
+export function isBrowserOriginAllowed(headers: OriginHeaders): boolean {
+ const origin = headers.origin;
+ if (origin === undefined) return true;
+ const host = headers.host;
+ if (!host) return false;
+ let parsed: URL;
+ try {
+ parsed = new URL(origin);
+ } catch {
+ return false; // "null" (sandboxed/opaque origin) or garbage
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
+ return parsed.host.toLowerCase() === host.toLowerCase();
+}
+
+/**
+ * Content-Security-Policy for the mobile SPA. The SPA is a Solid bundle with
+ * inline `style` attributes, an xterm canvas, and one WebSocket back to this
+ * server; nothing loads from anywhere else. Scripts are restricted to the
+ * bundle so an injected string can never become code in the page that holds
+ * the terminal token. The socket target is derived from the request's Host so
+ * `connect-src` stays tight whichever IP the phone reached us on.
+ */
+export function buildRemoteCsp(host: string | undefined): string {
+ // Host lands inside a header value: only accept hostname/IP/port characters
+ // so a crafted Host cannot append directives.
+ const safeHost = host && /^[A-Za-z0-9.\-:[\]]{1,255}$/.test(host) ? host : null;
+ const socketSources = safeHost ? ` ws://${safeHost} wss://${safeHost}` : '';
+ return [
+ "default-src 'self'",
+ "script-src 'self'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob:",
+ "font-src 'self' data:",
+ `connect-src 'self'${socketSources}`,
+ "worker-src 'self' blob:",
+ "manifest-src 'self'",
+ "object-src 'none'",
+ "base-uri 'none'",
+ "frame-ancestors 'none'",
+ "form-action 'self'",
+ ].join('; ');
+}
+
function parseLandSelfInput(body: Record): LandSelfInput | string {
const summary = body.summary;
if (summary !== undefined && typeof summary !== 'string') return 'summary must be a string';
@@ -697,6 +759,11 @@ export function startRemoteServer(opts: {
// the server, consistent with the mobile/coordinator tokens above. One entry
// per paired phone.
const pairedTokenBufs: Buffer[] = [];
+ // Bound the credential set: pairing is a user action on the desktop, so a
+ // handful covers every phone a person owns; beyond that the oldest is
+ // dropped. Eviction only stops future authentication with that token — a
+ // socket it already opened stays authenticated until it reconnects.
+ const MAX_PAIRED_TOKENS = 8;
// At most one pending PIN at a time — a fresh mint replaces any prior one.
let pairing: { pinBuf: Buffer; expiresAt: number; attemptsLeft: number } | null = null;
@@ -755,6 +822,9 @@ export function startRemoteServer(opts: {
pairing = null; // single-use
const pairedToken = randomBytes(24).toString('base64url');
pairedTokenBufs.push(Buffer.from(pairedToken));
+ if (pairedTokenBufs.length > MAX_PAIRED_TOKENS) {
+ pairedTokenBufs.splice(0, pairedTokenBufs.length - MAX_PAIRED_TOKENS);
+ }
return { ok: true, token: pairedToken };
}
@@ -769,6 +839,11 @@ export function startRemoteServer(opts: {
// --- API routes (require auth) ---
if (url.pathname.startsWith('/api/')) {
+ if (!isBrowserOriginAllowed(req.headers)) {
+ res.writeHead(403, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'forbidden origin' }));
+ return;
+ }
const tokenClass = classifyToken(req);
if (tokenClass === null) {
res.writeHead(401, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
@@ -844,10 +919,11 @@ export function startRemoteServer(opts: {
return jsonEnd(405, { error: 'method not allowed' });
}
- // --- Task notes (mobile + paired) ---
- // Read/write the notes textarea shown on the desktop task panel. Available
- // to the read-only mobile token too: editing notes is low-risk and mirrors
- // the "interact with your terminals" capability the mobile token already has.
+ // --- Task notes (read: mobile + paired; write: paired) ---
+ // The notes textarea shown on the desktop task panel. The QR-code mobile
+ // token may read notes; writing them (text that lands in the desktop UI
+ // and can be sent to an agent as a prompt) needs the paired token, like
+ // every other write.
const notesMatch = url.pathname.match(/^\/api\/mobile\/notes\/([^/]+)$/);
if (notesMatch) {
if (tokenClass !== 'mobile' && tokenClass !== 'paired')
@@ -878,6 +954,7 @@ export function startRemoteServer(opts: {
}
if (req.method === 'PUT') {
+ if (tokenClass !== 'paired') return jsonEnd(403, { error: 'pairing required' });
const setTaskNotes = opts.setTaskNotes;
if (!setTaskNotes) return jsonEnd(503, { error: 'notes unavailable' });
// Cap the body generously above MAX_NOTES_BYTES so the precise byte
@@ -1056,7 +1133,15 @@ export function startRemoteServer(opts: {
const serveFile = (path: string, ct: string, cc: string) => {
const stream = createReadStream(path);
- res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': ct, 'Cache-Control': cc });
+ res.writeHead(200, {
+ ...SECURITY_HEADERS,
+ // The policy governs documents; assets only need to be served by one.
+ ...(ct.startsWith('text/html')
+ ? { 'Content-Security-Policy': buildRemoteCsp(req.headers.host) }
+ : {}),
+ 'Content-Type': ct,
+ 'Cache-Control': cc,
+ });
stream.pipe(res);
stream.on('error', () => {
if (!res.headersSent) {
@@ -1099,6 +1184,10 @@ export function startRemoteServer(opts: {
server,
maxPayload: 64 * 1024,
verifyClient: (info, cb) => {
+ if (!isBrowserOriginAllowed(info.req.headers)) {
+ cb(false, 403, 'Forbidden origin');
+ return;
+ }
if (wss.clients.size >= 10) {
cb(false, 429, 'Too many connections');
return;
@@ -1111,7 +1200,7 @@ export function startRemoteServer(opts: {
const clientSubs = new WeakMap void>>();
const authenticatedClients = new Set();
- const clientTokenTypes = new Map();
+ const clientTokenTypes = new Map();
const authTimers = new WeakMap>();
function broadcast(msg: ServerMessage): void {
@@ -1157,10 +1246,12 @@ export function startRemoteServer(opts: {
const list = buildAgentList(opts.getTaskName, opts.getAgentStatus, getTaskAttention);
ws.send(JSON.stringify({ type: 'agents', list } satisfies ServerMessage));
} else {
- // Close unauthenticated connections after 5 seconds
+ // Close unauthenticated connections after 5 seconds. Distinct code from
+ // 4001: the phone treats 4001 as "my token is stale" and discards it,
+ // which a slow network must not trigger.
const authTimer = setTimeout(() => {
if (!authenticatedClients.has(ws)) {
- ws.close(4001, 'Auth timeout');
+ ws.close(4002, 'Auth timeout');
}
}, 5_000);
authTimers.set(ws, authTimer);
@@ -1170,11 +1261,12 @@ export function startRemoteServer(opts: {
const msg = parseClientMessage(String(raw));
if (!msg) return;
- // Handle first-message auth. Coordinator and mobile tokens grant WS
- // access; subtask tokens are denied.
+ // Handle first-message auth. Coordinator, mobile, and paired tokens
+ // grant WS access (with different write rights, below); subtask tokens
+ // are denied.
if (msg.type === 'auth') {
const tokenType = classifyCandidate(msg.token);
- if (tokenType === 'coordinator' || tokenType === 'mobile') {
+ if (tokenType === 'coordinator' || tokenType === 'mobile' || tokenType === 'paired') {
authenticatedClients.add(ws);
clientTokenTypes.set(ws, tokenType);
const timer = authTimers.get(ws);
@@ -1193,14 +1285,20 @@ export function startRemoteServer(opts: {
return;
}
- // Mobile clients may type into agent terminals (`input`) but cannot
- // resize the PTY (desktop owns the geometry) or kill agents — the
- // mobile token travels in a QR-code URL, so keep its blast radius small.
- if (clientTokenTypes.get(ws) === 'mobile') {
- if (msg.type === 'resize' || msg.type === 'kill') {
- ws.close(4003, 'Forbidden');
- return;
- }
+ // Write rights by token class. The mobile token travels in a QR-code
+ // URL over plain HTTP, so it is view-only: anything that can capture
+ // that URL must not be able to type into a shell on this machine.
+ // Typing (`input`) needs the paired token — the phone proved it can
+ // read the pairing PIN off the desktop screen. Resize (desktop owns the
+ // geometry) and kill stay coordinator-only.
+ const tokenType = clientTokenTypes.get(ws);
+ if (msg.type === 'input' && tokenType !== 'coordinator' && tokenType !== 'paired') {
+ ws.close(4003, 'Pairing required');
+ return;
+ }
+ if ((msg.type === 'resize' || msg.type === 'kill') && tokenType !== 'coordinator') {
+ ws.close(4003, 'Forbidden');
+ return;
}
switch (msg.type) {
@@ -1294,7 +1392,9 @@ export function startRemoteServer(opts: {
});
const primaryIp = ips.wifi ?? ips.tailscale ?? '127.0.0.1';
- // url embeds the mobileToken — safe to surface in UI. Coordinator token never leaves the main process.
+ // url embeds the mobileToken — a view-only credential (see the WS write
+ // gate above), so it is safe to surface in the UI and the QR code.
+ // Coordinator token never leaves the main process.
const url = `http://${primaryIp}:${opts.port}?token=${mobileToken}`;
const result: RemoteServer = {
diff --git a/electron/vite.config.electron.test.ts b/electron/vite.config.electron.test.ts
index 85e9cc26c..2a551b8cc 100644
--- a/electron/vite.config.electron.test.ts
+++ b/electron/vite.config.electron.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import config from './vite.config.electron';
+import config, { RENDERER_CSP } from './vite.config.electron';
describe('electron vite config', () => {
it('ignores nested worktree directories in dev watch mode', () => {
@@ -11,3 +11,35 @@ describe('electron vite config', () => {
expect(patterns).toContain('**/.worktrees/**');
});
});
+
+describe('renderer Content-Security-Policy', () => {
+ it('restricts scripts to the bundle and forbids plugins, framing, and base overrides', () => {
+ expect(RENDERER_CSP).toContain("script-src 'self' 'wasm-unsafe-eval'");
+ expect(RENDERER_CSP).toContain("object-src 'none'");
+ expect(RENDERER_CSP).toContain("base-uri 'none'");
+ expect(RENDERER_CSP).toContain("frame-src 'none'");
+ expect(RENDERER_CSP).not.toContain("'unsafe-eval'");
+ expect(RENDERER_CSP).not.toMatch(/script-src[^;]*unsafe-inline/);
+ });
+
+ it('is injected into the built index.html, and only at build time', () => {
+ const plugins = (config.plugins ?? []).flat() as Array<{
+ name?: string;
+ apply?: unknown;
+ transformIndexHtml?: unknown;
+ }>;
+ const csp = plugins.find((p) => p?.name === 'parallel-code:renderer-csp');
+ expect(csp).toBeDefined();
+ expect(csp?.apply).toBe('build');
+ const hook = csp?.transformIndexHtml as
+ | (() => Array<{ tag: string; attrs: Record }>)
+ | undefined;
+ const tags = hook?.();
+ expect(tags).toEqual([
+ expect.objectContaining({
+ tag: 'meta',
+ attrs: { 'http-equiv': 'Content-Security-Policy', content: RENDERER_CSP },
+ }),
+ ]);
+ });
+});
diff --git a/electron/vite.config.electron.ts b/electron/vite.config.electron.ts
index c5d29107c..c3a90fc16 100644
--- a/electron/vite.config.electron.ts
+++ b/electron/vite.config.electron.ts
@@ -1,13 +1,58 @@
import path from 'path';
-import { defineConfig } from 'vite';
+import { defineConfig, type Plugin } from 'vite';
import solid from 'vite-plugin-solid';
const rootDir = path.resolve(process.cwd());
const parentDir = path.resolve(rootDir, '..');
+/**
+ * Content-Security-Policy for the packaged renderer. The renderer can spawn
+ * processes through IPC, so any script injection in it is code execution on
+ * the user's machine; the policy makes sure only the bundle itself runs.
+ *
+ * - script-src: the bundle plus 'wasm-unsafe-eval' for shiki's oniguruma
+ * engine (WebAssembly instantiation is blocked without it).
+ * - style-src 'unsafe-inline': Solid `style={{}}` attributes plus the style
+ * elements xterm, Monaco, and mermaid inject.
+ * - img-src http(s): images linked from rendered markdown (notes, plans).
+ * - worker-src blob:: Monaco language workers.
+ *
+ * Applied at build time only: the dev server injects its own client and HMR
+ * socket, which this policy would block.
+ */
+export const RENDERER_CSP = [
+ "default-src 'self'",
+ "script-src 'self' 'wasm-unsafe-eval'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob: http: https:",
+ "font-src 'self' data:",
+ "connect-src 'self'",
+ "worker-src 'self' blob:",
+ "media-src 'self' blob:",
+ "object-src 'none'",
+ "base-uri 'none'",
+ "frame-src 'none'",
+].join('; ');
+
+function rendererCspPlugin(): Plugin {
+ return {
+ name: 'parallel-code:renderer-csp',
+ apply: 'build',
+ transformIndexHtml() {
+ return [
+ {
+ tag: 'meta',
+ attrs: { 'http-equiv': 'Content-Security-Policy', content: RENDERER_CSP },
+ injectTo: 'head-prepend',
+ },
+ ];
+ },
+ };
+}
+
export default defineConfig({
base: './',
- plugins: [solid()],
+ plugins: [solid(), rendererCspPlugin()],
clearScreen: false,
server: {
port: 1421,
diff --git a/package.json b/package.json
index a68a43519..f533b8056 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"test:client": "vitest run --config vitest.client.config.ts",
"test:coordinator-pty": "RUN_COORDINATOR_PTY_TEST=1 vitest run electron/mcp/coordinator-real-pty.integration.test.ts",
"test:coverage": "vitest run --coverage",
+ "test:ci": "vitest run --coverage && npm run test:client",
"check:coordinator-log": "node scripts/check-coordinator-run.mjs",
"check": "npm run compile && npm run typecheck && npm run lint && npm run format:check",
"check:static": "npm run typecheck && npm run lint && npm run lint:dead && npm run lint:arch",
diff --git a/src/arena/BattleScreen.tsx b/src/arena/BattleScreen.tsx
index a61de796b..1e74efa90 100644
--- a/src/arena/BattleScreen.tsx
+++ b/src/arena/BattleScreen.tsx
@@ -126,6 +126,7 @@ export function BattleScreen() {
- Pair a device to create tasks
+ Pair a device to type and create tasks
{pairingError()}
@@ -560,16 +560,16 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) {
}}
>
- Anyone on your network with the link can view your agent terminals and type into
- running agents.
+ Anyone on your network with the link can watch your agent terminals. Typing into
+ agents, editing notes, and creating tasks require pairing with the code above.
The Wi-Fi connection is unencrypted. Prefer Tailscale, or only connect on a network
you trust.
- A paired phone can create new tasks, which run code on this computer, until you
- disconnect.
+ A paired phone can type into agents and create new tasks, which run code on this
+ computer, until you disconnect.
Disconnecting stops the server and revokes every connected and paired device.
diff --git a/src/components/MergeDialog.tsx b/src/components/MergeDialog.tsx
index ab3e369b6..741628111 100644
--- a/src/components/MergeDialog.tsx
+++ b/src/components/MergeDialog.tsx
@@ -20,6 +20,7 @@ import { theme, bannerStyle } from '../lib/theme';
import type { CoverageComparison } from '../lib/coverage-comparison';
import type { Task } from '../store/types';
import type { ChangedFile, MergeStatus, WorktreeStatus } from '../ipc/types';
+import { errMessage } from '../lib/log';
interface MergeDialogProps {
open: boolean;
@@ -301,7 +302,7 @@ export function MergeDialog(props: MergeDialogProps) {
refetchBranchLog();
refetchWorktreeStatus();
} catch (err) {
- setRebaseError(String(err));
+ setRebaseError(errMessage(err));
} finally {
setRebasing(false);
}
@@ -584,8 +585,8 @@ export function MergeDialog(props: MergeDialogProps) {
.then(() => {
onDone();
})
- .catch((err) => {
- setMergeError(String(err));
+ .catch((err: unknown) => {
+ setMergeError(errMessage(err));
})
.finally(() => {
setMerging(false);
diff --git a/src/components/PushDialog.tsx b/src/components/PushDialog.tsx
index 6b30cd210..c49eb6360 100644
--- a/src/components/PushDialog.tsx
+++ b/src/components/PushDialog.tsx
@@ -4,6 +4,7 @@ import { Channel } from '../lib/ipc';
import { Dialog } from './Dialog';
import { theme, bannerStyle } from '../lib/theme';
import type { Task } from '../store/types';
+import { errMessage } from '../lib/log';
interface PushDialogProps {
open: boolean;
@@ -57,8 +58,8 @@ export function PushDialog(props: PushDialogProps) {
.then(() => {
onDone(true);
})
- .catch((err) => {
- setPushError(String(err));
+ .catch((err: unknown) => {
+ setPushError(errMessage(err));
onDone(false);
})
.finally(() => {
diff --git a/src/components/TaskAITerminal.tsx b/src/components/TaskAITerminal.tsx
index 9d2be526b..29d457b6a 100644
--- a/src/components/TaskAITerminal.tsx
+++ b/src/components/TaskAITerminal.tsx
@@ -19,8 +19,7 @@ import {
showNotification,
toggleAITerminalLayout,
} from '../store/store';
-import { markDirty, redrawTerminal } from '../lib/terminalFitManager';
-import { isMac } from '../lib/platform';
+import { markDirty } from '../lib/terminalFitManager';
import { warn as logWarn } from '../lib/log';
import { InfoBar } from './InfoBar';
import { TerminalView } from './TerminalView';
@@ -119,17 +118,14 @@ export function TaskAITerminal(props: TaskAITerminalProps) {
// In tabs mode only the selected pane is shown; the others stay mounted but
// hidden (visibility:hidden) so their pty sessions and scrollback survive the
- // switch. As a pane becomes the visible tab, re-fit it (its container may have
- // resized while hidden) and, on macOS, force a repaint: a backgrounded WebGL
- // pane can return with a corrupt glyph atlas, and TerminalView's issue-#121
- // redraw keys off focus mode — which never toggles for a within-task tab
- // switch — so it wouldn't fire here.
+ // switch. As a pane becomes the visible tab, re-fit it (its container may
+ // have resized while hidden). The repaint / WebGL reattach on that edge is
+ // TerminalView's job, driven by the `visible` prop passed below.
createEffect(() => {
if (!tabsMode()) return;
const id = visibleAgentId();
if (!id) return;
markDirty(id);
- if (isMac) redrawTerminal(id);
});
const infoBarStatus = () => {
@@ -681,6 +677,7 @@ function AgentTerminalPane(props: {
{
- const visible = !store.focusMode || store.activeTaskId === taskId;
- if (prevVisible === false && visible) redrawTerminal(agentId);
- prevVisible = visible;
- });
- }
+ // Whether this pane is on screen: the active task in focus mode, a task
+ // not scrolled fully out of view in tiling mode, and the selected tab
+ // within the task. Hidden panes stay mounted (visibility:hidden keeps
+ // their layout so fit() never resizes the pty) but do not keep a WebGL
+ // context — see terminalPaneVisibility.ts.
+ const paneOnScreen = createMemo(() =>
+ isTerminalPaneOnScreen({
+ focusMode: store.focusMode,
+ activeTaskId: store.activeTaskId,
+ taskId,
+ viewportVisibility: store.taskViewportVisibility[taskId],
+ paneVisible: props.visible,
+ standalone: props.standalone,
+ }),
+ );
- // Load WebGL addon for all terminals. On context loss (GPU process crash,
- // sleep/wake, or context-cap eviction — see max-active-webgl-contexts in
- // electron/main.ts) reattach after a short delay instead of permanently
- // falling back to the much slower DOM renderer. The loss window is shared
- // app-wide so an eviction rotation (each reattach evicting another pane)
- // trips the brake even though every hop lands on a different pane.
+ // Load the WebGL addon while the pane is on screen. On context loss (GPU
+ // process crash, sleep/wake, or context-cap eviction — see
+ // max-active-webgl-contexts in electron/main.ts) reattach after a short
+ // delay instead of permanently falling back to the much slower DOM
+ // renderer. The loss window is shared app-wide so an eviction rotation
+ // (each reattach evicting another pane) trips the brake even though every
+ // hop lands on a different pane.
let webglReattachTimer: number | undefined;
+ let webglDetachTimer: number | undefined;
function attachWebgl() {
- if (!term) return;
+ if (!term || webglAddon) return;
+ // A pane that went off screen while a reattach was pending stays on the
+ // DOM renderer; the visibility effect below attaches when it returns.
+ if (!untrack(paneOnScreen)) return;
try {
const addon = new WebglAddon();
addon.onContextLoss(() => {
+ // A loss reported by an addon we already detached is our own
+ // loseContext() below, not a GPU event — don't count it.
+ if (webglAddon !== addon) return;
addon.dispose();
webglAddon = undefined;
if (recordSharedWebglContextLoss()) {
@@ -1021,7 +1042,60 @@ export function TerminalView(props: TerminalViewProps) {
// WebGL2 not supported — DOM renderer used automatically
}
}
- attachWebgl();
+
+ function detachWebgl() {
+ if (webglReattachTimer !== undefined) {
+ clearTimeout(webglReattachTimer);
+ webglReattachTimer = undefined;
+ }
+ const addon = webglAddon;
+ if (!addon) return;
+ webglAddon = undefined;
+ // The renderer's canvas; dispose() removes it from the DOM.
+ const canvas = term?.element?.querySelector('canvas');
+ // Dropping the addon returns this pane to the DOM renderer. The glyph
+ // atlas is shared and ref-counted by xterm, so other panes keep theirs.
+ addon.dispose();
+ // dispose() drops the canvas but the GL context lingers in Chromium's
+ // active set until the canvas is garbage collected — and the point is
+ // to free that slot now, not at some later GC. Lose it explicitly. The
+ // addon's own loss listener went with dispose(), and the onContextLoss
+ // handler above ignores an addon that is no longer current.
+ try {
+ canvas?.getContext('webgl2')?.getExtension('WEBGL_lose_context')?.loseContext();
+ } catch {
+ // context already gone
+ }
+ }
+
+ // Attach on the hidden→visible edge, detach a little after visible→hidden.
+ // A pane that returns before the detach delay elapses keeps its context;
+ // on macOS it is repainted instead (issue #121): a WebGL surface throttled
+ // while backgrounded can come back with a corrupt glyph atlas, and a
+ // hidden pane never fires terminalFitManager's IntersectionObserver.
+ // Linux never showed the corruption, so it skips that repaint.
+ let prevOnScreen: boolean | undefined;
+ createEffect(() => {
+ const onScreen = paneOnScreen();
+ if (onScreen) {
+ if (webglDetachTimer !== undefined) {
+ clearTimeout(webglDetachTimer);
+ webglDetachTimer = undefined;
+ }
+ if (!webglAddon && webglReattachTimer === undefined) {
+ // loadAddon → setRenderer already repaints this pane in full.
+ attachWebgl();
+ } else if (isMac && prevOnScreen === false) {
+ redrawTerminal(agentId);
+ }
+ } else if (webglDetachTimer === undefined) {
+ webglDetachTimer = window.setTimeout(() => {
+ webglDetachTimer = undefined;
+ detachWebgl();
+ }, WEBGL_DETACH_DELAY_MS);
+ }
+ prevOnScreen = onScreen;
+ });
let spawnTimer: number | undefined;
let spawnStarted = false;
@@ -1105,6 +1179,7 @@ export function TerminalView(props: TerminalViewProps) {
if (inputFlushTimer !== undefined) clearTimeout(inputFlushTimer);
if (resizeFlushTimer !== undefined) clearTimeout(resizeFlushTimer);
if (webglReattachTimer !== undefined) clearTimeout(webglReattachTimer);
+ if (webglDetachTimer !== undefined) clearTimeout(webglDetachTimer);
if (outputRaf !== undefined) cancelAnimationFrame(outputRaf);
onOutput.cleanup?.();
webglAddon?.dispose();
diff --git a/src/lib/terminalPaneVisibility.test.ts b/src/lib/terminalPaneVisibility.test.ts
new file mode 100644
index 000000000..31d1cc6f6
--- /dev/null
+++ b/src/lib/terminalPaneVisibility.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { isTerminalPaneOnScreen } from './terminalPaneVisibility';
+
+const base = { focusMode: false, activeTaskId: 't1', taskId: 't1', viewportVisibility: undefined };
+
+describe('isTerminalPaneOnScreen', () => {
+ it('in tiling mode, only fully off-screen tasks are hidden', () => {
+ expect(isTerminalPaneOnScreen({ ...base, viewportVisibility: undefined })).toBe(true);
+ expect(isTerminalPaneOnScreen({ ...base, viewportVisibility: 'visible' })).toBe(true);
+ expect(isTerminalPaneOnScreen({ ...base, viewportVisibility: 'offscreen-left' })).toBe(false);
+ expect(isTerminalPaneOnScreen({ ...base, viewportVisibility: 'offscreen-right' })).toBe(false);
+ });
+
+ it('in focus mode, only the active task is on screen regardless of tiling measurements', () => {
+ expect(isTerminalPaneOnScreen({ ...base, focusMode: true })).toBe(true);
+ expect(isTerminalPaneOnScreen({ ...base, focusMode: true, activeTaskId: 't2' })).toBe(false);
+ expect(isTerminalPaneOnScreen({ ...base, focusMode: true, activeTaskId: null })).toBe(false);
+ // A stale offscreen measurement from tiling mode must not hide the active task.
+ expect(
+ isTerminalPaneOnScreen({ ...base, focusMode: true, viewportVisibility: 'offscreen-left' }),
+ ).toBe(true);
+ });
+
+ it('a standalone pane (arena overlay) ignores task-level focus and tiling state', () => {
+ expect(
+ isTerminalPaneOnScreen({ ...base, standalone: true, focusMode: true, activeTaskId: 'other' }),
+ ).toBe(true);
+ expect(
+ isTerminalPaneOnScreen({ ...base, standalone: true, viewportVisibility: 'offscreen-left' }),
+ ).toBe(true);
+ expect(isTerminalPaneOnScreen({ ...base, standalone: true, paneVisible: false })).toBe(false);
+ });
+
+ it('a hidden tab pane is off screen even when its task is visible', () => {
+ expect(isTerminalPaneOnScreen({ ...base, paneVisible: false })).toBe(false);
+ expect(isTerminalPaneOnScreen({ ...base, paneVisible: true })).toBe(true);
+ expect(isTerminalPaneOnScreen({ ...base, focusMode: true, paneVisible: false })).toBe(false);
+ });
+});
diff --git a/src/lib/terminalPaneVisibility.ts b/src/lib/terminalPaneVisibility.ts
new file mode 100644
index 000000000..3efe9e850
--- /dev/null
+++ b/src/lib/terminalPaneVisibility.ts
@@ -0,0 +1,45 @@
+// Whether a terminal pane is on screen, i.e. worth holding a WebGL context for.
+//
+// Every task's terminals stay mounted (their pty sessions and scrollback must
+// survive layout changes), so with many tasks the app would otherwise hold one
+// live WebGL context per pane — far beyond Chromium's active-context cap, which
+// then evicts contexts in rotation (see max-active-webgl-contexts in
+// electron/main.ts and the recovery policy in webglContextLoss.ts). Panes that
+// are not on screen fall back to xterm's DOM renderer instead and reacquire a
+// WebGL context when they come back, keeping live contexts ≈ visible panes.
+//
+// Three things hide a pane:
+// - focus mode shows one task; the others sit under it with visibility:hidden,
+// - tiling mode scrolls tasks horizontally; TilingLayout tracks which are
+// fully off screen (partially visible counts as visible),
+// - tabs within a task show one agent pane at a time.
+
+import type { TaskViewportVisibility } from '../store/types';
+
+export interface PaneVisibilityInput {
+ focusMode: boolean;
+ activeTaskId: string | null;
+ taskId: string;
+ /** TilingLayout's measurement; undefined while unmeasured (treated as visible). */
+ viewportVisibility: TaskViewportVisibility | undefined;
+ /** Pane-level visibility within the task (tabs); undefined means visible. */
+ paneVisible?: boolean;
+ /** The pane is not a task panel (e.g. an arena competitor rendered in an
+ * overlay): task-level focus/tiling state says nothing about it, so only
+ * `paneVisible` applies. */
+ standalone?: boolean;
+}
+
+export function isTerminalPaneOnScreen(input: PaneVisibilityInput): boolean {
+ if (input.paneVisible === false) return false;
+ if (input.standalone) return true;
+ if (input.focusMode) return input.activeTaskId === input.taskId;
+ return (
+ input.viewportVisibility !== 'offscreen-left' && input.viewportVisibility !== 'offscreen-right'
+ );
+}
+
+/** How long a pane stays on WebGL after leaving the screen. Absorbs brief
+ * flickers (a horizontal scroll passing over a pane, a quick tab round-trip)
+ * so a context is not torn down and rebuilt for nothing. */
+export const WEBGL_DETACH_DELAY_MS = 1000;
diff --git a/src/lib/webglContextLoss.ts b/src/lib/webglContextLoss.ts
index 02ff7208c..29b744a31 100644
--- a/src/lib/webglContextLoss.ts
+++ b/src/lib/webglContextLoss.ts
@@ -15,10 +15,10 @@
// Losses are therefore grouped into "waves" (losses close together share one
// underlying event) counted in a single app-wide window: the first waves
// retry, later waves inside the window mean churn — give up and let affected
-// panes settle onto the DOM renderer.
-// shortcut: after giving up, a pane stays on the DOM renderer until remount —
-// attaching/detaching on visibility edges would make this recoverable and
-// keep live contexts ≈ visible panes.
+// panes settle onto the DOM renderer. A pane that gave up recovers the next
+// time it leaves and re-enters the screen: TerminalView detaches WebGL from
+// off-screen panes and reattaches on the visible edge (see
+// terminalPaneVisibility.ts), which also keeps live contexts ≈ visible panes.
//
// Known conflation: unrelated one-off losses on different panes inside one
// window also advance the wave count, so a third such pane is denied even
diff --git a/src/remote/AgentDetail.tsx b/src/remote/AgentDetail.tsx
index c039f60ab..0634ac483 100644
--- a/src/remote/AgentDetail.tsx
+++ b/src/remote/AgentDetail.tsx
@@ -3,7 +3,15 @@ import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { TERMINAL_SCROLL_OPTIONS, base64ToUint8Array } from '../lib/terminalConstants';
import { createTerminalHttpLinkHandler } from '../lib/terminalLinks';
-import { fetchNotes, saveNotes } from './api';
+import { fetchNotes, saveNotes, ApiError } from './api';
+import { getPairedToken, clearPairedToken } from './auth';
+import { reconnect, socketCanType } from './ws';
+
+// Drafts survive the pairing detour: App unmounts this view while the user
+// enters the PIN, and text typed before that must still be there afterwards.
+// Keyed by agent so returning to a different agent starts clean.
+const inputDrafts = new Map();
+const notesDrafts = new Map();
import { agentStatusDisplay } from './attention';
import {
subscribeAgent,
@@ -44,6 +52,8 @@ interface AgentDetailProps {
agentId: string;
taskName: string;
onBack: () => void;
+ /** Typing and saving notes need the paired token; ask the user to pair. */
+ onNeedsPairing: () => void;
}
const openRemoteHttpLink = createTerminalHttpLinkHandler({
@@ -58,7 +68,13 @@ export function AgentDetail(props: AgentDetailProps) {
let inputRef: HTMLInputElement | undefined;
let term: Terminal | undefined;
let fitAddon: FitAddon | undefined;
- const [inputText, setInputText] = createSignal('');
+ // eslint-disable-next-line solid/reactivity -- initial value only; the draft map is re-read on each mount
+ const [inputText, setInputText] = createSignal(inputDrafts.get(props.agentId) ?? '');
+ createEffect(() => {
+ const text = inputText();
+ if (text) inputDrafts.set(props.agentId, text);
+ else inputDrafts.delete(props.agentId);
+ });
const [atBottom, setAtBottom] = createSignal(true);
const [termFontSize, setTermFontSize] = createSignal(10);
// Desktop PTY column count (from scrollback). The mobile client can't resize
@@ -69,11 +85,19 @@ export function AgentDetail(props: AgentDetailProps) {
// Notes editing
const [view, setView] = createSignal<'terminal' | 'notes'>('terminal');
- const [notesText, setNotesText] = createSignal('');
+ // eslint-disable-next-line solid/reactivity -- initial value only; the draft map is re-read on each mount
+ const notesDraft = notesDrafts.get(props.agentId);
+ const [notesText, setNotesText] = createSignal(notesDraft ?? '');
const [notesLoading, setNotesLoading] = createSignal(false);
const [notesSaving, setNotesSaving] = createSignal(false);
const [notesError, setNotesError] = createSignal(null);
- const [notesDirty, setNotesDirty] = createSignal(false);
+ // A restored draft is by definition unsaved, which also keeps the load
+ // effect below from overwriting it with the server's copy.
+ const [notesDirty, setNotesDirty] = createSignal(notesDraft !== undefined);
+ createEffect(() => {
+ if (notesDirty()) notesDrafts.set(props.agentId, notesText());
+ else notesDrafts.delete(props.agentId);
+ });
const [notesSaved, setNotesSaved] = createSignal(false);
const MIN_FONT = 6;
@@ -172,6 +196,13 @@ export function AgentDetail(props: AgentDetailProps) {
setNotesSaved(true);
setTimeout(() => setNotesSaved(false), 1500);
} catch (e) {
+ // 401/403: no paired token, or a stale one (desktop restarted). Pairing
+ // again is the fix, so send the user there instead of showing an error.
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
+ clearPairedToken();
+ props.onNeedsPairing();
+ return;
+ }
setNotesError(e instanceof Error ? e.message : String(e));
} finally {
setNotesSaving(false);
@@ -335,9 +366,26 @@ export function AgentDetail(props: AgentDetailProps) {
// the latest invocation sends the delayed \r.
let lastSendId = 0;
+ /** Typing needs the paired token; without one, detour to the pairing screen. */
+ function ensurePairedForInput(): boolean {
+ if (!getPairedToken()) {
+ props.onNeedsPairing();
+ return false;
+ }
+ // Paired in another tab, or the socket predates pairing: the server only
+ // knows what this socket authenticated with. Reconnect with the paired
+ // token; the typed text stays in the box for the next send.
+ if (!socketCanType()) {
+ reconnect();
+ return false;
+ }
+ return true;
+ }
+
function handleSend() {
const text = inputText();
if (!text) return;
+ if (!ensurePairedForInput()) return;
// Keep the typed text while disconnected — send() silently drops
// messages on a non-open socket, so clearing here would lose input.
if (status() !== 'connected') return;
@@ -352,6 +400,7 @@ export function AgentDetail(props: AgentDetailProps) {
}
function handleQuickAction(data: string) {
+ if (!ensurePairedForInput()) return;
sendInput(props.agentId, data);
}
diff --git a/src/remote/App.tsx b/src/remote/App.tsx
index 4a865b480..430db2fce 100644
--- a/src/remote/App.tsx
+++ b/src/remote/App.tsx
@@ -1,6 +1,6 @@
import { createSignal, onMount, Show, Switch, Match } from 'solid-js';
import { initAuth, getPairedToken } from './auth';
-import { connect } from './ws';
+import { connect, reconnect } from './ws';
import { AgentList } from './AgentList';
import { AgentDetail } from './AgentDetail';
import { ConnectScreen } from './ConnectScreen';
@@ -17,6 +17,9 @@ export function App() {
const [view, setView] = createSignal('list');
const [detailAgentId, setDetailAgentId] = createSignal('');
const [detailTaskName, setDetailTaskName] = createSignal('');
+ // Where to land after pairing: the New Task form, or back to the agent the
+ // user was about to type into.
+ const [afterPairing, setAfterPairing] = createSignal('newtask');
function selectAgent(id: string, name: string) {
setDetailAgentId(id);
@@ -27,9 +30,25 @@ export function App() {
// Creating a task needs the elevated paired token; pair first if we don't
// have one yet.
function startNewTask() {
+ setAfterPairing('newtask');
setView(getPairedToken() ? 'newtask' : 'pair');
}
+ // Typing into a terminal (or saving notes) needs the paired token too. The
+ // socket reconnects with it after pairing (see ws.ts), so returning to the
+ // detail view is enough.
+ function pairForDetail() {
+ setAfterPairing('detail');
+ setView('pair');
+ }
+
+ // A fresh paired token must also reach the socket, which authenticated with
+ // whichever token it had at connect time.
+ function onPaired() {
+ reconnect();
+ setView(afterPairing());
+ }
+
function onConnected() {
setAuthed(true);
connect();
@@ -48,16 +67,23 @@ export function App() {
agentId={detailAgentId()}
taskName={detailTaskName()}
onBack={() => setView('list')}
+ onNeedsPairing={pairForDetail}
/>
- setView('newtask')} onCancel={() => setView('list')} />
+ setView(afterPairing() === 'detail' ? 'detail' : 'list')}
+ />
setView('list')}
onCancel={() => setView('list')}
- onNeedsPairing={() => setView('pair')}
+ onNeedsPairing={() => {
+ setAfterPairing('newtask');
+ setView('pair');
+ }}
/>
diff --git a/src/remote/PairScreen.tsx b/src/remote/PairScreen.tsx
index 28699121a..2d3d9217d 100644
--- a/src/remote/PairScreen.tsx
+++ b/src/remote/PairScreen.tsx
@@ -8,8 +8,9 @@ interface PairScreenProps {
}
/**
- * Shown before a phone may create tasks. The user opens Connect Phone on the
- * desktop, taps "Pair a device", and types the 6-digit code shown there.
+ * Shown before a phone may type into a terminal, save notes, or create tasks.
+ * The user opens Connect Phone on the desktop, taps "Pair a device", and types
+ * the 6-digit code shown there.
*/
export function PairScreen(props: PairScreenProps) {
const [pin, setPin] = createSignal('');
@@ -56,8 +57,9 @@ export function PairScreen(props: PairScreenProps) {
Pair this device
- On your computer, open Connect Phone and tap{' '}
- Pair a device to create tasks. Enter the 6-digit code below.
+ Typing into agents, saving notes, and creating tasks need a paired phone. On your
+ computer, open Connect Phone and tap{' '}
+ Pair a device to type and create tasks. Enter the 6-digit code below.
diff --git a/src/remote/api.ts b/src/remote/api.ts
index d771e813b..7aac901f8 100644
--- a/src/remote/api.ts
+++ b/src/remote/api.ts
@@ -87,10 +87,10 @@ export async function fetchNotes(taskId: string): Promise {
return r.notes;
}
-/** Save the notes for a task. Works with the base connection token. */
+/** Save the notes for a task. Requires a paired token (it is a write). */
export async function saveNotes(taskId: string, notes: string): Promise {
- const token = getToken();
- if (!token) throw new ApiError('Not connected', 401);
+ const token = getPairedToken();
+ if (!token) throw new ApiError('Not paired', 401);
await request<{ ok: boolean }>(`/api/mobile/notes/${encodeURIComponent(taskId)}`, {
method: 'PUT',
body: { notes },
diff --git a/src/remote/ws.ts b/src/remote/ws.ts
index 02cc8095a..6911af21d 100644
--- a/src/remote/ws.ts
+++ b/src/remote/ws.ts
@@ -1,5 +1,5 @@
import { createSignal } from 'solid-js';
-import { getToken, clearToken } from './auth';
+import { getToken, clearToken, getPairedToken, clearPairedToken } from './auth';
import type { ServerMessage, RemoteAgent } from '../../electron/remote/protocol';
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
@@ -14,6 +14,18 @@ const scrollbackListeners = new Map>();
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType | null = null;
+// Which credential the open socket authenticated with. The paired token
+// (minted by entering the desktop PIN) is preferred because it is the one
+// that may type into terminals; the QR-code token only watches.
+let authTokenKind: 'paired' | 'mobile' = 'mobile';
+
+/** Pick the credential for the next socket: paired if this phone has one. */
+function selectAuthToken(): { token: string; kind: 'paired' | 'mobile' } | null {
+ const paired = getPairedToken();
+ if (paired) return { token: paired, kind: 'paired' };
+ const mobile = getToken();
+ return mobile ? { token: mobile, kind: 'mobile' } : null;
+}
export { agents, status };
@@ -26,8 +38,9 @@ export function connect(): void {
ws = null;
}
- const token = getToken();
- if (!token) return;
+ const auth = selectAuthToken();
+ if (!auth) return;
+ authTokenKind = auth.kind;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/ws`;
@@ -38,7 +51,7 @@ export function connect(): void {
ws.onopen = () => {
// Authenticate via first message instead of URL query to avoid
// token leaking in proxy logs or browser history.
- send({ type: 'auth', token });
+ send({ type: 'auth', token: auth.token });
setStatus('connected');
if (reconnectTimer) {
clearTimeout(reconnectTimer);
@@ -89,8 +102,18 @@ export function connect(): void {
ws.onclose = (event) => {
ws = null;
setStatus('disconnected');
- // 4001 = server rejected auth — token is stale, reload to re-auth
+ // 4001 = server rejected auth — the token is stale (the desktop restarted
+ // Remote Access, which rotates every token). A stale paired token falls
+ // back to the QR-code token so the phone keeps watching and only loses
+ // typing rights until it pairs again; a stale QR-code token means
+ // reconnecting from scratch.
if (event.code === 4001) {
+ if (authTokenKind === 'paired') {
+ clearPairedToken();
+ if (reconnectTimer) clearTimeout(reconnectTimer);
+ reconnectTimer = setTimeout(connect, 0);
+ return;
+ }
clearToken();
window.location.reload();
return;
@@ -104,6 +127,31 @@ export function connect(): void {
};
}
+/** True when the open socket authenticated with the paired token, i.e. the
+ * server will accept `input` from it. A paired token stored by another tab
+ * does not count until this socket reconnects with it. */
+export function socketCanType(): boolean {
+ return status() === 'connected' && authTokenKind === 'paired';
+}
+
+/** Drop the current socket and connect again with the best available token. */
+export function reconnect(): void {
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ if (ws) {
+ // Detach the handlers first: a manual close must not trigger the
+ // auto-reconnect path (that would race the connect below).
+ ws.onclose = null;
+ ws.onerror = null;
+ ws.close();
+ ws = null;
+ }
+ setStatus('disconnected');
+ connect();
+}
+
export function send(msg: Record): void {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
diff --git a/src/store/autosave.client.test.tsx b/src/store/autosave.client.test.tsx
new file mode 100644
index 000000000..0abe2b698
--- /dev/null
+++ b/src/store/autosave.client.test.tsx
@@ -0,0 +1,83 @@
+// Scheduling behaviour of the autosave effect. Lives in the client (happy-dom)
+// config: the node config compiles solid-js for SSR, where createEffect is a
+// no-op, so the effect under test would never run there.
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { createRoot } from 'solid-js';
+import { store, setStore } from './core';
+import { setupAutosave, AUTOSAVE_DEBOUNCE_MS, AUTOSAVE_MAX_WAIT_MS } from './autosave';
+
+const { mockSaveState } = vi.hoisted(() => ({ mockSaveState: vi.fn(async () => {}) }));
+vi.mock('./persistence', async (importOriginal) => ({
+ ...(await importOriginal()),
+ saveState: mockSaveState,
+}));
+describe('setupAutosave scheduling', () => {
+ // Real solid-js reactivity over the real store; only the write is mocked.
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockSaveState.mockClear();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ function withAutosave(run: () => void): void {
+ // Effects created inside createRoot run when the root's setup returns, so
+ // the scenario must execute after that. The initial effect pass schedules
+ // one startup save (as it always has); let it land and discard it so each
+ // scenario starts from a quiet, saved state.
+ const dispose = createRoot((d) => {
+ setupAutosave();
+ return d;
+ });
+ vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS);
+ expect(mockSaveState).toHaveBeenCalledTimes(1);
+ mockSaveState.mockClear();
+ try {
+ run();
+ } finally {
+ dispose();
+ }
+ }
+
+ it('debounces a burst of changes into one save after the quiet period', () => {
+ withAutosave(() => {
+ setStore('showSidebarTips', !store.showSidebarTips);
+ vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS - 1);
+ setStore('showSidebarTips', !store.showSidebarTips);
+ vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS - 1);
+ expect(mockSaveState).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(mockSaveState).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('never postpones a save past the max wait while changes keep arriving', () => {
+ withAutosave(() => {
+ // Simulate continuous typing: a change every 500ms, never a 1s gap.
+ const step = 500;
+ const typeFor = (ms: number) => {
+ for (let elapsed = 0; elapsed < ms; elapsed += step) {
+ setStore('showSidebarTips', !store.showSidebarTips);
+ vi.advanceTimersByTime(step);
+ }
+ };
+ typeFor(AUTOSAVE_MAX_WAIT_MS - step);
+ expect(mockSaveState).not.toHaveBeenCalled();
+ // The forced save lands exactly at the max wait after the burst began...
+ typeFor(step);
+ expect(mockSaveState).toHaveBeenCalledTimes(1);
+ // ...and the next burst gets its own max wait.
+ typeFor(AUTOSAVE_MAX_WAIT_MS);
+ expect(mockSaveState).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('does not save when nothing persisted changed', () => {
+ withAutosave(() => {
+ setStore('notification', 'transient toast');
+ vi.advanceTimersByTime(AUTOSAVE_MAX_WAIT_MS * 2);
+ expect(mockSaveState).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/store/autosave.ts b/src/store/autosave.ts
index 9cb53fe6f..57b7a0405 100644
--- a/src/store/autosave.ts
+++ b/src/store/autosave.ts
@@ -1,4 +1,4 @@
-import { createEffect, onCleanup } from 'solid-js';
+import { createEffect } from 'solid-js';
import { store, saveState } from './store';
/** Build a snapshot string of all persisted fields. Using JSON.stringify
@@ -95,9 +95,25 @@ export function persistedSnapshot(): string {
});
}
+/** Quiet period after the last change before a save is written. */
+export const AUTOSAVE_DEBOUNCE_MS = 1000;
+/** Upper bound on how long a save may be postponed by a continuous stream of
+ * changes (typing in the notes panel, for example). Without this the trailing
+ * debounce never fires while the user keeps typing, and a crash or force-quit
+ * loses the whole session. */
+export const AUTOSAVE_MAX_WAIT_MS = 5000;
+
export function setupAutosave(): void {
- let timer: number | undefined;
+ let timer: ReturnType | undefined;
let lastSnapshot: string | undefined;
+ // When the first unsaved change of the current burst happened.
+ let pendingSince: number | undefined;
+
+ const flush = () => {
+ timer = undefined;
+ pendingSince = undefined;
+ void saveState();
+ };
createEffect(() => {
const snapshot = persistedSnapshot();
@@ -106,9 +122,14 @@ export function setupAutosave(): void {
if (snapshot === lastSnapshot) return;
lastSnapshot = snapshot;
- clearTimeout(timer);
- timer = window.setTimeout(() => saveState(), 1000);
-
- onCleanup(() => clearTimeout(timer));
+ const now = Date.now();
+ pendingSince ??= now;
+ if (timer !== undefined) clearTimeout(timer);
+ // Trailing debounce, but never later than MAX_WAIT after the burst began.
+ const delay = Math.max(
+ 0,
+ Math.min(AUTOSAVE_DEBOUNCE_MS, pendingSince + AUTOSAVE_MAX_WAIT_MS - now),
+ );
+ timer = setTimeout(flush, delay);
});
}
diff --git a/src/store/notification.ts b/src/store/notification.ts
index cd9bd71c3..21a396d5e 100644
--- a/src/store/notification.ts
+++ b/src/store/notification.ts
@@ -2,13 +2,17 @@ import { setStore } from './core';
let notificationTimer: ReturnType | null = null;
-export function showNotification(message: string): void {
+export const NOTIFICATION_DEFAULT_MS = 3000;
+/** Errors the user must act on stay up longer than a passing status message. */
+export const NOTIFICATION_ERROR_MS = 10_000;
+
+export function showNotification(message: string, opts?: { durationMs?: number }): void {
if (notificationTimer) clearTimeout(notificationTimer);
setStore('notification', message);
notificationTimer = setTimeout(() => {
setStore('notification', null);
notificationTimer = null;
- }, 3000);
+ }, opts?.durationMs ?? NOTIFICATION_DEFAULT_MS);
}
export function clearNotification(): void {
diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts
index b9714cd90..3667f356f 100644
--- a/src/store/persistence.test.ts
+++ b/src/store/persistence.test.ts
@@ -1044,3 +1044,30 @@ describe('showSteps → defaultStepsEnabled migration', () => {
expect(saved.defaultStepsEnabled).toBe(true);
});
});
+
+describe('saveState failure reporting', () => {
+ it('tells the user when the state file could not be written', async () => {
+ vi.useFakeTimers();
+ try {
+ setStore('notification', null);
+ mockInvoke.mockImplementation((channel: string) =>
+ channel === IPC.SaveAppState
+ ? Promise.reject(new Error('ENOSPC: no space left on device'))
+ : Promise.resolve(undefined),
+ );
+ await saveState();
+ expect(store.notification).toContain("Couldn't save app state");
+ expect(store.notification).toContain('ENOSPC');
+ // Rate-limited: a second failure right away does not re-toast...
+ setStore('notification', null);
+ await saveState();
+ expect(store.notification).toBeNull();
+ // ...but the reminder returns once the interval has passed.
+ vi.advanceTimersByTime(60_000);
+ await saveState();
+ expect(store.notification).toContain("Couldn't save app state");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/src/store/persistence.ts b/src/store/persistence.ts
index 16b127333..4ccf0c7fb 100644
--- a/src/store/persistence.ts
+++ b/src/store/persistence.ts
@@ -28,6 +28,8 @@ import { isLookPreset } from '../lib/look';
import { validateCustomTheme, parseThemeCss, themeToCss } from '../lib/custom-theme';
import type { CustomTheme } from '../lib/custom-theme';
import { syncTerminalCounter } from './terminals';
+import { showNotification, NOTIFICATION_ERROR_MS } from './notification';
+import { errMessage } from '../lib/log';
const RESTORED_AGENT_SPAWN_STAGGER_MS = 1_000;
@@ -285,9 +287,25 @@ export async function saveState(): Promise {
persisted.terminals[id] = { id: terminal.id, name: terminal.name };
}
- await invoke(IPC.SaveAppState, { json: JSON.stringify(persisted) }).catch((e) =>
- console.warn('Failed to save state:', e),
- );
+ await invoke(IPC.SaveAppState, { json: JSON.stringify(persisted) }).catch((e: unknown) => {
+ console.warn('Failed to save state:', e);
+ notifySaveFailure(e);
+ });
+}
+
+/** Don't nag on every autosave tick while the cause (full disk, permissions) persists. */
+const SAVE_FAILURE_NOTIFY_INTERVAL_MS = 60_000;
+let lastSaveFailureNotifiedAt = 0;
+
+/** A failed state write means tasks, projects, and settings are silently no
+ * longer persisting — the user needs to know before they quit. */
+function notifySaveFailure(err: unknown): void {
+ const now = Date.now();
+ if (now - lastSaveFailureNotifiedAt < SAVE_FAILURE_NOTIFY_INTERVAL_MS) return;
+ lastSaveFailureNotifiedAt = now;
+ showNotification(`Couldn't save app state: ${errMessage(err)}`, {
+ durationMs: NOTIFICATION_ERROR_MS,
+ });
}
/** 20_000 px is ~10× the largest plausible monitor axis and big enough to let
diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts
index 904b876df..88f81875a 100644
--- a/src/store/tasks.test.ts
+++ b/src/store/tasks.test.ts
@@ -155,6 +155,7 @@ import {
collapseTask,
closeTask,
mergeTask,
+ pushTask,
sendPrompt,
pasteDelayMs,
markTaskUserActivity,
@@ -1464,3 +1465,75 @@ describe('pasteDelayMs', () => {
expect(pasteDelayMs(text)).toBe(500);
});
});
+
+describe('mergeTask / pushTask preconditions', () => {
+ beforeEach(() => {
+ const harness = expectDefined(core.harness, 'mock store harness');
+ harness.reset(harness.state());
+ mockInvoke.mockReset();
+ vi.mocked(getProjectPath).mockReset();
+ });
+
+ it('mergeTask throws instead of silently returning for a missing task', async () => {
+ await expect(mergeTask('nope')).rejects.toThrow('Task no longer exists');
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ it('mergeTask refuses a task that is being closed', async () => {
+ mockTasks['task-1'] = {
+ agentIds: [],
+ shellAgentIds: [],
+ gitIsolation: 'worktree',
+ closingStatus: 'removing',
+ projectId: 'proj-1',
+ };
+ await expect(mergeTask('task-1')).rejects.toThrow('being closed');
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ it('mergeTask refuses direct-mode tasks with a reason', async () => {
+ mockTasks['task-1'] = {
+ agentIds: [],
+ shellAgentIds: [],
+ gitIsolation: 'direct',
+ projectId: 'proj-1',
+ };
+ await expect(mergeTask('task-1')).rejects.toThrow('worktree');
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ it('mergeTask reports a missing project folder', async () => {
+ mockTasks['task-1'] = {
+ agentIds: [],
+ shellAgentIds: [],
+ gitIsolation: 'worktree',
+ projectId: 'proj-gone',
+ };
+ vi.mocked(getProjectPath).mockReturnValue(undefined);
+ await expect(mergeTask('task-1')).rejects.toThrow('Project folder not found');
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+
+ it('pushTask throws for a missing task, a direct-mode task, and a missing project', async () => {
+ const channel = { onmessage: undefined } as unknown as Parameters[1];
+ await expect(pushTask('nope', channel)).rejects.toThrow('Task no longer exists');
+
+ mockTasks['task-1'] = {
+ agentIds: [],
+ shellAgentIds: [],
+ gitIsolation: 'direct',
+ projectId: 'p',
+ };
+ await expect(pushTask('task-1', channel)).rejects.toThrow('worktree');
+
+ mockTasks['task-2'] = {
+ agentIds: [],
+ shellAgentIds: [],
+ gitIsolation: 'worktree',
+ projectId: 'p',
+ };
+ vi.mocked(getProjectPath).mockReturnValue(undefined);
+ await expect(pushTask('task-2', channel)).rejects.toThrow('Project folder not found');
+ expect(mockInvoke).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/store/tasks.ts b/src/store/tasks.ts
index 5f1f3e31a..9a8aef115 100644
--- a/src/store/tasks.ts
+++ b/src/store/tasks.ts
@@ -621,12 +621,16 @@ export async function mergeTask(
taskId: string,
options?: { squash?: boolean; message?: string; cleanup?: boolean },
): Promise {
+ // Precondition failures throw so the merge dialog can say why nothing
+ // happened; a silent return here reads as "Merge did nothing" to the user.
const task = store.tasks[taskId];
- if (!task || task.closingStatus === 'removing') return;
- if (task.gitIsolation !== 'worktree') return;
+ if (!task) throw new Error('Task no longer exists');
+ if (task.closingStatus === 'removing') throw new Error('Task is being closed');
+ if (task.gitIsolation !== 'worktree') throw new Error('Only worktree tasks can be merged');
const projectRoot = getProjectPath(task.projectId);
- if (!projectRoot) return;
+ if (!projectRoot)
+ throw new Error('Project folder not found — use Relink in the project settings');
const agentIds = [...task.agentIds];
const shellAgentIds = [...task.shellAgentIds];
@@ -669,10 +673,12 @@ export async function mergeTask(
export async function pushTask(taskId: string, onOutput: Channel): Promise {
const task = store.tasks[taskId];
- if (!task || task.gitIsolation !== 'worktree') return;
+ if (!task) throw new Error('Task no longer exists');
+ if (task.gitIsolation !== 'worktree') throw new Error('Only worktree tasks can be pushed');
const projectRoot = getProjectPath(task.projectId);
- if (!projectRoot) return;
+ if (!projectRoot)
+ throw new Error('Project folder not found — use Relink in the project settings');
await invoke(IPC.PushTask, {
projectRoot,
diff --git a/vitest.config.ts b/vitest.config.ts
index 76649cd9a..d214c76a7 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -10,6 +10,15 @@ export default defineConfig({
provider: 'v8',
reporter: ['text', 'html', 'json-summary'],
reportsDirectory: './coverage',
+ // A floor, not a target: ~3 points under the measured totals (58/56/45/54
+ // at the time of writing) so CI fails when a change drops coverage
+ // noticeably, without failing on noise. Raise these as coverage grows.
+ thresholds: {
+ lines: 55,
+ statements: 53,
+ functions: 42,
+ branches: 50,
+ },
exclude: [
'coverage/**',
'dist/**',