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

@@ -1,42 +1,54 @@
import { describe, it, expect } from 'vitest'
import { parseHookEvent } from '../src/http/hook.js'
import type { HookEventFull } from '../src/http/hook.js'
const SID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
describe('parseHookEvent', () => {
it('maps tool events to "working"', () => {
// ── Existing status / detail behaviour (must not regress) ───────────────────
// We use expect.objectContaining() so the new fields (at/eventClass/gate) do
// not break these assertion — they verify only the status-mapping contract.
describe('parseHookEvent — status mapping (regression guard)', () => {
it('maps PreToolUse / PostToolUse / UserPromptSubmit to "working"', () => {
for (const e of ['PreToolUse', 'PostToolUse', 'UserPromptSubmit']) {
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual({
sessionId: SID,
status: 'working',
})
expect(parseHookEvent(SID, { hook_event_name: e })).toEqual(
expect.objectContaining({ sessionId: SID, status: 'working' }),
)
}
})
it('maps PermissionRequest to "waiting" with the tool name as detail', () => {
expect(parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' })).toEqual(
{ sessionId: SID, status: 'waiting', detail: 'Bash' },
)
it('maps PermissionRequest to "waiting" with tool_name as detail', () => {
expect(
parseHookEvent(SID, { hook_event_name: 'PermissionRequest', tool_name: 'Bash' }),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting', detail: 'Bash' }))
})
it('maps Notification permission_prompt to "waiting", idle_prompt to "idle"', () => {
it('maps Notification(permission_prompt) → "waiting"; other Notification → "idle"', () => {
expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'permission_prompt' }),
).toEqual({ sessionId: SID, status: 'waiting' })
parseHookEvent(SID, {
hook_event_name: 'Notification',
notification_type: 'permission_prompt',
}),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'waiting' }))
expect(
parseHookEvent(SID, { hook_event_name: 'Notification', notification_type: 'idle_prompt' }),
).toEqual({ sessionId: SID, status: 'idle' })
parseHookEvent(SID, {
hook_event_name: 'Notification',
notification_type: 'idle_prompt',
}),
).toEqual(expect.objectContaining({ sessionId: SID, status: 'idle' }))
})
it('maps Stop / SessionEnd to "idle"', () => {
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual({ sessionId: SID, status: 'idle' })
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual({
sessionId: SID,
status: 'idle',
})
expect(parseHookEvent(SID, { hook_event_name: 'Stop' })).toEqual(
expect.objectContaining({ sessionId: SID, status: 'idle' }),
)
expect(parseHookEvent(SID, { hook_event_name: 'SessionEnd' })).toEqual(
expect.objectContaining({ sessionId: SID, status: 'idle' }),
)
})
it('returns null for missing sessionId, bad body, or unknown event', () => {
it('returns null for missing/empty sessionId, bad body, or unknown event', () => {
expect(parseHookEvent(undefined, { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent('', { hook_event_name: 'Stop' })).toBeNull()
expect(parseHookEvent(SID, null)).toBeNull()
@@ -45,3 +57,161 @@ describe('parseHookEvent', () => {
expect(parseHookEvent(SID, {})).toBeNull()
})
})
// ── HookEventFull — new A4 / B4 fields ──────────────────────────────────────
describe('HookEventFull — at field (A4)', () => {
it('includes a numeric timestamp in [before, after] around the call', () => {
const before = Date.now()
const ev = parseHookEvent(SID, { hook_event_name: 'Stop' }) as HookEventFull
const after = Date.now()
expect(ev).not.toBeNull()
expect(typeof ev.at).toBe('number')
expect(ev.at).toBeGreaterThanOrEqual(before)
expect(ev.at).toBeLessThanOrEqual(after)
})
it('is absent from null results', () => {
const ev = parseHookEvent(SID, { hook_event_name: 'Unknown' })
expect(ev).toBeNull()
})
})
describe('HookEventFull — eventClass field (A4)', () => {
const EVENTS = [
'PreToolUse',
'PostToolUse',
'UserPromptSubmit',
'PermissionRequest',
'Stop',
'SessionEnd',
'Notification',
]
it('carries the raw hook_event_name for every whitelisted event', () => {
for (const e of EVENTS) {
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.eventClass).toBe(e)
}
})
it('is not present when parseHookEvent returns null', () => {
expect(parseHookEvent(SID, { hook_event_name: 'NotAHook' })).toBeNull()
})
})
describe('HookEventFull — toolInput passthrough (A4)', () => {
it('includes toolInput when tool_input is present in the body', () => {
const input = { path: '/src/foo.ts', content: 'hello world' }
const ev = parseHookEvent(SID, {
hook_event_name: 'PreToolUse',
tool_input: input,
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.toolInput).toBe(input) // same reference, no copy/parse
})
it('passes toolInput through as-is (does not validate inner shape)', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PostToolUse',
tool_input: 'just a string',
}) as HookEventFull
expect(ev.toolInput).toBe('just a string')
})
it('omits toolInput when tool_input is absent', () => {
const ev = parseHookEvent(SID, { hook_event_name: 'PreToolUse' }) as HookEventFull
expect(ev).not.toBeNull()
expect('toolInput' in ev).toBe(false)
})
it('includes toolInput even when its value is null', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PostToolUse',
tool_input: null,
}) as HookEventFull
expect(ev).not.toBeNull()
expect('toolInput' in ev).toBe(true)
expect(ev.toolInput).toBeNull()
})
})
describe('HookEventFull — gate field (B4 plan-gate recognition)', () => {
it('sets gate="plan" for PermissionRequest with ExitPlanMode tool', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PermissionRequest',
tool_name: 'ExitPlanMode',
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.gate).toBe('plan')
})
it('sets gate="tool" for PermissionRequest with any other tool name', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.gate).toBe('tool')
})
it('sets gate="tool" for PermissionRequest with no tool_name', () => {
const ev = parseHookEvent(SID, { hook_event_name: 'PermissionRequest' }) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.gate).toBe('tool')
})
it('omits gate for non-PermissionRequest events', () => {
const nonPermEvents = [
'PreToolUse',
'PostToolUse',
'UserPromptSubmit',
'Stop',
'SessionEnd',
'Notification',
]
for (const e of nonPermEvents) {
const ev = parseHookEvent(SID, { hook_event_name: e }) as HookEventFull
expect(ev).not.toBeNull()
expect('gate' in ev).toBe(false)
}
})
})
describe('HookEventFull — detail not set for Notification (no tool_name)', () => {
it('Notification(permission_prompt) has status=waiting but no detail', () => {
const ev = parseHookEvent(SID, {
hook_event_name: 'Notification',
notification_type: 'permission_prompt',
}) as HookEventFull
expect(ev).not.toBeNull()
expect(ev.status).toBe('waiting')
expect('detail' in ev).toBe(false)
})
})
describe('SEC-M7 — never throws on any body shape', () => {
const malformed = [
[],
42,
true,
undefined,
{ hook_event_name: null },
{ hook_event_name: 123 },
{ hook_event_name: 'PreToolUse', tool_input: { toString: null } },
]
it('returns null without throwing for every malformed body', () => {
for (const b of malformed) {
expect(() => parseHookEvent(SID, b)).not.toThrow()
}
})
})