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

@@ -53,6 +53,12 @@ const CFG: Config = {
previewBytes: 24 * 1024,
useTmux: false,
allowedOrigins: [],
// v0.6 project manager fields
projectRoots: ['/home/tester'],
projectScanDepth: 4,
projectScanTtlMs: 10_000,
projectDirtyCheck: true,
editorCmd: 'code',
};
const DIMS: Dims = { cols: 80, rows: 24 };
@@ -407,3 +413,129 @@ describe('kill', () => {
expect((s.pty as MockIPty).killed).toBe(true);
});
});
// ── spawn env (T-spawn-env / B2) ──────────────────────────────────────────────
describe('spawn env', () => {
it('injects WEBTERM_STATUSLINE_URL pointing to /hook/status (B2)', () => {
newSession();
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toBe(
`http://127.0.0.1:${CFG.port}/hook/status`,
);
});
it('still injects WEBTERM_SESSION and WEBTERM_HOOK_URL (existing env vars)', () => {
const s = newSession();
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
expect(opts.env?.['WEBTERM_SESSION']).toBe(s.meta.id);
expect(opts.env?.['WEBTERM_HOOK_URL']).toBe(`http://127.0.0.1:${CFG.port}/hook`);
});
it('uses port from cfg (not hardcoded) in all URL env vars', () => {
nextPty = createMockPty();
const altCfg: Config = { ...CFG, port: 4321 };
createSession(altCfg, DIMS, 1_000, () => {});
const opts = spawnCalls[0]!.options as { env?: Record<string, string> };
expect(opts.env?.['WEBTERM_STATUSLINE_URL']).toContain('4321');
expect(opts.env?.['WEBTERM_HOOK_URL']).toContain('4321');
});
});
// ── session initialization — v0.7 fields (T-spawn-env) ───────────────────────
describe('session v0.7 field initialization', () => {
it('initializes timeline as an empty frozen array', () => {
const s = newSession();
expect(s.timeline).toEqual([]);
expect(Object.isFrozen(s.timeline)).toBe(true);
});
it('initializes stuckNotified as false', () => {
const s = newSession();
expect(s.stuckNotified).toBe(false);
});
it('initializes telemetry as null', () => {
const s = newSession();
expect(s.telemetry).toBeNull();
});
});
// ── onData stuck re-arming (A5 / §3.4) ───────────────────────────────────────
describe('onData stuck re-arming', () => {
it('resets stuckNotified to false on any output', () => {
const s = newSession();
s.stuckNotified = true; // simulate a stuck alert having fired this round
(s.pty as MockIPty).emitData('new output');
expect(s.stuckNotified).toBe(false);
});
it('resets claudeStatus from stuck→working and broadcasts when output arrives', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = 'stuck';
(s.pty as MockIPty).emitData('recovered output');
expect(s.claudeStatus).toBe('working');
const msgs = received(ws);
const statusMsg = msgs.find((m) => m.type === 'status');
expect(statusMsg).toEqual({ type: 'status', status: 'working' });
});
it('does NOT emit a status message when claudeStatus is not stuck', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = 'working'; // not stuck
(s.pty as MockIPty).emitData('normal chunk');
const msgs = received(ws);
const statusMsgs = msgs.filter((m) => m.type === 'status');
expect(statusMsgs).toHaveLength(0);
});
it('does NOT emit a status message for idle/unknown/waiting (only stuck → working)', () => {
for (const status of ['idle', 'unknown', 'waiting'] as const) {
nextPty = createMockPty();
const s = createSession(CFG, DIMS, 1_000, () => {});
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = status;
(s.pty as MockIPty).emitData('chunk');
const msgs = received(ws);
const statusMsgs = msgs.filter((m) => m.type === 'status');
expect(statusMsgs).toHaveLength(0);
}
});
it('broadcasts stuck reset even with no clients attached (no-op, does not throw)', () => {
const s = newSession();
// no clients attached
s.claudeStatus = 'stuck';
expect(() => (s.pty as MockIPty).emitData('silent recovery')).not.toThrow();
expect(s.claudeStatus).toBe('working');
expect(s.stuckNotified).toBe(false);
});
it('output message is still broadcast after stuck re-arm', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0;
s.claudeStatus = 'stuck';
(s.pty as MockIPty).emitData('hello after stuck');
const msgs = received(ws);
const outputMsgs = msgs.filter((m) => m.type === 'output');
expect(outputMsgs).toHaveLength(1);
expect(outputMsgs[0]).toEqual({ type: 'output', data: 'hello after stuck' });
});
});