diff --git a/codev/resources/arch.md b/codev/resources/arch.md index 88c0af722..255c8ee2b 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -312,15 +312,15 @@ A workspace can host more than one architect terminal. Each architect has a stab - `terminal-manager.ts` keys terminal slots by architect name (`architect:${name}`), not the pre-786 singleton `'architect'`. Each architect gets its own VSCode terminal. - Right-click context menu on a sibling entry → "Remove Architect" (gated on `viewItem == workspace-architect-sibling`; `main` uses `'workspace-architect-main'` and gets no remove option). - `codev.referenceIssueInArchitect` (Backlog inline button) always targets `main` regardless of how many siblings exist — preserves the pre-786 Backlog UX. -- **Spec 823**: the tree auto-refreshes when an architect is added or removed from outside VSCode (CLI, dashboard close-button, mobile TabBar). Tower emits an `architects-updated` SSE notification from every successful add/remove path; `WorkspaceProvider` subscribes via its existing `connectionManager.onSSEEvent` callback and fires `changeEmitter` on a matching envelope. Same JSON-envelope-on-`data:` shape as `worktree-config-updated`, no workspace filter at the SSE-subscriber layer. +- **Spec 823**: the tree auto-refreshes when an architect is added or removed from outside VSCode (CLI, dashboard close-button, mobile TabBar). Tower emits an `architects-updated` SSE notification from every successful add/remove path; `WorkspaceProvider` subscribes via its existing `connectionManager.onSSEEvent` callback and fires `changeEmitter` on a matching envelope. Same JSON-envelope-on-`data:` shape as `codev-config-updated`, no workspace filter at the SSE-subscriber layer. #### Tower SSE Event Conventions -Tower fans events to subscribers via an SSE stream. The shape convention (used by `worktree-config-updated`, `architects-updated`, and `builder-spawned`): +Tower fans events to subscribers via an SSE stream. The shape convention (used by `codev-config-updated`, `architects-updated`, and `builder-spawned`): - Events ride the generic `notification` SSE event type — no per-event-type `event:` name on the SSE wire. The SSE-client-level `type` is always `''`; the real event-type lives inside the JSON envelope at `data.type`. - Subscribers parse the `data:` JSON in a `try/catch` to swallow malformed payloads, then match `envelope.type === ''` to decide whether to act. -- `NotifyFn` shape (`worktree-config-watcher.ts:19-24`): `{ type: string; title: string; body: string; workspace?: string }`. `body` is `JSON.stringify({ workspace })` for events that are workspace-scoped. +- `NotifyFn` shape (`codev-config-watcher.ts:19-24`): `{ type: string; title: string; body: string; workspace?: string }`. `body` is `JSON.stringify({ workspace })` for events that are workspace-scoped. - `ctx.broadcastNotification` is available directly on the `RouteContext` for route handlers; standalone modules (like the worktree config watcher) wire their own notifier via a setter (`setWorktreeConfigNotifier`). #### Builder Gate Notifications (Spec 0100, replaced by Spec 0108) diff --git a/packages/codev/src/__tests__/config.test.ts b/packages/codev/src/__tests__/config.test.ts index a6bc864ee..f2eefa993 100644 --- a/packages/codev/src/__tests__/config.test.ts +++ b/packages/codev/src/__tests__/config.test.ts @@ -10,6 +10,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { deepMerge, loadConfig, resolveProjectConfigPath, resolveLocalConfigPath } from '../lib/config.js'; +import { getActivityHooks } from '../agent-farm/utils/config.js'; // Helpers let tmpDir: string; @@ -254,3 +255,38 @@ describe('loadConfig', () => { expect(config.shell?.architect).toBe('project-architect'); }); }); + +describe('getActivityHooks (trusted personal layers only — never the committed config)', () => { + it('resolves hooks from .codev/config.local.json, dropping malformed entries', () => { + writeLocalConfig(tmpDir, { + activityHooks: [ + { on: ['window-focus', 'builder-active'], url: 'app://x?w={workspace}', background: true }, + { on: ['bogus-event'], url: 'app://drop-me' }, // no valid event → dropped + { on: ['window-focus'] }, // no url → dropped + ], + }); + expect(getActivityHooks(tmpDir).hooks).toEqual([ + { on: ['window-focus', 'builder-active'], url: 'app://x?w={workspace}', background: true }, + ]); + }); + + it('IGNORES the committed .codev/config.json (closes the zero-click RCE vector)', () => { + writeProjectConfig(tmpDir, { activityHooks: [{ on: ['window-focus'], url: 'app://committed-rce' }] }); + expect(getActivityHooks(tmpDir).hooks).toEqual([]); + }); + + it('a per-engineer config.local.json replaces the global hook', () => { + writeGlobalConfig({ activityHooks: [{ on: ['window-focus'], url: 'app://global' }] }); + writeLocalConfig(tmpDir, { activityHooks: [{ on: ['window-focus'], url: 'app://mine' }] }); + expect(getActivityHooks(tmpDir).hooks.map((h) => h.url)).toEqual(['app://mine']); + }); + + it('picks up a personal hook from ~/.codev/config.json (global)', () => { + writeGlobalConfig({ activityHooks: [{ on: ['builder-active'], url: 'app://global' }] }); + expect(getActivityHooks(tmpDir).hooks.map((h) => h.url)).toEqual(['app://global']); + }); + + it('returns [] when nothing is configured', () => { + expect(getActivityHooks(tmpDir).hooks).toEqual([]); + }); +}); diff --git a/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts b/packages/codev/src/agent-farm/servers/codev-config-watcher.ts similarity index 70% rename from packages/codev/src/agent-farm/servers/worktree-config-watcher.ts rename to packages/codev/src/agent-farm/servers/codev-config-watcher.ts index 6d2620c4c..fa47c9450 100644 --- a/packages/codev/src/agent-farm/servers/worktree-config-watcher.ts +++ b/packages/codev/src/agent-farm/servers/codev-config-watcher.ts @@ -1,11 +1,13 @@ /** * Per-workspace file watcher for `.codev/config.json` and - * `.codev/config.local.json`. Lazily installed by the - * `/api/worktree-config` route handler on first request, then persists - * for the Tower process lifetime. On each detected change it fans out - * a `worktree-config-updated` SSE event so subscribed clients (the - * VSCode extension, the dashboard) can refetch via the route and - * re-render. + * `.codev/config.local.json`. Lazily installed by any config-resolving route + * handler (`/api/worktree-config`, `/api/activity-hooks`) on first request, then + * persists for the Tower process lifetime. On each detected change it fans out a + * `codev-config-updated` SSE event so subscribed clients (the VSCode extension, the + * dashboard) refetch whichever resolved config they consume and re-render. + * + * Watches the codev config FILES, not any one config section — so a single watcher + * + event serves every consumer of `.codev/config(.local).json`. * * Pattern follows `tower-tunnel.ts:startConfigWatcher` (which watches * `~/.codev/cloud.json` for OAuth credential changes) — `node:fs.watch` @@ -32,10 +34,9 @@ let notify: NotifyFn | undefined; /** * Wire the broadcast function once at Tower startup. Subsequent calls - * to `ensureWorktreeConfigWatcher` will use this notifier when files - * change. + * to `ensureCodevConfigWatcher` will use this notifier when files change. */ -export function setWorktreeConfigNotifier(fn: NotifyFn): void { +export function setCodevConfigNotifier(fn: NotifyFn): void { notify = fn; } @@ -44,7 +45,7 @@ export function setWorktreeConfigNotifier(fn: NotifyFn): void { * `/.codev/{config.json,config.local.json}`. Safe to * call on every route hit. */ -export function ensureWorktreeConfigWatcher(workspacePath: string): void { +export function ensureCodevConfigWatcher(workspacePath: string): void { if (watchers.has(workspacePath)) { return; } const dir = path.join(workspacePath, '.codev'); try { @@ -57,8 +58,8 @@ export function ensureWorktreeConfigWatcher(workspacePath: string): void { setTimeout(() => { debounces.delete(workspacePath); notify?.({ - type: 'worktree-config-updated', - title: 'Worktree config changed', + type: 'codev-config-updated', + title: 'Codev config changed', body: JSON.stringify({ workspace: workspacePath }), workspace: workspacePath, }); @@ -73,7 +74,7 @@ export function ensureWorktreeConfigWatcher(workspacePath: string): void { } /** Test / shutdown helper — close every watcher and clear pending debounces. */ -export function stopAllWorktreeConfigWatchers(): void { +export function stopAllCodevConfigWatchers(): void { for (const t of debounces.values()) { clearTimeout(t); } debounces.clear(); for (const w of watchers.values()) { try { w.close(); } catch { /* benign */ } } diff --git a/packages/codev/src/agent-farm/servers/tower-routes.ts b/packages/codev/src/agent-farm/servers/tower-routes.ts index 0e31af506..62dbd5555 100644 --- a/packages/codev/src/agent-farm/servers/tower-routes.ts +++ b/packages/codev/src/agent-farm/servers/tower-routes.ts @@ -40,8 +40,8 @@ import { serveStaticFile, } from './tower-utils.js'; import { handleTunnelEndpoint } from './tower-tunnel.js'; -import { getWorktreeConfig } from '../utils/config.js'; -import { ensureWorktreeConfigWatcher } from './worktree-config-watcher.js'; +import { getWorktreeConfig, getActivityHooks } from '../utils/config.js'; +import { ensureCodevConfigWatcher } from './codev-config-watcher.js'; import { hasTeam, loadTeamMembers, loadMessages, type TeamMember, type TeamMessage } from '../../lib/team.js'; import { fetchTeamGitHubData, type TeamMemberGitHubData } from '../../lib/team-github.js'; import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js'; @@ -168,6 +168,7 @@ const ROUTES: Record = { 'GET /api/issue': (_req, res, url) => handleIssueView(res, url), 'GET /api/issue-search': (_req, res, url) => handleIssueSearch(res, url), 'GET /api/worktree-config': (_req, res, url) => handleWorktreeConfigView(res, url), + 'GET /api/activity-hooks': (_req, res, url) => handleActivityHooksView(res, url), 'GET /api/analytics': (_req, res, url) => handleAnalytics(res, url), 'POST /api/overview/refresh': (_req, res, _url, ctx) => handleOverviewRefresh(res, ctx), 'GET /api/events': (req, res, _url, ctx) => handleSSEEvents(req, res, ctx), @@ -387,7 +388,7 @@ async function handleAddArchitect( // Spec 823: emit an `architects-updated` SSE event so VSCode's // WorkspaceProvider tree refreshes when the add happens via the CLI // (today the tree only refreshes when add is triggered from within - // VSCode itself). Mirrors `worktree-config-updated`'s broadcast shape. + // VSCode itself). Mirrors `codev-config-updated`'s broadcast shape. ctx.broadcastNotification({ type: 'architects-updated', title: 'Architects updated', @@ -1031,7 +1032,7 @@ async function handleIssueSearch(res: http.ServerResponse, url: URL): Promise !p.includes('/.builders/')) || null; + } + if (!workspaceRoot) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing workspace' })); + return; + } + try { + const config = getActivityHooks(workspaceRoot); + ensureCodevConfigWatcher(workspaceRoot); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(config)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Failed to resolve activity hooks: ${message}` })); + } +} + function handleOverviewRefresh(res: http.ServerResponse, ctx?: RouteContext): void { overviewCache.invalidate(); // Bugfix #388: Broadcast SSE event so all connected dashboard clients diff --git a/packages/codev/src/agent-farm/servers/tower-server.ts b/packages/codev/src/agent-farm/servers/tower-server.ts index 603113243..d6cf24399 100644 --- a/packages/codev/src/agent-farm/servers/tower-server.ts +++ b/packages/codev/src/agent-farm/servers/tower-server.ts @@ -49,7 +49,7 @@ import { } from './tower-websocket.js'; import { handleRequest, startSendBuffer, stopSendBuffer } from './tower-routes.js'; import type { RouteContext } from './tower-routes.js'; -import { setWorktreeConfigNotifier, stopAllWorktreeConfigWatchers } from './worktree-config-watcher.js'; +import { setCodevConfigNotifier, stopAllCodevConfigWatchers } from './codev-config-watcher.js'; import { DEFAULT_TOWER_PORT } from '../lib/tower-client.js'; import { validateHost } from '../utils/server-utils.js'; import { version } from '../../version.js'; @@ -176,7 +176,7 @@ async function gracefulShutdown(signal: string): Promise { shutdownTunnel(); // 6b. Close per-workspace .codev/config(.local).json watchers. - stopAllWorktreeConfigWatchers(); + stopAllCodevConfigWatchers(); // 7. Tear down instance module (Spec 0105 Phase 3) shutdownInstances(); @@ -340,11 +340,11 @@ const routeCtx: RouteContext = { }, }; -// Wire the broadcast function into the worktree config watcher so file -// edits to .codev/config{,.local}.json fan out as -// `worktree-config-updated` SSE events. The actual watcher is installed -// lazily by the /api/worktree-config route handler on first request. -setWorktreeConfigNotifier(broadcastNotification); +// Wire the broadcast function into the codev config-file watcher so edits +// to .codev/config{,.local}.json fan out as `codev-config-updated` SSE +// events. The actual watcher is installed lazily by any config-resolving +// route handler (/api/worktree-config, /api/activity-hooks) on first request. +setCodevConfigNotifier(broadcastNotification); // ============================================================================ // Create server — delegates all HTTP handling to tower-routes.ts diff --git a/packages/codev/src/agent-farm/types.ts b/packages/codev/src/agent-farm/types.ts index f12c68d02..84ae58e41 100644 --- a/packages/codev/src/agent-farm/types.ts +++ b/packages/codev/src/agent-farm/types.ts @@ -243,6 +243,19 @@ export interface UserConfig { */ devUrls?: Array<{ label: string; url: string }>; }; + /** + * Activity hooks: URL sinks the VSCode extension fires when an abstract event + * occurs (`window-focus`, `builder-active`). Integration-agnostic — the + * destination url (a deep link, a companion app, a webhook launcher) is yours. + * Like other array settings, a higher config layer REPLACES a lower one's list, + * so define them in a single layer: `~/.codev/config.json` for a personal hook + * across all repos, or `.codev/config.local.json` for a per-repo personal one. + */ + activityHooks?: Array<{ + on?: Array<'window-focus' | 'builder-active'>; + url?: string; + background?: boolean; + }>; } /** diff --git a/packages/codev/src/agent-farm/utils/config.ts b/packages/codev/src/agent-farm/utils/config.ts index c5ca82d22..2fe7c3bbb 100644 --- a/packages/codev/src/agent-farm/utils/config.ts +++ b/packages/codev/src/agent-farm/utils/config.ts @@ -2,7 +2,8 @@ * Configuration management for Agent Farm */ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; @@ -11,7 +12,7 @@ import { getSkeletonDir } from '../../lib/skeleton.js'; import { loadConfig } from '../../lib/config.js'; import type { CodevConfig } from '../../lib/config.js'; import { resolveHarness, type HarnessProvider, type CustomHarnessConfig } from './harness.js'; -import type { ResolvedWorktreeConfig, WorktreeDevUrl } from '@cluesmith/codev-types'; +import type { ResolvedWorktreeConfig, WorktreeDevUrl, ResolvedActivityHooks, ActivityHook, ActivityEvent } from '@cluesmith/codev-types'; // Re-export so existing internal callers that import the resolved types // from this module keep working. The canonical home is now @@ -298,6 +299,49 @@ export function getWorktreeConfig(workspaceRoot?: string): ResolvedWorktreeConfi }; } +const ACTIVITY_EVENTS: ReadonlySet = new Set(['window-focus', 'builder-active']); + +interface RawActivityHook { on?: string[]; url?: string; background?: boolean } + +/** + * Read the `activityHooks` array from one config file. Returns `undefined` when the + * file is missing/invalid or doesn't define the key — so a present-but-empty list in + * a higher layer can still REPLACE a lower one (array-replace, matching deepMerge). + */ +function readActivityHooksLayer(configPath: string): RawActivityHook[] | undefined { + try { + const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as { activityHooks?: RawActivityHook[] }; + if (!('activityHooks' in parsed)) { return undefined; } + return Array.isArray(parsed.activityHooks) ? parsed.activityHooks : []; + } catch { + return undefined; // absent / unreadable / invalid JSON → layer not present + } +} + +/** + * Resolved `activityHooks` for a workspace. + * + * SECURITY: hooks EXECUTE (the VSCode extension opens their url), so they are + * resolved ONLY from the user's trusted personal config layers — `~/.codev/config.json` + * (global, across all repos) and `/.codev/config.local.json` (per-engineer, + * gitignored) — and NEVER from the committed `.codev/config.json`, which a cloned repo + * controls (a committed hook would be a zero-click RCE). The project-local layer + * replaces the global one when present. Malformed entries (no url, or no valid `on` + * event) are dropped. + */ +export function getActivityHooks(workspaceRoot?: string): ResolvedActivityHooks { + const root = workspaceRoot || findWorkspaceRoot(); + const local = readActivityHooksLayer(resolve(root, '.codev', 'config.local.json')); + const global = readActivityHooksLayer(resolve(homedir(), '.codev', 'config.json')); + const raw = local ?? global ?? []; + const hooks: ActivityHook[] = raw.flatMap((h) => { + const on = (h?.on ?? []).filter((e): e is ActivityEvent => ACTIVITY_EVENTS.has(e)); + if (!h?.url || on.length === 0) { return []; } + return [{ on, url: h.url, background: h.background ?? false }]; + }); + return { hooks }; +} + /** * Filter malformed entries (missing/empty `label` or `url`). Both * fields are mandatory by schema — no default-label fallback. diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts index 94bc927dc..c37e96f47 100644 --- a/packages/core/src/tower-client.ts +++ b/packages/core/src/tower-client.ts @@ -7,7 +7,7 @@ * Extracted from packages/codev/src/agent-farm/lib/tower-client.ts */ -import type { DashboardState, OverviewData, IssueView, IssueSearchResponse, ResolvedWorktreeConfig, TowerVersionInfo } from '@cluesmith/codev-types'; +import type { DashboardState, OverviewData, IssueView, IssueSearchResponse, ResolvedWorktreeConfig, ResolvedActivityHooks, TowerVersionInfo } from '@cluesmith/codev-types'; import { DEFAULT_TOWER_PORT } from './constants.js'; import { ensureLocalKey } from './auth.js'; @@ -379,7 +379,7 @@ export class TowerClient { * that needs to act on `.codev/config(.local).json` — e.g. the VSCode * "Open Dev URL" surface — without parsing or merging the files * locally. Tower lazily installs a directory watcher on first call; - * subsequent edits fan out a `worktree-config-updated` SSE event so + * subsequent edits fan out a `codev-config-updated` SSE event so * subscribed clients refetch and re-render. Returns null on failure * so callers can degrade. */ @@ -389,6 +389,21 @@ export class TowerClient { return result.ok ? result.data! : null; } + /** + * Resolved `activityHooks` from Tower's GET /api/activity-hooks. Single source of + * truth for the extension's activity hooks — no local file parsing. SECURITY: Tower + * resolves these from the PERSONAL config layers only (`~/.codev/config.json` + + * `.codev/config.local.json`), never the committed `.codev/config.json` — hooks open + * URLs, so a committed hook would be a zero-click RCE (do NOT widen to loadConfig). + * Shares the config-file watcher with worktree-config, so a `.codev/config(.local).json` + * edit fans out a `codev-config-updated` SSE and subscribed clients refetch. Null on failure. + */ + async getActivityHooks(workspacePath?: string): Promise { + const query = workspacePath ? `?workspace=${encodeURIComponent(workspacePath)}` : ''; + const result = await this.request(`/api/activity-hooks${query}`); + return result.ok ? result.data! : null; + } + /** * Invalidate Tower's in-memory overview cache and broadcast an * `overview-changed` SSE event. Subscribed clients (VSCode sidebar, diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index ab9a42b01..c2f8d0f50 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -320,6 +320,37 @@ export interface ResolvedWorktreeConfig { devUrls: WorktreeDevUrl[]; } +// --- Activity hooks (GET /api/activity-hooks) --- + +/** An abstract Codev activity the extension publishes to configured URL hooks. */ +export type ActivityEvent = 'window-focus' | 'builder-active'; + +/** + * One configured sink from the `activityHooks` config block: when one of `on`'s + * events fires, the extension opens `url` (a template — `{workspace}`/`{builder}` + * placeholders are replaced with the URL-encoded event data). `background: true` + * delivers without foregrounding the handler app. The destination is the user's + * (a deep link, a companion app, a webhook launcher); the extension is agnostic. + */ +export interface ActivityHook { + on: ActivityEvent[]; + url: string; + background?: boolean; +} + +/** + * Resolved `activityHooks`. SECURITY: hooks resolve from the **personal config + * layers only** — `~/.codev/config.json` (global) + `.codev/config.local.json` + * (per-engineer) — and NEVER the committed `.codev/config.json` project layer. + * Hooks open URLs (a deep link, a companion app, a scheme handler), so sourcing + * them from repo-committed config would be a zero-click RCE (a cloned repo could + * ship a hook url that fires on `window-focus`). Do NOT widen this to `loadConfig`. + * Malformed entries are dropped; `hooks` is always an array (`[]` when unset). + */ +export interface ResolvedActivityHooks { + hooks: ActivityHook[]; +} + // --- Issue view (GET /api/issue) --- /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 60282cc48..7bb562ef2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -36,6 +36,9 @@ export { type IssueSearchResponse, type WorktreeDevUrl, type ResolvedWorktreeConfig, + type ActivityEvent, + type ActivityHook, + type ResolvedActivityHooks, type TeamMemberGitHubData, type ReviewBlockingEntry, type TeamApiMember, diff --git a/packages/vscode/src/__tests__/activity-hooks.test.ts b/packages/vscode/src/__tests__/activity-hooks.test.ts new file mode 100644 index 000000000..dab8cdf19 --- /dev/null +++ b/packages/vscode/src/__tests__/activity-hooks.test.ts @@ -0,0 +1,59 @@ +/** + * Activity hooks: the extension publishes abstract events (`window-focus`, + * `builder-active`) to URL templates configured in `.codev/config.json`, with no + * knowledge of the destination. These cover the pure selection + interpolation core + * (`resolveHookUrls`); the OS-handler delivery is a thin `execFile` wrapper. + * + * Mocks `vscode` per the established `__tests__` pattern (the module imports it for + * the one-time failure warning). + */ +import { describe, it, expect, vi } from 'vitest'; +import type { ActivityHook } from '@cluesmith/codev-types'; + +vi.mock('vscode', () => ({ window: { showWarningMessage: vi.fn() } })); + +const { resolveHookUrls } = await import('../activity-hooks.js'); + +const values = { workspace: '/Users/me/repos/proj', builder: 'pir-1298-slug' }; + +describe('resolveHookUrls', () => { + it('interpolates + URL-encodes placeholders', () => { + const out = resolveHookUrls( + [{ on: ['builder-active'], url: 'app://x/active?workspace={workspace}&builder={builder}' }], + 'builder-active', + values, + ); + expect(out).toEqual([{ + url: 'app://x/active?workspace=%2FUsers%2Fme%2Frepos%2Fproj&builder=pir-1298-slug', + background: false, + }]); + }); + + it('only returns hooks listening for the event', () => { + const hooks = [ + { on: ['window-focus' as const], url: 'app://focus?w={workspace}' }, + { on: ['builder-active' as const], url: 'app://builder?b={builder}' }, + ]; + expect(resolveHookUrls(hooks, 'window-focus', values).map((h) => h.url)).toEqual(['app://focus?w=%2FUsers%2Fme%2Frepos%2Fproj']); + expect(resolveHookUrls(hooks, 'builder-active', values).map((h) => h.url)).toEqual(['app://builder?b=pir-1298-slug']); + }); + + it('passes through the background flag and blanks unknown placeholders', () => { + const out = resolveHookUrls( + [{ on: ['window-focus'], url: 'app://x?b={builder}&z={unknown}', background: true }], + 'window-focus', + { workspace: '/w' }, // no builder + ); + expect(out).toEqual([{ url: 'app://x?b=&z=', background: true }]); + }); + + it('ignores malformed hooks (no url, or no on[])', () => { + // Tower validates before serving, but resolveHookUrls is defensive; feed it junk. + const hooks = [ + { on: ['window-focus'] }, // no url + { url: 'app://x' }, // no on[] + { on: ['window-focus'], url: 'app://ok' }, + ] as unknown as ActivityHook[]; + expect(resolveHookUrls(hooks, 'window-focus', values).map((h) => h.url)).toEqual(['app://ok']); + }); +}); diff --git a/packages/vscode/src/__tests__/command-relay.test.ts b/packages/vscode/src/__tests__/command-relay.test.ts index 3afb4218c..2c32795d8 100644 --- a/packages/vscode/src/__tests__/command-relay.test.ts +++ b/packages/vscode/src/__tests__/command-relay.test.ts @@ -136,4 +136,18 @@ describe('wireCommandProvider', () => { expect(vscode.commands.executeCommand).toHaveBeenCalledWith('codev.viewDiff', '0809'); }); + + it('strips a controller-supplied options arg from approve-gate (no silent human-gate approval)', async () => { + const { mgr, fire } = makeConnMgr(); + wireCommandProvider(mgr as never); + + // A crafted `{ skipConfirmation: true }` must NOT reach codev.approveGate — that + // path runs `porch approve --a-human-explicitly-approved-this` with no human. + fire('command', { verb: 'approve-gate', args: ['0042', { skipConfirmation: true }] }); + await new Promise((r) => setTimeout(r, 0)); + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('codev.approveGate', '0042'); + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + 'codev.approveGate', '0042', { skipConfirmation: true }); + }); }); diff --git a/packages/vscode/src/__tests__/menu-when-clauses.test.ts b/packages/vscode/src/__tests__/menu-when-clauses.test.ts index 66c48d171..d4eff8350 100644 --- a/packages/vscode/src/__tests__/menu-when-clauses.test.ts +++ b/packages/vscode/src/__tests__/menu-when-clauses.test.ts @@ -132,7 +132,7 @@ describe('commandPalette hiding for view{Spec,Plan,Review}File', () => { * they gated on view + viewItem family only, so they showed even with no * dev command — picking one ran against a missing command. The fix appends * `&& codev.hasDevCommand` (a setContext key extension.ts refreshes from the - * Tower-merged config on connect + the `worktree-config-updated` SSE) to the + * Tower-merged config on connect + the `codev-config-updated` SSE) to the * `when` clause. * * The same gate extends to the keybindings and the workspace-dev palette diff --git a/packages/vscode/src/__tests__/workspace-sse-subscriber.test.ts b/packages/vscode/src/__tests__/workspace-sse-subscriber.test.ts index 39a986c18..84d24146b 100644 --- a/packages/vscode/src/__tests__/workspace-sse-subscriber.test.ts +++ b/packages/vscode/src/__tests__/workspace-sse-subscriber.test.ts @@ -148,13 +148,13 @@ describe('Spec 823 Phase 4 — WorkspaceProvider SSE subscriber runtime behaviou expect(fire).toHaveBeenCalledTimes(1); }); - it('fires changeEmitter when a worktree-config-updated envelope arrives (regression)', () => { + it('fires changeEmitter when a codev-config-updated envelope arrives (regression)', () => { // Phase 4 must NOT break the existing #786 behaviour. const { fire, captured } = makeProvider(); fire.mockClear(); const sseHandler = captured.sse[0]; - sseHandler({ type: '', data: JSON.stringify({ type: 'worktree-config-updated' }) }); + sseHandler({ type: '', data: JSON.stringify({ type: 'codev-config-updated' }) }); expect(fire).toHaveBeenCalledTimes(1); }); @@ -222,7 +222,7 @@ describe('Spec 823 Phase 4 — WorkspaceProvider SSE subscriber runtime behaviou const sseHandler = captured.sse[0]; sseHandler({ type: '', data: JSON.stringify({ type: 'architects-updated' }) }); sseHandler({ type: '', data: JSON.stringify({ type: 'architects-updated' }) }); - sseHandler({ type: '', data: JSON.stringify({ type: 'worktree-config-updated' }) }); + sseHandler({ type: '', data: JSON.stringify({ type: 'codev-config-updated' }) }); expect(fire).toHaveBeenCalledTimes(3); }); diff --git a/packages/vscode/src/__tests__/workspace.test.ts b/packages/vscode/src/__tests__/workspace.test.ts index a16ab3b24..c1c0110de 100644 --- a/packages/vscode/src/__tests__/workspace.test.ts +++ b/packages/vscode/src/__tests__/workspace.test.ts @@ -93,9 +93,9 @@ describe('Spec 786 Phase 6 — WorkspaceProvider expandable Architects tree', () describe('Spec 823 — WorkspaceProvider subscribes to architects-updated SSE', () => { it('SSE subscriber matches the architects-updated envelope type', () => { // The subscriber callback uses the SAME `connectionManager.onSSEEvent` - // subscription that already handles `worktree-config-updated`. Phase 4 + // subscription that already handles `codev-config-updated`. Phase 4 // extends the type check to also accept `architects-updated`. - expect(WS_SRC).toMatch(/envelope\.type === ['"]worktree-config-updated['"]/); + expect(WS_SRC).toMatch(/envelope\.type === ['"]codev-config-updated['"]/); expect(WS_SRC).toMatch(/envelope\.type === ['"]architects-updated['"]/); }); diff --git a/packages/vscode/src/activity-hooks.ts b/packages/vscode/src/activity-hooks.ts new file mode 100644 index 000000000..237a8e62b --- /dev/null +++ b/packages/vscode/src/activity-hooks.ts @@ -0,0 +1,112 @@ +import * as vscode from 'vscode'; +import { execFile } from 'node:child_process'; +import type { ActivityEvent, ActivityHook } from '@cluesmith/codev-types'; + +export type { ActivityEvent, ActivityHook }; + +/** + * The active workspace's resolved activity hooks. Fed from Tower's + * `GET /api/activity-hooks` via `setActivityHooks`, and refreshed on the + * `codev-config-updated` SSE — so we never parse or merge config ourselves. SECURITY: + * Tower resolves these from the PERSONAL config layers only (`~/.codev/config.json` + + * `.codev/config.local.json`), never the committed `.codev/config.json`, because hooks + * open URLs and a committed hook would be a zero-click RCE. + */ +let cachedHooks: ActivityHook[] = []; + +/** Replace the cached hooks (called after fetching from Tower). */ +export function setActivityHooks(hooks: ActivityHook[]): void { + cachedHooks = hooks; +} + +/** + * Set once a hook delivery fails (no handler for the url, or no opener) so we stop + * spawning a doomed process on every event. Reset on window reload (module re-init). + */ +let deliveryDisabled = false; + +/** Last `(event, workspace, builder)` fired — to dedup rapid repeats (see fireActivity). */ +let lastFiredKey = ''; + +/** + * Publish an activity event to the configured URL hooks. No-op when no hook listens + * for the event, so a workspace that configures nothing sees zero behavior. The + * extension knows only the abstract event + its data; the destination url lives in + * config (a deep link, a companion app, a webhook launcher). + */ +export function fireActivity( + workspaceRoot: string | null, + event: ActivityEvent, + data: Record = {}, +): void { + if (deliveryDisabled || !workspaceRoot) { return; } + // SECURITY: hooks execute (we open their url), so never fire in a workspace the + // user hasn't trusted (VSCode Restricted Mode). Defence-in-depth with resolving + // hooks from personal config layers only (never the committed .codev/config.json). + if (!vscode.workspace.isTrusted) { return; } + // `builder-active` is emitted from three subscriptions (diff / terminal / sidebar); + // rapid navigation within one builder would relaunch the same url repeatedly. Dedup + // consecutive identical fires. + const key = JSON.stringify({ event, workspaceRoot, builder: data.builder ?? '' }); + if (key === lastFiredKey) { return; } + lastFiredKey = key; + const values: Record = { workspace: workspaceRoot, ...data }; + for (const { url, background } of resolveHookUrls(cachedHooks, event, values)) { + openUrl(url, background); + } +} + +/** + * Pure core: select hooks listening for `event` and interpolate their url + * templates with `values` (each value URL-encoded; absent keys → empty). + */ +export function resolveHookUrls( + hooks: ActivityHook[], + event: ActivityEvent, + values: Record, +): Array<{ url: string; background: boolean }> { + return hooks + .filter((h) => !!h.url && Array.isArray(h.on) && h.on.includes(event)) + .map((h) => ({ url: interpolate(h.url, values), background: h.background ?? false })); +} + +function interpolate(template: string, values: Record): string { + return template.replace(/\{(\w+)\}/g, (_, key: string) => encodeURIComponent(values[key] ?? '')); +} + +/** + * Deliver a url to its OS handler. Routes through `vscode.env.openExternal` so it + * works under remote dev (the URL is forwarded to the LOCAL client, where the + * handler app — a Stream Deck, a companion app — actually lives) and avoids OS + * shell-quoting pitfalls (a Windows `cmd /c start "" url` would treat an `&` in the + * url as a command separator and truncate/execute the remainder). + * + * The one exception is macOS + `background:true`: `open -g` is the only way to + * deliver WITHOUT foregrounding the handler app, which `openExternal` can't express. + * (That path is local-only; a background hook under remote dev is an accepted edge.) + * + * Fire-and-forget. On the first failure we stop and warn once, rather than retrying + * a doomed url on every event. + */ +function openUrl(url: string, background: boolean): void { + const onFail = (): void => { + if (deliveryDisabled) { return; } + deliveryDisabled = true; + void vscode.window.showWarningMessage( + 'Codev: an activity hook could not be delivered (no handler for its url). Pausing activity hooks ' + + 'for this window; reload the window to retry, or fix the url in your codev config.', + ); + }; + if (background && process.platform === 'darwin') { + execFile('open', ['-g', url], (err) => { if (err) { onFail(); } }); // -g = don't foreground + return; + } + let uri: vscode.Uri; + try { + uri = vscode.Uri.parse(url, true); + } catch { + onFail(); + return; + } + void vscode.env.openExternal(uri).then((ok) => { if (!ok) { onFail(); } }, () => { onFail(); }); +} diff --git a/packages/vscode/src/command-relay.ts b/packages/vscode/src/command-relay.ts index 93eb24c8d..dae3fd3b5 100644 --- a/packages/vscode/src/command-relay.ts +++ b/packages/vscode/src/command-relay.ts @@ -35,6 +35,9 @@ const VERB_COMMANDS: Record = { // Context verbs (operate on the focused editor; no arg). 'add-comment': 'codev.addReviewComment', 'forward-selection': 'codev.forwardSelectionToBuilder', + // Gate review (arg: builder id). Surfaces the approval modal in the focused + // window — a deliberate, human-confirmed action, never a silent approve. + 'approve-gate': 'codev.approveGate', // Diff-review navigation. 'diff-next-file': 'codev.diffNextFile', 'diff-prev-file': 'codev.diffPreviousFile', @@ -42,7 +45,11 @@ const VERB_COMMANDS: Record = { 'diff-next-hunk': 'workbench.action.compareEditor.nextChange', 'diff-prev-hunk': 'workbench.action.compareEditor.previousChange', 'diff-first-hunk': 'codev.diffFirstHunk', + // Viewport scroll of the focused editor (the Scroll dial). Args carry the + // built-in editorScroll options { to, by, value, revealCursor }. + 'scroll': 'editorScroll', // Workspace verbs (configurable Codev Action key / Dev Server key). + 'focus-workspace': 'codev.focusWorkspaceWindow', 'open-architect-terminal': 'codev.openArchitectTerminal', 'open-builder-terminal': 'codev.openBuilderTerminal', 'send-message': 'codev.sendMessage', @@ -77,8 +84,15 @@ export function wireCommandProvider(connectionManager: ConnectionManager): vscod // The verb operands arrive over the wire as `unknown[]`; a non-array (a stray // object) would throw on spread, so coerce to an empty arg list. const args = Array.isArray(req.args) ? req.args : []; + // SECURITY: `approve-gate` surfaces the human-confirmation modal; its command + // ALSO accepts an `{ skipConfirmation }` options arg that runs + // `porch approve --a-human-explicitly-approved-this` with no human. A controller + // must never reach that path, so forward ONLY the builder id (first arg) — never + // a second options object. (Other verbs legitimately take object args, e.g. + // `scroll`, so this is scoped to the privileged verb, not a blanket filter.) + const callArgs = req.verb === 'approve-gate' ? args.slice(0, 1) : args; try { - await vscode.commands.executeCommand(command, ...args); + await vscode.commands.executeCommand(command, ...callArgs); } catch { // command failures surface in VSCode's own UI; nothing to relay back } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 1d6d1947d..1ccc418a6 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; import { ConnectionManager } from './connection-manager.js'; import { wireCommandProvider } from './command-relay.js'; +import { fireActivity, setActivityHooks } from './activity-hooks.js'; +import { loadActivityHooks } from './load-activity-hooks.js'; import { TerminalManager } from './terminal-manager.js'; import { OverviewCache } from './views/overview-data.js'; import { spawnBuilder } from './commands/spawn.js'; @@ -191,8 +193,17 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand( 'setContext', 'codev.terminalFocused', terminalManager?.isCodevTerminalActive() ?? false); + // Publish a builder-active event (for configured activity hooks) when a builder + // terminal is focused. Same subscription as the focus context. + const announceActiveBuilderFromTerminal = (): void => { + const id = terminalManager?.getActiveBuilderId(); + if (id) { fireActivity(connectionManager?.getWorkspacePath() ?? null, 'builder-active', { builder: id }); } + }; context.subscriptions.push( - vscode.window.onDidChangeActiveTerminal(syncTerminalFocusContext)); + vscode.window.onDidChangeActiveTerminal(() => { + syncTerminalFocusContext(); + announceActiveBuilderFromTerminal(); + })); syncTerminalFocusContext(); // seed initial state // Opt-in: force a terminal repaint when the VSCode window regains focus @@ -214,9 +225,15 @@ export async function activate(context: vscode.ExtensionContext) { if (enabled) { terminalManager?.repaintAllOnRefocus(); } + // Publish a window-focus activity event so configured hooks can + // follow the focused window. + fireActivity(connectionManager?.getWorkspacePath() ?? null, 'window-focus'); } windowFocused = state.focused; })); + // NOTE: the activation-time `window-focus` publish is deferred until after the + // initial hooks load (see `seedActivityHooks` below) — firing here would race + // the async hook fetch and publish into an empty cache. // Drive the `codev.hasDevCommand` context key so the builder-row Run/Stop // Dev Server menu entries, the dev keybindings, and the workspace-dev palette @@ -225,7 +242,7 @@ export async function activate(context: vscode.ExtensionContext) { // regardless of whether the Builders tree has rendered), so the key is // refreshed by global signals — mirroring the Workspace view's own gate: // `onStateChange` for the initial value once Tower is reachable, plus the - // `worktree-config-updated` SSE envelope (fired by Tower's config-file + // `codev-config-updated` SSE envelope (fired by Tower's config-file // watcher) so the key stays live on `.codev/config(.local).json` edits // without a window reload. The config is the Tower-merged 5-layer view // (shared + project-local). Fail-safe: a disconnected/error state resolves @@ -235,19 +252,39 @@ export async function activate(context: vscode.ExtensionContext) { await vscode.commands.executeCommand( 'setContext', 'codev.hasDevCommand', hasRunnableDevCommand(config)); }; + // Cache this workspace's resolved activity hooks (Tower's 5-layer merge, incl. + // ~/.codev and .codev/config.local.json). Refreshed by the same signals as the + // dev-command context: Tower reachability + the config-file-change SSE — so + // edits to .codev/config(.local).json take effect without a window reload. + const syncActivityHooks = async () => { + const resolved = await loadActivityHooks(connectionManager!); + setActivityHooks(resolved?.hooks ?? []); + }; + // Initial seed: load the hooks FIRST, then publish the activation `window-focus` + // if this window is focused — so a reload syncs a listener without a focus bounce + // (the cache is populated before the event fires, unlike a bare activation-time + // publish which would race the async fetch). + const seedActivityHooks = async () => { + await syncActivityHooks(); + if (vscode.window.state.focused) { + fireActivity(connectionManager?.getWorkspacePath() ?? null, 'window-focus'); + } + }; context.subscriptions.push( - connectionManager.onStateChange(() => { syncHasDevCommandContext(); })); + connectionManager.onStateChange(() => { syncHasDevCommandContext(); syncActivityHooks(); })); context.subscriptions.push( connectionManager.onSSEEvent(({ data }) => { try { - if ((JSON.parse(data) as { type?: unknown }).type === 'worktree-config-updated') { + if ((JSON.parse(data) as { type?: unknown }).type === 'codev-config-updated') { syncHasDevCommandContext(); + syncActivityHooks(); } } catch { // benign — malformed envelope } })); syncHasDevCommandContext(); // seed initial state + seedActivityHooks(); // Update status bar with builder + needs-attention counts. // Two "needs me" signals: blocked (formal gate) and idle-waiting @@ -379,6 +416,13 @@ export async function activate(context: vscode.ExtensionContext) { // count; the rest stay on registerTreeDataProvider. const buildersProvider = new BuildersProvider(overviewCache, builderDiffCache); buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: buildersProvider }); + // Publish a builder-active event when a builder row is selected in the sidebar. + // Builder tree items carry `builderId` (= OverviewBuilder.id); selecting a + // builder's root node or a file row re-targets any configured hook. + context.subscriptions.push(buildersView.onDidChangeSelection((e) => { + const sel = e.selection[0] as { builderId?: string } | undefined; + if (sel?.builderId) { fireActivity(connectionManager?.getWorkspacePath() ?? null, 'builder-active', { builder: sel.builderId }); } + })); pullRequestsView = vscode.window.createTreeView('codev.pullRequests', { treeDataProvider: new PullRequestsProvider(overviewCache) }); const backlogProvider = new BacklogProvider(overviewCache, context.workspaceState); backlogView = vscode.window.createTreeView('codev.backlog', { treeDataProvider: backlogProvider }); @@ -537,9 +581,22 @@ export async function activate(context: vscode.ExtensionContext) { // Benign if the row is no longer present (e.g. mid-cleanup). } }; + // Publish a builder-active event when a builder diff is under review, so a + // configured hook can follow it. The diff session entry already carries the + // builder id (`OverviewBuilder.id`) — fire it directly. Same diff-only gate as + // the reveal above (registry entry present + not a standalone tab), so a plain + // source file or the diff's base side doesn't re-target the hook. + const announceActiveBuilderFromEditor = (): void => { + const fsPath = vscode.window.activeTextEditor?.document.uri.fsPath; + if (!fsPath) { return; } + const entry = getDiffInjectEntry(fsPath); + if (!entry) { return; } + if (isStandaloneTextTab(vscode.window.tabGroups.activeTabGroup?.activeTab?.input)) { return; } + fireActivity(connectionManager?.getWorkspacePath() ?? null, 'builder-active', { builder: entry.builderId }); + }; context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor(() => { revealActiveBuilderFile(); }), - onDidChangeDiffInjectRegistry(() => { revealActiveBuilderFile(); }), + vscode.window.onDidChangeActiveTextEditor(() => { revealActiveBuilderFile(); announceActiveBuilderFromEditor(); }), + onDidChangeDiffInjectRegistry(() => { revealActiveBuilderFile(); announceActiveBuilderFromEditor(); }), ); // Builders file-view-as-tree: each builder's changed-files list renders @@ -1080,6 +1137,21 @@ export async function activate(context: vscode.ExtensionContext) { reg('codev.viewReviewFile', (arg: vscode.TreeItem | string | undefined) => viewReviewFile(connectionManager!, extractBuilderId(arg))), reg('codev.refreshOverview', () => overviewCache.refresh()), + // Focus the editor window for a workspace path (driven by a controller via the + // command relay). `vscode.openFolder` with forceNewWindow reuses and focuses an + // existing window for that folder rather than replacing this one; it opens a new + // window only if the folder isn't already open. + reg('codev.focusWorkspaceWindow', async (arg: unknown) => { + if (typeof arg !== 'string' || !arg) { return; } + // Only ever focus a Tower-KNOWN Codev workspace, never an arbitrary + // controller-supplied path (which would otherwise open any folder in a new + // window). The path originates from Tower's /api/workspaces and is echoed + // back by the controller, so an exact match against the same list is the guard. + const known = (await connectionManager?.getClient()?.listWorkspaces()) ?? []; + if (!known.some((w) => w.path === arg)) { return; } + await vscode.commands.executeCommand( + 'vscode.openFolder', vscode.Uri.file(arg), { forceNewWindow: true }); + }), reg('codev.enableBuildersAutoCollapse', () => vscode.workspace.getConfiguration('codev').update('buildersAutoCollapse', true, vscode.ConfigurationTarget.Global)), reg('codev.disableBuildersAutoCollapse', () => diff --git a/packages/vscode/src/load-activity-hooks.ts b/packages/vscode/src/load-activity-hooks.ts new file mode 100644 index 000000000..cb7835782 --- /dev/null +++ b/packages/vscode/src/load-activity-hooks.ts @@ -0,0 +1,26 @@ +/** + * Thin client-side wrapper over Tower's `GET /api/activity-hooks`. + * + * Returns the canonical `ResolvedActivityHooks` for the active workspace, so the + * extension never parses or merges config files itself. SECURITY: Tower resolves + * hooks from the PERSONAL config layers only (`~/.codev/config.json` + + * `.codev/config.local.json`) — never the committed `.codev/config.json` — because + * hooks open URLs and a committed hook would be a zero-click RCE. The resolved hooks + * are a workspace-wide concern fetched once and cached in `activity-hooks.ts`. + */ + +import type { ResolvedActivityHooks } from '@cluesmith/codev-types'; +import type { ConnectionManager } from './connection-manager.js'; + +/** + * Returns `null` when Tower is unreachable or the workspace isn't activated, so + * callers degrade to "no hooks". + */ +export async function loadActivityHooks( + connectionManager: ConnectionManager, +): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { return null; } + return client.getActivityHooks(workspacePath); +} diff --git a/packages/vscode/src/terminal-manager.ts b/packages/vscode/src/terminal-manager.ts index 4d8ff53a3..15b7fb9d8 100644 --- a/packages/vscode/src/terminal-manager.ts +++ b/packages/vscode/src/terminal-manager.ts @@ -435,6 +435,24 @@ export class TerminalManager { return this.getActiveManagedPty() !== null; } + /** + * The builder id of the currently-focused VSCode terminal, or null when the + * active terminal isn't a Codev *builder* terminal. Recovered from the map key + * (`builder-`), the same id the open path was given. Used to publish a + * `builder-active` activity event when a builder terminal is focused. + */ + getActiveBuilderId(): string | null { + const active = vscode.window.activeTerminal; + if (!active) { return null; } + const prefix = 'builder-'; + for (const [mapKey, entry] of this.terminals) { + if (entry.terminal === active && entry.type === 'builder' && mapKey.startsWith(prefix)) { + return mapKey.slice(prefix.length); + } + } + return null; + } + // ── Internal ───────────────────────────────────────────────── private async openTerminal( diff --git a/packages/vscode/src/views/workspace.ts b/packages/vscode/src/views/workspace.ts index 4bcd8ccab..9b37f6636 100644 --- a/packages/vscode/src/views/workspace.ts +++ b/packages/vscode/src/views/workspace.ts @@ -25,9 +25,9 @@ export class WorkspaceProvider implements vscode.TreeDataProvider this.changeEmitter.fire()); - // Tower fans out a `worktree-config-updated` SSE event whenever + // Tower fans out a `codev-config-updated` SSE event whenever // .codev/config(.local).json changes (server-side file watcher in - // worktree-config-watcher.ts), and a `architects-updated` event + // codev-config-watcher.ts), and a `architects-updated` event // whenever an architect is added or removed (Spec 823 — closes the // gap where the Architects tree went stale when add/remove happened // via CLI outside VSCode). We re-render on either signal. @@ -40,11 +40,11 @@ export class WorkspaceProvider implements vscode.TreeDataProvider { try { const envelope = JSON.parse(data) as { type?: unknown }; - if (envelope.type === 'worktree-config-updated' || envelope.type === 'architects-updated') { + if (envelope.type === 'codev-config-updated' || envelope.type === 'architects-updated') { this.changeEmitter.fire(); } } catch {