feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build

Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
This commit is contained in:
Yaojia Wang
2026-06-30 17:42:18 +02:00
parent 4f1d3ebc6b
commit d6809c65c4
54 changed files with 13171 additions and 200 deletions

View File

@@ -13,7 +13,14 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Config, Dims, Session, WebSocketLike } from '../src/types.js';
import type {
Config,
Dims,
NotifyService,
Session,
StatusTelemetry,
WebSocketLike,
} from '../src/types.js';
import { WS_OPEN } from '../src/types.js';
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
@@ -53,10 +60,53 @@ const CFG: Config = {
previewBytes: 24 * 1024,
useTmux: false,
allowedOrigins: [],
// v0.6 project manager
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
// v0.7 Walk-away Workbench
vapidPublicKey: undefined,
vapidPrivateKey: undefined,
vapidSubject: undefined,
pushStorePath: '/home/tester/.web-terminal-push-subs.json',
pushMaxSubs: 50,
notifyDone: true,
notifyDnd: false,
decisionTokenTtlMs: 300_000,
timelineMax: 200,
timelineEnabled: true,
stuckTtlMs: 10_000, // 10 s for easy arithmetic in tests
stuckAlert: true,
diffTimeoutMs: 2000,
diffMaxBytes: 2 * 1024 * 1024,
diffMaxFiles: 300,
statuslineTtlMs: 30_000,
worktreeEnabled: true,
worktreeRoot: undefined,
worktreeTimeoutMs: 10_000,
defaultPermissionMode: 'default',
allowAutoMode: false,
};
const DIMS: Dims = { cols: 80, rows: 24 };
/** A telemetry sample for B2 tests. */
function makeTelemetry(at = 1_000): StatusTelemetry {
return { contextUsedPct: 42, costUsd: 1.23, model: 'sonnet', at };
}
/** A mock NotifyService (DI) recording every notify(session, cls, token?) call. */
function createMockNotify() {
const notify = vi.fn(async (_session: Session, _cls: string, _token?: string) => {});
const service: NotifyService = {
isEnabled: () => true,
notify,
};
return { service, notify };
}
interface MockWs extends WebSocketLike {
readyState: number;
sent: string[];
@@ -646,3 +696,356 @@ describe('onExit removes session from table when ws attached at exit time (L2)',
expect(session.exitedAt).not.toBeNull();
});
});
// ── handleHookEvent — A4 timeline + B4 gate (T-manager) ───────────────────────
describe('handleHookEvent — timeline append (A4)', () => {
it('appends a derived timeline event when eventClass is supplied + timeline enabled', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', 'Bash', false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).toHaveLength(1);
expect(s.timeline[0]).toMatchObject({ class: 'tool', toolName: 'Bash', label: 'using Bash' });
expect(typeof s.timeline[0]?.at).toBe('number');
});
it('replaces the timeline array immutably (never mutates the previous one)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const before = s.timeline;
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).not.toBe(before);
expect(before).toHaveLength(0);
});
it('does NOT append when timeline is disabled', () => {
const mgr = createSessionManager({ ...CFG, timelineEnabled: false });
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', 'Bash');
expect(s.timeline).toHaveLength(0);
});
it('does NOT append when eventClass is omitted', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
expect(s.timeline).toHaveLength(0);
});
it('drops non-whitelisted event classes (makeTimelineEvent → null)', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'BogusEvent', 'X');
expect(s.timeline).toHaveLength(0);
});
it('evicts oldest events past cfg.timelineMax', () => {
const mgr = createSessionManager({ ...CFG, timelineMax: 3 });
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
for (let i = 0; i < 5; i += 1) {
mgr.handleHookEvent(s.meta.id, 'working', undefined, false, undefined, 'PreToolUse', `Tool${i}`);
}
expect(s.timeline).toHaveLength(3);
expect(s.timeline.map((e) => e.toolName)).toEqual(['Tool2', 'Tool3', 'Tool4']);
});
});
describe('handleHookEvent — gate broadcast (B4)', () => {
it('broadcasts the status frame with gate when supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'ExitPlanMode', true, 'plan', 'PermissionRequest', 'ExitPlanMode');
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({
type: 'status',
status: 'waiting',
detail: 'ExitPlanMode',
pending: true,
gate: 'plan',
});
});
it('omits gate when not supplied', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
mgr.handleHookEvent(s.meta.id, 'waiting', 'Bash', true);
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).not.toHaveProperty('gate');
});
});
// ── handleStatusLine (B2 telemetry) ───────────────────────────────────────────
describe('handleStatusLine', () => {
it('stores telemetry on the session and broadcasts it to all clients', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const b = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
a.sent.length = 0;
b.sent.length = 0;
const telemetry = makeTelemetry();
mgr.handleStatusLine(s.meta.id, telemetry);
expect(s.telemetry).toBe(telemetry);
for (const ws of [a, b]) {
const msg = parseSent(ws).find((m) => m.type === 'telemetry');
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { costUsd: 1.23, model: 'sonnet' } });
}
});
it('is a no-op for an unknown session id', () => {
const mgr = createSessionManager(CFG);
expect(() => mgr.handleStatusLine('00000000-0000-4000-8000-0000000000cc', makeTelemetry())).not.toThrow();
});
});
// ── handleAttach Case 2 — late-join telemetry/status replay (M3 / AC-B2.3) ─────
describe('handleAttach — late-join replay (M3)', () => {
it('sends the current telemetry to a device joining a live session', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleStatusLine(s.meta.id, makeTelemetry());
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
const msg = parseSent(b).find((m) => m.type === 'telemetry');
expect(msg).toMatchObject({ type: 'telemetry', telemetry: { model: 'sonnet' } });
});
it('sends the current status to a late-joining device', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
mgr.handleHookEvent(s.meta.id, 'working');
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
const status = parseSent(b).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'working' });
});
it('does NOT send a telemetry frame when the session has no telemetry yet', () => {
const mgr = createSessionManager(CFG);
const a = createMockWs();
const s = mgr.handleAttach(a, null, DIMS, 1_000);
const b = createMockWs();
mgr.handleAttach(b, s.meta.id, DIMS, 2_000);
expect(parseSent(b).some((m) => m.type === 'telemetry')).toBe(false);
});
});
// ── sweepStuck (A5) ───────────────────────────────────────────────────────────
describe('sweepStuck (A5)', () => {
it('marks a silent live session stuck, broadcasts, and notifies once', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000); // lastOutputAt = 1000
ws.sent.length = 0;
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).toBe('stuck');
expect(s.stuckNotified).toBe(true);
const status = parseSent(ws).find((m) => m.type === 'status');
expect(status).toMatchObject({ type: 'status', status: 'stuck' });
expect(notify).toHaveBeenCalledTimes(1);
expect(notify).toHaveBeenCalledWith(s, 'stuck');
});
it('covers ATTACHED sessions, not only detached ones (AC-A5.3)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
// still attached: clients.size === 1, detachedAt === null
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.detachedAt).toBeNull();
expect(s.claudeStatus).toBe('stuck');
expect(notify).toHaveBeenCalledTimes(1);
});
it('alerts at most once per silent round (AC-A5.1)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 9_999);
expect(notify).toHaveBeenCalledTimes(1);
expect(s.claudeStatus).toBe('stuck');
});
it('re-arms after output recovery so a later silence alerts again (AC-A5.2)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(notify).toHaveBeenCalledTimes(1);
// Output recovery (session.ts onData) re-arms the flag + status.
(s.pty as MockIPty).emitData('back to work');
expect(s.stuckNotified).toBe(false);
expect(s.claudeStatus).toBe('working');
s.lastOutputAt = 100_000; // pin to a known cursor
mgr.sweepStuck(100_000 + CFG.stuckTtlMs + 1);
expect(notify).toHaveBeenCalledTimes(2);
expect(s.claudeStatus).toBe('stuck');
});
it('skips idle sessions', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
s.claudeStatus = 'idle';
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).toBe('idle');
expect(notify).not.toHaveBeenCalled();
});
it('skips already-exited sessions (kept in table after detach-then-exit)', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
s.clients.clear();
s.detachedAt = 2_000;
(s.pty as MockIPty).emitExit(0); // detached-then-exit → stays in table (L1)
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('does not mark a session whose output is within the TTL window', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs - 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('is disabled when stuckAlert is false', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, stuckAlert: false }, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('is disabled when stuckTtlMs is 0', () => {
const { service, notify } = createMockNotify();
const mgr = createSessionManager({ ...CFG, stuckTtlMs: 0 }, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
mgr.sweepStuck(99_999_999);
expect(s.claudeStatus).not.toBe('stuck');
expect(notify).not.toHaveBeenCalled();
});
it('swallows a notifyService rejection (logs via console.error, never throws)', async () => {
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const notify = vi.fn(async () => {
throw new Error('push transport down');
});
const service: NotifyService = { isEnabled: () => true, notify };
const mgr = createSessionManager(CFG, service);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
await Promise.resolve();
await Promise.resolve();
expect(s.claudeStatus).toBe('stuck');
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
it('works without an injected notifyService (broadcasts, no throw)', () => {
const mgr = createSessionManager(CFG); // no notifyService
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
ws.sent.length = 0;
expect(() => mgr.sweepStuck(1_000 + CFG.stuckTtlMs + 1)).not.toThrow();
expect(s.claudeStatus).toBe('stuck');
expect(parseSent(ws).some((m) => m.type === 'status' && m.status === 'stuck')).toBe(true);
});
});
// ── list() carries telemetry (B2 thumbnail wall) ──────────────────────────────
describe('list — telemetry field', () => {
it('includes the latest telemetry for each session', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const telemetry = makeTelemetry();
mgr.handleStatusLine(s.meta.id, telemetry);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.telemetry).toBe(telemetry);
});
it('reports null telemetry before the first statusLine report', () => {
const mgr = createSessionManager(CFG);
const ws = createMockWs();
const s = mgr.handleAttach(ws, null, DIMS, 1_000);
const entry = mgr.list().find((e) => e.id === s.meta.id);
expect(entry?.telemetry).toBeNull();
});
});