diff --git a/.github/workflows/dashboard-e2e.yml b/.github/workflows/dashboard-e2e.yml index 4a9d1375..171380f6 100644 --- a/.github/workflows/dashboard-e2e.yml +++ b/.github/workflows/dashboard-e2e.yml @@ -25,6 +25,10 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build core package + working-directory: packages/core + run: pnpm build + - name: Build package working-directory: packages/codev run: pnpm build diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c164b3c9..4781cb63 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,6 +34,10 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build core package + working-directory: packages/core + run: pnpm build + - name: Build package working-directory: packages/codev run: pnpm build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01e615d0..d3c8de0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,10 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build core package + working-directory: packages/core + run: pnpm build + - name: Copy skeleton for unit tests working-directory: packages/codev run: pnpm copy-skeleton @@ -69,6 +73,10 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build core package + working-directory: packages/core + run: pnpm build + - name: Build package working-directory: packages/codev run: pnpm build @@ -96,6 +104,10 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build core package + working-directory: packages/core + run: pnpm build + - name: Build package working-directory: packages/codev run: pnpm build @@ -123,15 +135,23 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build core package + working-directory: packages/core + run: pnpm build + - name: Build package working-directory: packages/codev run: pnpm build + - name: Pack core tarball + working-directory: packages/core + run: pnpm pack + - name: Pack tarball working-directory: packages/codev run: pnpm pack - name: Verify install from tarball working-directory: packages/codev - run: node scripts/verify-install.mjs cluesmith-codev-*.tgz + run: node scripts/verify-install.mjs cluesmith-codev-*.tgz ../core/cluesmith-codev-core-*.tgz diff --git a/.gitignore b/.gitignore index c0c3ccf2..330bd322 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,10 @@ node_modules/ packages/codev/dist/ packages/codev/skeleton/ packages/types/dist/ +packages/core/dist/ packages/vscode/dist/ packages/vscode/out/ +*.vsix *.tsbuildinfo test-results/ diff --git a/CLAUDE.md b/CLAUDE.md index f01bc42f..7e1f03fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,23 +36,28 @@ To release a new version, tell the AI: `Let's release v1.6.0`. The AI follows th To test changes locally before publishing to npm: ```bash -# From packages/codev directory: -cd packages/codev +# From the repository root: -# 1. Build and create tarball (Tower stays up during this) +# 1. Build (Tower stays up during this) pnpm build -pnpm pack -# 2. Install (Tower stays up — running process already loaded old code) -npm install -g ./cluesmith-codev-*.tgz +# 2. Pack both tarballs +pnpm --filter @cluesmith/codev-core pack +pnpm --filter @cluesmith/codev pack + +# 3. Install globally (Tower stays up) +pnpm local-install # 3. Restart (only this step needs downtime) afx tower stop && afx tower start ``` +- `pnpm build` builds core first, then codev (including dashboard) +- `pnpm --filter pack` creates tarballs (run for core and codev separately) +- `pnpm local-install` installs both tarballs in a single `npm install -g` command — separate installs fail because `@cluesmith/codev-core` isn't on the public npm registry - Install while Tower is running — it doesn't affect the running process - Do NOT stop Tower before installing — unnecessary downtime -- Do NOT delete the tarball — keep it for debugging if restart fails +- Do NOT delete the tarballs — keep them for debugging if restart fails - Do NOT build between stop and start - Do NOT use `npm link` or `pnpm link` — it breaks global installs diff --git a/codev/plans/0602-vscode-extension.md b/codev/plans/0602-vscode-extension.md index 64905b7c..9ee66e2b 100644 --- a/codev/plans/0602-vscode-extension.md +++ b/codev/plans/0602-vscode-extension.md @@ -35,7 +35,8 @@ The shared types package (`@cluesmith/codev-types`) is extracted in Phase 1 as a ```json { "phases": [ - {"id": "shared_types", "title": "Phase 1: Shared Types Package"}, + {"id": "shared_types", "title": "Phase 1a: Shared Types Package (done)"}, + {"id": "shared_runtime", "title": "Phase 1b: Shared Runtime Package"}, {"id": "connection_core", "title": "Phase 2a: Connection Manager + Auth"}, {"id": "connection_reactive", "title": "Phase 2b: SSE + Tower Auto-Start"}, {"id": "terminal_integration", "title": "Phase 3: Terminal Integration"}, @@ -88,16 +89,16 @@ The shared types package (`@cluesmith/codev-types`) is extracted in Phase 1 as a **Key decision:** Types package uses `"type": "module"` to match the codev package. The extension's `"moduleResolution": "bundler"` handles ESM imports. #### Acceptance Criteria -- [ ] `npm install` from root resolves all three workspace members -- [ ] `npm run build` in `packages/codev` passes with shared type imports -- [ ] `npm run check-types` in `packages/codev-vscode` passes with shared type imports +- [ ] `pnpm install` from root resolves all workspace members +- [ ] `pnpm build` in `packages/codev` passes with shared type imports +- [ ] `pnpm check-types` in `packages/vscode` passes with shared type imports - [ ] `vsce package` produces a valid `.vsix` (workspace symlinks correctly resolved by esbuild at bundle time) - [ ] Existing 2422 unit tests still pass #### Test Plan - **Unit Tests**: Type exports compile correctly - **Integration Tests**: Server build + extension type-check both pass -- **Manual Testing**: `npm install` from root, verify symlinks +- **Manual Testing**: `pnpm install` from root, verify symlinks #### Rollback Strategy Revert the extraction — types go back to local definitions. No runtime behavior change. @@ -108,55 +109,128 @@ Revert the extraction — types go back to local definitions. No runtime behavio --- +### Phase 1b: Shared Runtime Package +**Dependencies**: Phase 1a + +#### Objectives +- Extract shared runtime utilities from `packages/codev/src/agent-farm/lib/tower-client.ts` into `packages/core/` +- Extract `EscapeBuffer` from `packages/dashboard/src/lib/escapeBuffer.ts` +- Both codev server and VS Code extension import from `@cluesmith/codev-core` — no duplication +- Publish `@cluesmith/codev-core` to npm as part of the release process + +#### Deliverables +- [ ] `packages/core/package.json` with `"name": "@cluesmith/codev-core"` +- [ ] `packages/core/tsconfig.json` extending `../config/tsconfig.base.json` +- [ ] `packages/core/src/auth.ts` — `readLocalKey()` (read-only, returns `string | null`) + `ensureLocalKey()` (creates dir + generates, CLI-only) extracted from `tower-client.ts` +- [ ] `packages/core/src/workspace.ts` — `encodeWorkspacePath()`, `decodeWorkspacePath()` extracted from `tower-client.ts` +- [ ] `packages/core/src/tower-client.ts` — `TowerClient` class refactored with injectable auth (`getAuthKey` option), all Tower API types +- [ ] `packages/core/src/escape-buffer.ts` — `EscapeBuffer` class extracted from dashboard +- [ ] `packages/core/src/constants.ts` — `DEFAULT_TOWER_PORT` and other shared constants +- [ ] Subpath exports in `package.json` (not single barrel — prevents Node builtins leaking into dashboard Vite build) +- [ ] `packages/codev/src/agent-farm/lib/tower-client.ts` — replaced with re-exports from `@cluesmith/codev-core` +- [ ] `packages/codev/package.json` — add `@cluesmith/codev-core` as dependency (exact version, runtime, must be published) +- [ ] `packages/vscode/package.json` — add `@cluesmith/codev-core` as dependency (esbuild bundles it, no publish needed for extension) +- [ ] `packages/dashboard/src/lib/escapeBuffer.ts` — replaced with import from `@cluesmith/codev-core/escape-buffer` +- [ ] Update release protocol with two-package publish order (shared first, then codev) + +#### Implementation Details + +**Files to create:** +- `packages/core/package.json` — `"type": "module"`, subpath exports per module (tower-client, auth, workspace, constants, escape-buffer) +- `packages/core/tsconfig.json` — extends base config +- `packages/core/src/auth.ts` — `readLocalKey()` (read-only) + `ensureLocalKey()` (CLI-only) +- `packages/core/src/workspace.ts` — extract `encodeWorkspacePath()` / `decodeWorkspacePath()` +- `packages/core/src/tower-client.ts` — `TowerClient` with injectable auth (`{ getAuthKey?: () => string | null }`) +- `packages/core/src/escape-buffer.ts` — extract `EscapeBuffer` from dashboard +- `packages/core/src/constants.ts` — `DEFAULT_TOWER_PORT = 4100` +- (no barrel index.ts — subpath exports only) + +**Files to modify:** +- `packages/codev/src/agent-farm/lib/tower-client.ts` — replace with re-exports from `@cluesmith/codev-core` +- `packages/codev/package.json` — add `@cluesmith/codev-core` to dependencies +- `packages/dashboard/src/lib/escapeBuffer.ts` — replace with re-export from `@cluesmith/codev-core` +- `packages/dashboard/package.json` — add `@cluesmith/codev-core` to dependencies +- `.gitignore` — add `packages/core/dist/` + +**Key decision:** `@cluesmith/codev-core` is a runtime dependency of `@cluesmith/codev` (the server runs under Node.js, not bundled). It must be published to npm alongside `@cluesmith/codev` during releases. The VS Code extension uses esbuild which bundles it at build time — no npm publish needed for the extension. + +**TowerClient extraction:** The `TowerClient` class is the core API client used by all `afx` CLI commands. Moving it to the shared package means the extension gets the full Tower API client for free — no need to reimplement REST calls, auth, health checks, or workspace operations. + +#### Acceptance Criteria +- [ ] `pnpm install` from root resolves all workspace members including shared +- [ ] `packages/codev/src/agent-farm/lib/tower-client.ts` is a thin re-export file +- [ ] All existing consumers of `TowerClient` work without changes (re-exports preserve the API) +- [ ] Extension can import `TowerClient` from `@cluesmith/codev-core` +- [ ] `pnpm build` in `packages/codev` passes +- [ ] All 2422+ unit tests pass +- [ ] `pnpm pack` + `npm install -g` succeeds (codev-core must be published first, or use devDependency pattern for local testing) + +#### Test Plan +- **Unit Tests**: Existing tower-client tests continue to pass via re-exports +- **Integration Tests**: `afx` commands work after extraction +- **Manual Testing**: `pnpm install` from root, verify workspace symlinks, build all packages + +#### Rollback Strategy +Revert extraction — move code back to `tower-client.ts`. No runtime behavior change. + +#### Risks +- **Risk**: `TowerClient` has hidden dependencies on Node.js APIs not available in extension + - **Mitigation**: `TowerClient` uses `fetch` (available everywhere) and `fs` (only for `getLocalKey`). Extension wraps `getLocalKey` with `SecretStorage` at the consumer level. +- **Risk**: Publishing `@cluesmith/codev-core` adds release complexity + - **Mitigation**: Publish both packages in same release step. Version them together. + +--- + ### Phase 2a: Connection Manager + Auth -**Dependencies**: Phase 1 +**Dependencies**: Phase 1b #### Objectives -- Implement the singleton Connection Manager with state machine -- REST client with auth and proxy support -- Register Output Channel for diagnostics -- Workspace path detection and config reading +- Implement the singleton Connection Manager wrapping `TowerClient` from `@cluesmith/codev-core` +- Add VS Code-specific layers: state machine, Output Channel, SecretStorage auth wrapper, settings, activation +- No duplication of REST/auth/encoding logic — reuse `TowerClient` directly #### Deliverables -- [ ] `src/connection-manager.ts` — singleton with state machine (DISCONNECTED → CONNECTING → CONNECTED → RECONNECTING) -- [ ] REST client with authenticated fetch (`codev-web-key` header) -- [ ] HTTP proxy support via `vscode.workspace.getConfiguration('http').get('proxy')` with `https-proxy-agent` -- [ ] Health check against `/api/health` with protocol version compatibility check -- [ ] Workspace path detection (traverse up to `.codev/config.json` root) -- [ ] Base64url encoding for workspace-scoped routes +- [ ] `src/connection-manager.ts` — singleton wrapping `TowerClient` from `@cluesmith/codev-core`, adds state machine (DISCONNECTED → CONNECTING → CONNECTED → RECONNECTING) and VS Code integration +- [ ] `src/auth-wrapper.ts` — wraps `getLocalKey()` from shared with VS Code `SecretStorage` caching + 401 re-read +- [ ] `src/workspace-detector.ts` — uses `findProjectRoot()` pattern to traverse up from `vscode.workspace.workspaceFolders[0]` to `.codev/config.json`, reads Tower port from config - [ ] `Codev` Output Channel for diagnostic logging (redacts auth tokens) -- [ ] Auth via `SecretStorage` with re-read from disk on 401 -- [ ] Extension settings registration: `codev.towerHost`, `codev.towerPort`, `codev.workspacePath`, `codev.terminalPosition`, `codev.autoConnect`, `codev.autoStartTower`, `codev.telemetry` -- [ ] Activation events: `onCommand:codev.*` (implicit via command registration) and `workspaceContains:.codev/config.json` +- [ ] Extension settings registration: all 7 settings +- [ ] Activation events: `workspaceContains:.codev` + `workspaceContains:codev` + implicit `onCommand:` - [ ] Proper `deactivate()` — close all connections, dispose resources -- [ ] Add `ws` and `https-proxy-agent` as runtime dependencies in `package.json` -- [ ] Update `esbuild.js` to mark `bufferutil` and `utf-8-validate` as external +- [ ] Add `ws` as runtime dependency, update `esbuild.js` externals - [ ] `src/extension.ts` updated to initialize Connection Manager on activation +- [ ] Workspace auto-activation: after connecting to Tower, call `client.activateWorkspace(workspacePath)` to ensure architect terminal is created +- [ ] Status bar showing connection state #### Implementation Details +**Key principle:** The extension does NOT reimplement Tower API calls. It imports `TowerClient` from `@cluesmith/codev-core` and wraps it with VS Code-specific concerns (state machine, Output Channel logging, SecretStorage, settings). + **Files to create:** -- `src/connection-manager.ts` — core class with state machine, REST client -- `src/auth.ts` — local-key reading, SecretStorage caching, 401 re-read -- `src/config.ts` — `.codev/config.json` reader, workspace path detection +- `src/connection-manager.ts` — owns a `TowerClient` instance, adds state machine + reconnection + VS Code event emitters +- `src/auth-wrapper.ts` — thin wrapper: calls `getLocalKey()` from shared, caches in `SecretStorage`, re-reads on 401 +- `src/workspace-detector.ts` — traverses up from workspace folder to find `.codev/config.json`, reads port **Files to modify:** - `src/extension.ts` — activate initializes Connection Manager, deactivate cleans up -- `package.json` — add `contributes.configuration` for all 7 settings, add activation events, add `https-proxy-agent` dependency - -**Note on `ws` dependency**: The `ws` package has optional native bindings (`bufferutil`, `utf-8-validate`) that don't bundle with esbuild. Mark these as external in `esbuild.js` or use the pure-JS fallback (ws works without them, just slower). Add to `esbuild.js`: -```javascript -external: ['vscode', 'bufferutil', 'utf-8-validate'], -``` - -**State machine:** -``` -DISCONNECTED → (health check) → CONNECTING → (connected) → CONNECTED -CONNECTED → (connection drops) → RECONNECTING → (backoff retry) → CONNECTING -RECONNECTING → (max retries) → DISCONNECTED -``` - -**Workspace detection:** Walk up from `vscode.workspace.workspaceFolders[0]` looking for `.codev/config.json`. Read Tower port from config. Match against `GET /api/workspaces`. +- `package.json` — add settings, activation events, `ws` + `@cluesmith/codev-core` dependencies +- `esbuild.js` — add `bufferutil`, `utf-8-validate` to externals + +**What the extension adds on top of `TowerClient`:** +- State machine with VS Code event emitter (`onStateChange`) +- Status bar item reflecting connection state +- Output Channel logging (connection events, errors, redacted auth) +- `SecretStorage` caching for the auth key +- Settings-based host/port configuration +- Workspace auto-detection from VS Code workspace folders + +**What the extension does NOT implement (reused from shared):** +- REST client with auth headers → `TowerClient.request()` +- Health check → `TowerClient.isRunning()`, `TowerClient.getHealth()` +- Workspace encoding → `encodeWorkspacePath()` +- Terminal operations → `TowerClient.createTerminal()`, etc. +- Send message → `TowerClient.sendMessage()` +- Tunnel control → `TowerClient.signalTunnel()` **Activation:** Register `activationEvents` in `package.json`: ```json @@ -257,7 +331,7 @@ Remove SSE client and Tower starter. REST connection still works for manual refr #### Deliverables - [ ] `src/terminal-adapter.ts` — Pseudoterminal implementation with WebSocket binary protocol -- [ ] `src/escape-buffer.ts` — port of `dashboard/src/lib/escapeBuffer.ts` +- [ ] Import `EscapeBuffer` from `@cluesmith/codev-core/escape-buffer` (extracted in Phase 1b) - [ ] Binary protocol adapter: inbound `0x01` → `TextDecoder({ stream: true })` → `onDidWrite`, outbound → `0x01` prefix → `ws.send()` - [ ] Control frame handling (`0x00`): resize, ping/pong, sequence numbers - [ ] Reconnection with inline ANSI banner and ring buffer replay @@ -266,8 +340,8 @@ Remove SSE client and Tower starter. REST connection still works for manual refr - [ ] Editor layout: architect in left group, builders as tabs in right group - [ ] Terminal naming: `Codev: Architect`, `Codev: #42 password-hashing [implement]`, `Codev: Shell #1` - [ ] WebSocket auth via `0x00` control message after connection (not query param) -- [ ] Respect `codev.terminalPosition` setting — only attempt `moveIntoEditor` when set to `"editor"` -- [ ] Fallback to bottom panel if `moveIntoEditor` fails +- [ ] Use `TerminalLocation.Editor` + `ViewColumn` for editor placement (stable API, no `moveIntoEditor`) +- [ ] Respect `codev.terminalPosition` setting — `"editor"` uses ViewColumn, `"panel"` uses TerminalLocation.Panel - [ ] WebSocket pool management (max 10 concurrent) - [ ] Image paste: intercept clipboard paste in terminal, upload via `POST /api/paste-image` (note: VS Code Pseudoterminal only delivers text input — investigate clipboard API feasibility, defer if not possible) @@ -275,7 +349,7 @@ Remove SSE client and Tower starter. REST connection still works for manual refr **Files to create:** - `src/terminal-adapter.ts` — `CodevPseudoterminal` class implementing `vscode.Pseudoterminal` -- `src/escape-buffer.ts` — ANSI escape sequence buffering +- (EscapeBuffer imported from `@cluesmith/codev-core/escape-buffer`) - `src/terminal-manager.ts` — WebSocket pool, terminal lifecycle, editor layout **Files to modify:** @@ -306,12 +380,12 @@ handleInput(data: string): void { } ``` -**Editor layout sequence:** -1. Create terminal with `createTerminal({ name, pty })` -2. `vscode.commands.executeCommand('workbench.action.terminal.moveIntoEditor')` -3. For architect: move to first editor group -4. For builders: move to second editor group (split if needed) -5. Catch errors → fallback to bottom panel +**Editor layout:** +Create terminals directly in editor area via `createTerminal({ name, pty, location })`: +- Architect: `{ viewColumn: ViewColumn.One }` +- Builders/shells: `{ viewColumn: ViewColumn.Two }` +- VS Code auto-creates the split when ViewColumn.Two is first used +- Panel fallback: `TerminalLocation.Panel` when setting is `"panel"` #### Acceptance Criteria - [ ] Architect terminal opens in left editor group @@ -331,8 +405,8 @@ handleInput(data: string): void { Remove terminal adapter, revert to scaffold. Connection Manager stays functional. #### Risks -- **Risk**: `moveIntoEditor` fails on some VS Code versions - - **Mitigation**: Try/catch with panel fallback, log warning to Output Channel +- **Risk**: Terminal editor placement not working as expected + - **Mitigation**: Uses stable `TerminalLocation.Editor` + `ViewColumn` API, `"panel"` setting as fallback - **Risk**: EscapeBuffer diverges from dashboard implementation - **Mitigation**: Port tests alongside code, verify against same fixtures @@ -644,15 +718,16 @@ Remove URI handler and link provider. `afx open` continues to work via Tower API ## Dependency Map ``` -Phase 1 (types) ──→ Phase 2a (connection) ──→ Phase 2b (SSE + auto-start) ──→ Phase 4 (sidebar) - ──→ Phase 3 (terminals) - Phase 2b + 3 + 4 ──→ Phase 5 (commands) - Phase 2a ──→ Phase 8 (analytics, post-V1) - Phase 3 ──→ Phase 9 (file links, post-V1) +Phase 1a (types, done) ──→ Phase 1b (shared runtime) ──→ Phase 2a (connection) + ──→ Phase 2b (SSE + auto-start) ──→ Phase 4 (sidebar) + ──→ Phase 3 (terminals) + Phase 2b + 3 + 4 ──→ Phase 5 (commands) + Phase 2a ──→ Phase 8 (analytics, post-V1) + Phase 3 ──→ Phase 9 (file links, post-V1) Phase 6 (review comments) ── NO DEPENDENCIES ── can run in parallel from Day 1 -All V1 phases (1-6) ──→ Phase 7 (V1 polish + packaging) +All V1 phases (1a, 1b, 2a, 2b, 3-6) ──→ Phase 7 (V1 polish + packaging) ``` Phases 3, 4, and 6 can run in parallel. Phase 6 (review comments) has zero dependencies and can start immediately. @@ -679,8 +754,8 @@ Phases 3, 4, and 6 can run in parallel. Phase 6 (review comments) has zero depen | Tower auto-start PATH issues | Medium | Low | Resolve full afx path, log errors | 2b | | SSE double-retry (native + custom backoff) | Medium | Medium | Disable native EventSource reconnection | 2b | | HTTP proxy not configured in enterprise | Medium | Medium | Integrate with VS Code proxy settings, `https-proxy-agent` | 2a | -| `moveIntoEditor` API instability | Medium | Medium | Check `terminalPosition` setting first, try/catch with panel fallback | 3 | -| EscapeBuffer divergence (third copy) | Low | High | Port dashboard tests alongside code, extract to shared package post-V1 | 3 | +| Terminal editor placement | Low | Low | Uses stable TerminalLocation.Editor + ViewColumn API | 3 | +| `@cluesmith/codev-core` must be published to npm | Medium | High | Publish alongside codev in same release step, version together | 1b | | Image paste infeasible via Pseudoterminal | High | Low | Investigate clipboard API, defer if not possible | 3 | | 7 TreeView providers cause excessive API calls | Low | Medium | Single cached overview call | 4 | | Phase 4 context menu actions depend on later phases | Medium | Low | Register as no-ops, wire up in Phase 5 | 4 | @@ -733,9 +808,9 @@ Phases 3, 4, and 6 can run in parallel. Phase 6 (review comments) has zero depen **Parallel execution**: Phase 6 (review comments) has zero dependencies and can start Day 1. Phases 3 and 4 can run in parallel after Phase 2b. Most Phase 5 commands only need Phase 2b (not 3 or 4). -**Monorepo prerequisite**: Already done — npm workspaces set up with root `package.json`, extension scaffold at `packages/codev-vscode/`, cross-package imports verified. +**Monorepo prerequisite**: Already done — pnpm workspaces set up with `pnpm-workspace.yaml`, extension scaffold at `packages/vscode/`, cross-package imports verified. -**Tech debt acknowledged**: Phase 3 creates a third copy of EscapeBuffer (server, dashboard, extension). This is pragmatic for V1 — extraction into `@cluesmith/codev-shared` happens post-V1 when patterns stabilize across two consumers. +**No duplication by design**: Phase 1b extracts `TowerClient`, auth, workspace encoding, and `EscapeBuffer` into `@cluesmith/codev-core` before any extension code is written. Phase 2a wraps the shared `TowerClient` with VS Code-specific concerns (state machine, SecretStorage, Output Channel) instead of reimplementing REST calls. **Consultation feedback incorporated**: Phase 2 split into 2a/2b per Claude recommendation. Review comments moved into V1 per Gemini/Codex. Image paste feasibility flagged per Codex. Cross-platform `afx open` per Codex. `ws` bundling risk per Claude. All missing settings/activation events added. diff --git a/codev/resources/arch.md b/codev/resources/arch.md index c153e1ac..d776d4f5 100644 --- a/codev/resources/arch.md +++ b/codev/resources/arch.md @@ -13,6 +13,9 @@ Codev is a Human-Agent Software Development Operating System. This repository se **To understand a specific subsystem:** - **Agent Farm**: Start with the Architecture Overview diagram in this document, then `packages/codev/src/agent-farm/` +- **Shared Runtime**: `packages/core/` — TowerClient, auth, workspace encoding, EscapeBuffer +- **VS Code Extension**: `packages/vscode/` — thin client over Tower API +- **Dashboard**: `packages/dashboard/` — React SPA served by Tower - **Consult Tool**: See `packages/codev/src/commands/consult/` and `codev/roles/consultant.md` - **Protocols**: Read the relevant protocol in `codev/protocols/{spir,tick,maintain,experiment}/protocol.md` @@ -110,27 +113,30 @@ This section provides comprehensive documentation of how the Agent Farm (`afx`) ### Architecture Overview -Agent Farm orchestrates multiple AI agents working in parallel on a codebase. The architecture consists of: +Agent Farm orchestrates multiple AI agents working in parallel on a codebase. Two clients connect to the same Tower server: ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Dashboard (React SPA on Tower :4100) │ -│ HTTP server + WebSocket multiplexer │ -├─────────────────────────────────────────────────────────────────────┤ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │Architect │ │ Builder │ │ Builder │ │ Utils │ │ -│ │ Tab │ │ Tab 1 │ │ Tab 2 │ │ Tabs │ │ -│ │(xterm.js)│ │(xterm.js)│ │(xterm.js)│ │(xterm.js)│ │ -│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ -│ │ │ │ │ │ -│ └─────────────┴──────┬──────┴─────────────┘ │ -│ ▼ │ -│ ┌───────────────────┐ │ -│ │ Terminal Manager │ │ -│ │ (node-pty PTY │ │ -│ │ sessions) │ │ -│ └────────┬──────────┘ │ -└───────────────────────────┼─────────────────────────────────────────┘ +┌─────────────────────────────┐ ┌─────────────────────────────┐ +│ Browser Dashboard │ │ VS Code Extension │ +│ (React SPA on Tower :4100) │ │ (packages/vscode) │ +│ │ │ │ +│ xterm.js terminals │ │ Pseudoterminal ↔ WS │ +│ Work View (React) │ │ Sidebar TreeViews │ +│ SSE for updates │ │ SSE for updates │ +└──────────────┬──────────────┘ └──────────────┬──────────────┘ + │ HTTP + WebSocket + SSE │ + └────────────────┬────────────────┘ + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ Tower Server (:4100) │ +│ HTTP routes + WebSocket + SSE push │ +│ │ +│ ┌───────────────────┐ │ +│ │ Terminal Manager │ │ +│ │ (node-pty PTY │ │ +│ │ sessions) │ │ +│ └────────┬──────────┘ │ +└───────────────────────────┼───────────────────────────────────────┘ │ WebSocket /ws/terminal/ ┌─────────────┼─────────────┬─────────────┐ ▼ ▼ ▼ ▼ @@ -157,12 +163,13 @@ Agent Farm orchestrates multiple AI agents working in parallel on a codebase. Th 5. **Git Worktrees**: Isolated working directories for each Builder 6. **SQLite Databases**: State persistence (local and global) -**Data Flow**: -1. User opens dashboard at `http://localhost:4100` -2. React dashboard polls `/api/state` for current state (1-second interval). Response includes `persistent` boolean per terminal. -3. Each tab renders an xterm.js terminal connected via WebSocket to `/ws/terminal/` -4. Terminal creation uses `SessionManager.createSession()` for persistent shellper-backed sessions, or direct node-pty for non-persistent sessions -5. Shellper-backed PtySessions delegate write/resize/kill to the shellper's Unix socket via `IShellperClient` +**Data Flow** (both clients use the same Tower API): +1. User opens browser dashboard at `http://localhost:4100` or VS Code auto-connects on workspace open +2. Client subscribes to SSE at `/api/events` for real-time push notifications +3. Client fetches workspace state via `/api/overview` and `/workspace/:encoded/api/state` +4. Terminals connect via WebSocket to `/workspace/:encoded/ws/terminal/` (binary protocol: `0x00` control, `0x01` data) +5. Terminal creation uses `SessionManager.createSession()` for persistent shellper-backed sessions +6. Shellper-backed PtySessions delegate write/resize/kill to the shellper's Unix socket via `IShellperClient` 6. Builders work in isolated git worktrees under `.builders/` ### Port System @@ -841,8 +848,13 @@ const CONFIG = { - **tree-kill**: Process cleanup and termination - **Shellper processes**: Detached Node.js processes for terminal session persistence (Spec 0104) - **node-pty**: Native PTY sessions with WebSocket multiplexing (Spec 0085) -- **React 19 + Vite 6**: Dashboard SPA (replaced vanilla JS in Spec 0085) -- **xterm.js**: Terminal emulator in the browser (with `customGlyphs: true` for Unicode) +- **React 19 + Vite 6**: Dashboard SPA at `packages/dashboard/` (standalone workspace member) +- **xterm.js**: Terminal emulator in the browser dashboard (with `customGlyphs: true` for Unicode) + +### VS Code Extension +- **VS Code Extension API**: TreeViews, Pseudoterminal, StatusBar, Commands, Decorations +- **esbuild**: Bundles extension + codev-core into single `dist/extension.js` +- **ws**: WebSocket client for terminal binary protocol ### Testing Framework - **Vitest**: Unit and integration tests (`packages/codev/src/__tests__/`) @@ -863,6 +875,75 @@ const CONFIG = { - Native addon: node-pty (compiled during npm install, may need `npm rebuild node-pty`) - Runtime directory: `~/.codev/run/` for shellper Unix sockets (created automatically with `0700` permissions) +## Monorepo Structure + +The repository uses pnpm workspaces with the following packages: + +| Package | npm Name | Purpose | +|---------|----------|---------| +| `packages/codev` | `@cluesmith/codev` | CLI + Tower server (published to npm) | +| `packages/core` | `@cluesmith/codev-core` | Shared runtime: TowerClient, auth, workspace encoding, EscapeBuffer (published to npm) | +| `packages/types` | `@cluesmith/codev-types` | Shared TypeScript types: WebSocket protocol, API shapes, SSE events (dev dependency only) | +| `packages/config` | `@cluesmith/config` | Shared tsconfig base (cross-project) | +| `packages/dashboard` | `@cluesmith/codev-dashboard` | React dashboard SPA (built into codev package) | +| `packages/vscode` | `codev` (Marketplace) | VS Code extension | + +**Dependency graph:** +``` +codev-types (types only, dev dep) + ↓ +codev-core (runtime: TowerClient, auth, EscapeBuffer) + ↓ +codev (CLI + Tower) vscode (extension) dashboard (React SPA) + imports core imports core imports core/escape-buffer + imports types (dev) imports types (dev) imports types (dev) +``` + +**Build order:** `pnpm build` from root builds core → codev (including dashboard). + +**Publishing:** `codev-core` must be published to npm before `codev` (runtime dependency). + +## VS Code Extension + +The VS Code extension (`packages/vscode`) is a thin client over Tower's existing API. It adds VS Code-specific UI on top of `TowerClient` from `@cluesmith/codev-core` — no Tower logic is reimplemented. + +### Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ VS Code Extension │ +│ │ +│ ConnectionManager (singleton) │ +│ ├── TowerClient (from @cluesmith/codev-core) │ +│ ├── AuthWrapper (SecretStorage + readLocalKey) │ +│ ├── WorkspaceDetector (traverse up to .codev/ or codev/) │ +│ ├── SSEClient (real-time state updates) │ +│ └── TowerStarter (auto-start as detached daemon) │ +│ │ +│ UI Layer │ +│ ├── Sidebar: 7 TreeView sections (overview + team + status) │ +│ ├── Terminals: Pseudoterminal ↔ WebSocket binary protocol │ +│ ├── Status Bar: builder count + blocked gates │ +│ ├── Commands: spawn, send, approve, cleanup, tunnel, cron │ +│ └── Review: snippet + Decorations API highlighting │ +│ │ +│ esbuild → dist/extension.js (bundles codev-core inline) │ +└──────────────┬───────────────────────────────────────────────┘ + │ HTTP + WebSocket + SSE (localhost:4100) +┌──────────────▼───────────────────────────────────────────────┐ +│ Tower Server │ +│ (unchanged — same API as browser dashboard) │ +└──────────────────────────────────────────────────────────────┘ +``` + +### Key Design Decisions + +- **Thin client**: All state stays in Tower/shellper. Extension is a viewport, not a second orchestrator. +- **TowerClient reuse**: Extension imports `TowerClient` from `codev-core` — same class the CLI uses. No duplicate REST/auth/encoding logic. +- **TerminalLocation.Editor**: Terminals open directly in editor area via `ViewColumn.One` (architect) and `ViewColumn.Two` (builders). Uses stable VS Code API, not the undocumented `moveIntoEditor` command. +- **Subpath exports**: `codev-core` uses subpath exports (`./tower-client`, `./escape-buffer`, etc.) to prevent Node builtins from leaking into the dashboard's Vite build. +- **Injectable auth**: `TowerClient` accepts a `getAuthKey` callback. CLI uses `ensureLocalKey()` (creates key if missing). Extension uses `readLocalKey()` + `SecretStorage` (never creates keys). + ## Repository Dual Nature This repository has a unique dual structure: @@ -919,7 +1000,36 @@ This is the `@cluesmith/codev` npm package containing all CLI tools: ## Complete Directory Structure ``` -codev/ # Project root (git repository) +codev/ # Project root (pnpm monorepo) +├── packages/core/ # @cluesmith/codev-core (shared runtime) +│ └── src/ +│ ├── tower-client.ts # TowerClient class (injectable auth) +│ ├── auth.ts # readLocalKey() + ensureLocalKey() +│ ├── workspace.ts # encodeWorkspacePath() / decodeWorkspacePath() +│ ├── constants.ts # DEFAULT_TOWER_PORT, AGENT_FARM_DIR +│ └── escape-buffer.ts # EscapeBuffer (ANSI sequence buffering) +├── packages/types/ # @cluesmith/codev-types (shared interfaces) +│ └── src/ +│ ├── websocket.ts # FRAME_CONTROL, FRAME_DATA, ControlMessage +│ ├── sse.ts # SSEEventType, SSENotification +│ └── api.ts # DashboardState, OverviewData, TeamApiResponse, etc. +├── packages/config/ # @cluesmith/config (shared tsconfig) +│ └── tsconfig.base.json +├── packages/dashboard/ # @cluesmith/codev-dashboard (React SPA) +│ └── src/ # React 19 + Vite 6 + xterm.js + Recharts +├── packages/vscode/ # VS Code extension (Marketplace: cluesmith.codev) +│ └── src/ +│ ├── extension.ts # Activation, command/view registration +│ ├── connection-manager.ts # Singleton wrapping TowerClient +│ ├── auth-wrapper.ts # SecretStorage + readLocalKey() +│ ├── workspace-detector.ts # Traverse to .codev/ or codev/ +│ ├── sse-client.ts # SSE with heartbeat filtering +│ ├── tower-starter.ts # Auto-start Tower as detached process +│ ├── terminal-adapter.ts # Pseudoterminal ↔ WebSocket binary protocol +│ ├── terminal-manager.ts # WebSocket pool, editor layout +│ ├── review-decorations.ts # REVIEW(...) line highlighting +│ ├── commands/ # spawn, send, approve, cleanup, tunnel, cron, review +│ └── views/ # TreeView providers (7 sidebar sections) ├── packages/codev/ # @cluesmith/codev npm package │ ├── src/ # TypeScript source code │ │ ├── cli.ts # Main CLI entry point @@ -964,13 +1074,15 @@ codev/ # Project root (git repository) │ │ │ │ └── migrate.ts # JSON → SQLite migration │ │ │ └── __tests__/ # Vitest unit tests │ │ └── lib/ # Shared library code +│ │ ├── tower-client.ts # Re-exports from @cluesmith/codev-core │ │ └── templates.ts # Template file handling │ ├── bin/ # CLI entry points │ │ ├── codev.js # codev command -│ │ ├── af.js # afx command +│ │ ├── afx.js # afx command (af.js deprecated, redirects) │ │ ├── consult.js # consult command │ │ ├── team.js # team command │ │ └── porch.js # porch command +│ ├── dashboard-dist/ # Dashboard build output (copied from packages/dashboard/dist) │ ├── skeleton/ # Embedded codev-skeleton (built) │ ├── templates/ # HTML templates │ │ ├── tower.html # Multi-project overview diff --git a/codev/reviews/0602-vscode-extension.md b/codev/reviews/0602-vscode-extension.md new file mode 100644 index 00000000..8dc92bae --- /dev/null +++ b/codev/reviews/0602-vscode-extension.md @@ -0,0 +1,81 @@ +# Review: VS Code Extension for Codev Agent Farm + +## Summary + +Built a VS Code extension that integrates Codev's Agent Farm into the IDE as a thin client over Tower's API. 9 implementation phases (1a, 1b, 2a, 2b, 3, 4, 5, 6, 7), plus monorepo restructuring. The extension provides native terminals, a unified sidebar, command palette integration, and review comment tooling. + +## Spec Compliance + +- [x] Architect terminal opens in left editor group (Phase 3) +- [x] Builder terminals open in right editor group as tabs (Phase 3) +- [x] Unified sidebar with Needs Attention, Builders, PRs, Backlog, Recently Closed, Team, Status (Phase 4) +- [x] Status bar shows builder count and blocked gate count (Phase 5) +- [x] `afx spawn`, `afx send`, `afx cleanup`, `porch approve` via Command Palette (Phase 5) +- [x] Review comments via snippet + Decorations API (Phase 6) +- [x] Shell terminals via Command Palette (Phase 3) +- [x] Needs Attention section shows blocked builders (Phase 4) +- [x] Cloud tunnel connect/disconnect commands (Phase 5) +- [x] Team section conditional on teamEnabled (Phase 4) +- [x] Cron task management via Command Palette (Phase 5) +- [x] Tower auto-starts on activation (Phase 2b) +- [x] Extension detects Tower offline and shows degraded state (Phase 2a) +- [x] Terminal sessions survive VS Code reload via shellper (Phase 3) +- [x] Extension activates in < 500ms (Phase 7 — verified) +- [x] `vsce package` produces valid 35KB .vsix (Phase 7) +- [ ] `afx open file.ts:42` opens in VS Code editor — deferred, CLI can't detect VS Code from shellper terminals +- [ ] Analytics Webview — deferred to post-V1 (Phase 8) +- [ ] Image paste in terminal — deferred, VS Code Pseudoterminal doesn't support clipboard image data + +## Deviations from Plan + +- **Phase 1b**: Added `@cluesmith/codev-core` package (not in original plan — added after discovering duplication during Phase 2a implementation) +- **Phase 3**: Used `TerminalLocation.Editor` + `ViewColumn` instead of `workbench.action.terminal.moveIntoEditor` (the planned approach was an undocumented API that failed) +- **Phase 4**: Overview cache renamed from `overview-cache.ts` to `overview-data.ts` for clarity +- **Phase 9**: Deferred — CLI can't detect VS Code from shellper-managed terminals, making the URI scheme approach invalid for the main use case +- **Monorepo**: Migrated from npm to pnpm workspaces mid-implementation (not in original plan) +- **Dashboard**: Moved to standalone workspace member at `packages/dashboard/` (not in original plan, driven by type sharing needs) + +## Key Metrics + +- **Commits**: 30+ on the branch +- **Tests**: 2442 passing (existing — no new extension tests yet) +- **Packages created**: `@cluesmith/codev-core`, `@cluesmith/codev-types`, `@cluesmith/config` +- **Extension files**: 20+ source files in `packages/vscode/src/` +- **Bundle size**: 80KB (dist/extension.js), 35KB packaged (.vsix) + +## Lessons Learned + +### What Went Well +- Extracting `TowerClient` to a shared package before building the extension eliminated duplication and gave the extension the full Tower API for free +- Using `TerminalLocation.Editor` + `ViewColumn` was much cleaner than the `moveIntoEditor` hack — stable API, no workarounds needed +- Subpath exports in `codev-core` successfully isolated Node builtins from the browser dashboard build +- The thin client architecture works — the extension adds VS Code-specific UI on top of shared infrastructure without reimplementing any Tower logic + +### Challenges Encountered +- **codev-core exports resolution**: esbuild reads from `dist/` not source, requiring manual core rebuilds during development. Documented but not fixed. +- **npm workspaces quirks**: Initial workspace discovery failures led to pnpm migration mid-project +- **`moveIntoEditor` failure**: The planned terminal layout approach used an undocumented API that failed silently. Discovered during testing, fixed by switching to `TerminalLocation.Editor`. +- **`afx open` integration**: The planned URI scheme approach assumed VS Code environment detection from builder terminals, which doesn't work because builders run in shellper (not VS Code's terminal) + +### What Would Be Done Differently +- Start with pnpm from the beginning instead of migrating mid-project +- Fix the codev-core exports issue (source vs dist resolution) before starting Phase 2 +- Validate the `afx open` URI scheme approach earlier — the shellper environment limitation should have been caught during spec consultation +- Add extension unit tests alongside implementation, not defer them + +## Technical Debt + +- **codev-core exports**: esbuild resolves from `dist/` not source. Requires manual `pnpm build` in `packages/core` after changes. Fix documented in `codev/specs/0602-codev-core-exports-issue.md`. +- **codev-core as runtime dependency**: Must be published to npm before codev. Adds release complexity. +- **No extension tests**: All 2442 tests are from the codev package. The extension has zero automated tests. +- **Team provider uses manual workspace encoding**: Should use a `TowerClient` method instead of inline `encodeWorkspacePath`. +- **Snippet not working**: The `rev` snippet registration may need further debugging — VS Code may need specific configuration. + +## Follow-up Items + +- Fix codev-core exports issue (Option 2: `node` + `default` conditions) +- Add extension unit tests (state machine, workspace detection, auth wrapper) +- Phase 8: Analytics Webview (post-V1) +- Phase 9: File link handling — needs a different approach than URI scheme (possibly SSE-based file open events from Tower) +- Publish `@cluesmith/codev-core` to npm and update release protocol +- Update `codev/resources/arch.md` with extension architecture diff --git a/codev/specs/0602-vscode-extension.md b/codev/specs/0602-vscode-extension.md index bd02d2e1..99ddd299 100644 --- a/codev/specs/0602-vscode-extension.md +++ b/codev/specs/0602-vscode-extension.md @@ -236,14 +236,14 @@ Singleton service managing all communication with Tower: - **REST client**: Authenticated calls to all `/api/*` endpoints - **WebSocket pool**: One WebSocket per open terminal, managed lifecycle - **Auth**: Reads `~/.agent-farm/local-key`, sends as `codev-web-key` header (HTTP). For WebSocket, send auth via a `0x00` control message after connection (not query param — query params leak into logs and process lists). Store key in VS Code `SecretStorage` for persistence, but **re-read from disk on 401** to handle key rotation. -- **Health check**: Pings `/api/health` on activation and after SSE drops. Health response should include a protocol version for compatibility checking. +- **Health check**: Pings `/health` on activation and after SSE drops. Health response should include a protocol version for compatibility checking. - **Output Channel**: Register `Codev` Output Channel for structured diagnostic logging (connection events, errors, reconnections). Essential for debugging. - **Reconnection**: Exponential backoff (1s → 2s → 4s → 8s → max 30s) - **Config**: Reads `.codev/config.json` for Tower port override and project-level settings **Workspace-scoped routing:** Tower has two route layers. The extension must use the correct one: -- **Global routes** (no prefix): `/api/overview`, `/api/send`, `/api/events`, `/api/health`, `/api/analytics`, `/api/cron/*`, `/api/workspaces` +- **Global routes** (no prefix): `/health`, `/api/overview`, `/api/send`, `/api/events`, `/api/analytics`, `/api/cron/*`, `/api/workspaces` - **Workspace-scoped routes** (prefixed): `/workspace/:base64urlPath/api/state`, `/workspace/:base64urlPath/api/team`, `/workspace/:base64urlPath/api/tabs/shell`, `/workspace/:base64urlPath/ws/terminal/:id` The Connection Manager encodes the active workspace path as base64url and prefixes all workspace-scoped requests. The workspace path is determined by traversing up from VS Code's workspace folder to find the `.codev/config.json` root, then matching that path against Tower's known workspaces (via `GET /api/workspaces`). This handles cases where the user opens a subdirectory (e.g., `~/project/src`) rather than the project root. @@ -263,10 +263,10 @@ Each Tower PTY session maps to a VS Code `Pseudoterminal`: **Terminal layout (editor area, not bottom panel):** All Codev terminals (architect, builders, shells) open in the **editor area** as terminal-in-editor views — not the bottom panel. This provides full vertical height and mirrors the browser dashboard's layout. -On first terminal open, the extension arranges two editor groups: -1. Move terminals into the editor area via `workbench.action.terminal.moveIntoEditor` -2. **Left editor group**: Architect terminal (single tab, always visible) -3. **Right editor group**: Builder terminals (one tab per builder) + shell terminals +Terminals are created directly in the editor area using `createTerminal({ location })` with `TerminalEditorLocationOptions`: +- **Architect**: `ViewColumn.One` (left editor group) +- **Builders + shells**: `ViewColumn.Two` (right editor group, one tab per terminal) +- VS Code auto-creates the split when `ViewColumn.Two` is first used ``` ┌──────────────┬────────────────┬────────────────┐ @@ -288,7 +288,7 @@ This mirrors the browser dashboard exactly: architect on the left, builders on t **Shell terminals**: Open as additional tabs in the right editor group alongside builders. -**Fallback**: `workbench.action.terminal.moveIntoEditor` is an undocumented internal VS Code command that may change between versions. If it fails, the extension falls back to the standard bottom panel and logs a warning to the Output Channel. +**Fallback**: When `codev.terminalPosition` is set to `"panel"`, terminals open in the standard bottom panel instead of the editor area. **Binary protocol adapter:** - **Inbound** (`0x01` data): `slice(1)` → `TextDecoder.decode(bytes, { stream: true })` → `onDidWrite.fire(string)` @@ -303,7 +303,7 @@ This mirrors the browser dashboard exactly: architect on the left, builders on t - Terminal scrollback is preserved via shellper — no data loss **Escape sequence buffering:** -WebSocket frames can split ANSI escape sequences mid-sequence (e.g., CSI, OSC, DCS). Writing a partial escape to `onDidWrite` corrupts terminal state (production Bugfix #630). The Pseudoterminal adapter must buffer incomplete trailing sequences and prepend them to the next frame — same logic as `dashboard/src/lib/escapeBuffer.ts`. This should be extracted into `@cluesmith/codev-shared` as a shared utility. +WebSocket frames can split ANSI escape sequences mid-sequence (e.g., CSI, OSC, DCS). Writing a partial escape to `onDidWrite` corrupts terminal state (production Bugfix #630). The Pseudoterminal adapter must buffer incomplete trailing sequences and prepend them to the next frame — same logic as `dashboard/src/lib/escapeBuffer.ts`. This should be extracted into `@cluesmith/codev-core` as a shared utility. **Resize deferral during replay:** On reconnect, the ring buffer replays potentially large scrollback. Sending a resize control frame (`0x00` with `type: 'resize'`) while replay data is being written causes garbled rendering (production Bugfix #625). The adapter must queue resize events and flush them only after the replay write completes. @@ -372,7 +372,7 @@ Single VS Code sidebar pane (like Explorer or Source Control) with collapsible s | Backlog | `GET /api/overview` | SSE events | | Recently Closed | `GET /api/overview` | SSE events | | Team | `GET /workspace/:path/api/team` | On activation + manual refresh | -| Status | `/api/health`, `/api/tunnel/*`, `/api/cron/tasks` | SSE events + polling | +| Status | `/health`, `/api/tunnel/*`, `/api/cron/tasks` | SSE events + polling | **Team section**: Conditional on `teamEnabled` — hidden when fewer than 2 team members configured. Shows member name, role, current work, open PRs, and 7-day activity summary. Context menu: "View on GitHub", "View Activity". Team messages accessible via `Codev: View Team Messages` command. @@ -498,43 +498,45 @@ Single Webview panel embedding the existing Recharts analytics page: ## Prerequisite: Shared Package Extraction -Extract shared code to avoid triple-duplicating types and API client logic across server, dashboard, and extension. **Phased approach** — do not block V1 on extracting everything. +Extract shared code to avoid duplicating logic across server, dashboard, and extension. **Extract and reuse existing code — do not duplicate as a default approach.** -**Phase 1 (before V1)**: Extract `@cluesmith/codev-types` only. Low risk, high value. -**Phase 2 (after V1 ships)**: Extract `@cluesmith/codev-shared` once patterns stabilize across two real consumers (dashboard + extension). +**Monorepo prerequisite**: Root `package.json` with `"workspaces": ["packages/*"]` — already done. -**Monorepo prerequisite**: Add a root `package.json` with `"workspaces": ["packages/*"]` before extracting. Currently no workspace manager exists. Without this, `file:` dependencies will break `vsce` packaging. +### `@cluesmith/codev-types` (Required — done) -### `@cluesmith/codev-types` (Required, Phase 1) +Zero-dependency package with shared TypeScript interfaces at `packages/types/`. Already extracted and in use. Contains WebSocket frame types, SSE event types, and API response shapes. -Zero-dependency package with shared TypeScript interfaces currently duplicated between `packages/codev/src/agent-farm/types.ts` (server) and `packages/codev/dashboard/src/lib/api.ts` (dashboard): +### `@cluesmith/codev-core` (Required — before V1) -- `DashboardState`, `Builder`, `Annotation`, `OverviewData`, `ArchitectState`, `UtilTerminal` -- WebSocket frame types (`FRAME_CONTROL = 0x00`, `FRAME_DATA = 0x01`) -- SSE event type catalog (`overview-changed`, `notification`, `connected`) -- API request/response shapes for all Tower endpoints +Shared runtime utilities extracted from existing code and reused by server, extension, and dashboard. This is NOT new code — it's existing logic moved to a shared location. -Without this package, the extension becomes a third independent copy of these types, making protocol drift inevitable. +**Subpath exports** (not a single barrel — prevents Node builtins leaking into browser builds): +- `@cluesmith/codev-core/tower-client` — `TowerClient` class + Tower API types (Node-only, uses `fs`/`os`/`crypto`) +- `@cluesmith/codev-core/auth` — `readLocalKey()` (read-only) + `ensureLocalKey()` (creates dir + generates, CLI-only) +- `@cluesmith/codev-core/workspace` — `encodeWorkspacePath()` / `decodeWorkspacePath()` (pure, universal) +- `@cluesmith/codev-core/constants` — `DEFAULT_TOWER_PORT`, `AGENT_FARM_DIR` (pure, universal) +- `@cluesmith/codev-core/escape-buffer` — `EscapeBuffer` class (pure, browser-safe) -### `@cluesmith/codev-shared` (Recommended, Phase 2 — after V1) +**`TowerClient` auth refactoring** — current class reads auth key once in constructor (`private readonly localKey`). Must be refactored to accept injectable auth (key provider function or setter) so the extension can: +- Use `SecretStorage` instead of `fs.readFileSync` +- Refresh the key on 401 without recreating the client -Shared runtime utilities and API client logic used by dashboard and extension: +**Auth function split:** +- `readLocalKey()` — read-only, returns `string | null` if file doesn't exist. Used by the extension. +- `ensureLocalKey()` — creates `~/.agent-farm/` directory and generates key if missing. CLI-only — the extension must never auto-generate keys. -- REST client with authenticated fetch (local-key header) -- SSE client with reconnection logic and heartbeat handling -- WebSocket binary protocol adapter (encode/decode `0x00`/`0x01` frames) -- EscapeBuffer (from `dashboard/src/lib/escapeBuffer.ts`) for incomplete ANSI sequence handling -- Workspace path encoding (base64url) for workspace-scoped routes -- Connection state machine (`DISCONNECTED → CONNECTING → CONNECTED → RECONNECTING`) -- Must accept a custom HTTP agent — VS Code extensions run behind corporate proxies and need to integrate with `vscode.workspace.getConfiguration('http').get('proxy')` +**After extraction:** +- `packages/codev` imports from `@cluesmith/codev-core` instead of local `tower-client.ts` +- `packages/vscode` imports from `@cluesmith/codev-core` — wraps `TowerClient` with VS Code concerns +- `packages/dashboard` imports `EscapeBuffer` from `@cluesmith/codev-core/escape-buffer` (browser-safe subpath) + +**Publishing:** `@cluesmith/codev-core` must be published to npm **before** `@cluesmith/codev` during releases (server has a runtime dependency). Pin exact versions (not `*` or `^`). Update release protocol with two-package publish order. ### Changes to Main `@cluesmith/codev` Package -1. Export types cleanly from `types.ts` for the shared types package -2. Move protocol constants (`0x00`, `0x01`) to shared package -3. Make auth helpers (local-key reading) importable -4. Reference shared types in Tower route response bodies -5. Define SSE event type catalog as shared constants +1. Replace `src/agent-farm/lib/tower-client.ts` with re-exports from `@cluesmith/codev-core` +2. Import frame constants from shared package +3. Reference shared types in Tower route response bodies ### What NOT to Extract @@ -704,7 +706,7 @@ All errors surface through a consistent pattern: ## Dependencies - **External Services**: None (localhost only) - **Internal Systems**: Tower server (existing), shellper (existing), all existing REST/WebSocket/SSE APIs -- **Internal Packages (new)**: `@cluesmith/codev-types` (shared interfaces), `@cluesmith/codev-shared` (shared runtime utilities + API client, post-V1) +- **Internal Packages (new)**: `@cluesmith/codev-types` (shared interfaces), `@cluesmith/codev-core` (shared runtime utilities + API client, post-V1) - **Libraries/Frameworks**: VS Code Extension API, `ws` (WebSocket client), `eventsource` (SSE client), React + Vite (analytics + team Webviews) - **Build**: `@vscode/vsce` for packaging and Marketplace publishing @@ -729,9 +731,9 @@ All errors surface through a consistent pattern: | Risk | Probability | Impact | Mitigation Strategy | |------|------------|--------|---------------------| | Extension host CPU spikes from high-volume terminal output | Medium | High | Chunk `onDidWrite` calls with `setImmediate`, 16KB threshold | -| Protocol drift between Tower WebSocket and extension adapter | Medium | High | Unit tests against captured binary frames, shared protocol types, version in `/api/health` | +| Protocol drift between Tower WebSocket and extension adapter | Medium | High | Unit tests against captured binary frames, shared protocol types, version in `/health` | | WebSocket backpressure causing frozen terminals | Low | High | Never drop frames — disconnect and reconnect via ring buffer replay if > 1MB queued | -| `moveIntoEditor` API instability | Medium | Medium | Fallback to bottom panel on failure, log warning to Output Channel | +| Terminal position setting ignored | Low | Low | `TerminalLocation.Editor` + `ViewColumn` used via stable API, `"panel"` fallback via setting | | Multi-workspace confusion (actions against wrong workspace) | Medium | Medium | Scope all actions to active workspace, traverse up to `.codev/config.json` root | | Comment thread line-drift after edits | Medium | Medium | Re-scan on `TextDocumentChangeEvent`, update thread positions | | Concurrent annotation edits (browser + VS Code) | Low | Medium | Document as unsupported — last writer wins | @@ -784,7 +786,7 @@ All consultation feedback has been incorporated into the relevant sections above - **No monorepo workspace config**: `vsce` fails with unmanaged `file:` dependencies. → **Added monorepo prerequisite.** **GPT-5.4 Codex — Key findings:** -- **Protocol versioning missing**: No handshake or version header. Extension breaks silently as Tower evolves. → **Added version in `/api/health` requirement.** +- **Protocol versioning missing**: No handshake or version header. Extension breaks silently as Tower evolves. → **Added version in `/health` requirement.** - **Error UX unspecified**: No error presentation for command failures. → **Added Error Handling UX section.** - **WebSocket auth via query param**: Key leaks into logs. → **Changed to `0x00` control message post-connection.** - **SSE refresh storms**: Burst of SSE events can overload extension. → **Added rate limiting (max 1/second).** diff --git a/codev/team/people/amrmelsayed.md b/codev/team/people/amrmelsayed.md index 81c53ae0..fdaec1b1 100644 --- a/codev/team/people/amrmelsayed.md +++ b/codev/team/people/amrmelsayed.md @@ -1,9 +1,8 @@ --- name: Amr Elsayed github: amrmelsayed -role: Developer — CLI & Architect Commands +role: Developer & Architect --- -Amr built the `afx architect` command (TICK 0002-001) and fixed architect -command flag parsing (#577). Also contributed ruler documentation and -installation instructions. +Contributions include the VS Code extension, monorepo architecture +(pnpm workspaces, shared packages), CLI and CI pipeline improvements. diff --git a/package.json b/package.json index cd4df61a..49116408 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,10 @@ { "name": "cluesmith-codev", "private": true, - "packageManager": "pnpm@10.33.0" + "packageManager": "pnpm@10.33.0", + "scripts": { + "build": "pnpm --filter @cluesmith/codev-core build && pnpm --filter @cluesmith/codev build", + "test": "pnpm --filter @cluesmith/codev test", + "local-install": "npm install -g ./packages/core/cluesmith-codev-core-*.tgz ./packages/codev/cluesmith-codev-*.tgz" + } } diff --git a/packages/codev/package.json b/packages/codev/package.json index c8e8def8..032c62c1 100644 --- a/packages/codev/package.json +++ b/packages/codev/package.json @@ -37,6 +37,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { + "@cluesmith/codev-core": "workspace:*", "@anthropic-ai/claude-agent-sdk": "^0.2.41", "@google/genai": "^1.0.0", "@openai/codex-sdk": "^0.101.0", @@ -80,7 +81,7 @@ "multi-agent" ], "author": "Cluesmith", - "license": "MIT", + "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/cluesmith/codev" diff --git a/packages/codev/scripts/verify-install.mjs b/packages/codev/scripts/verify-install.mjs index 0011edc7..8c3a5ec2 100644 --- a/packages/codev/scripts/verify-install.mjs +++ b/packages/codev/scripts/verify-install.mjs @@ -7,10 +7,10 @@ * all CLI binaries exist and respond to --help. * * Usage: - * node scripts/verify-install.mjs + * node scripts/verify-install.mjs [...extra-tarballs] * * Examples: - * node scripts/verify-install.mjs ./cluesmith-codev-2.0.0.tgz + * node scripts/verify-install.mjs ./cluesmith-codev-2.0.0.tgz ../core/cluesmith-codev-core-0.0.1.tgz * node scripts/verify-install.mjs @cluesmith/codev@2.0.0 */ @@ -19,18 +19,18 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -const target = process.argv[2]; -if (!target) { - console.error('Usage: node scripts/verify-install.mjs '); +const targets = process.argv.slice(2); +if (targets.length === 0) { + console.error('Usage: node scripts/verify-install.mjs [...extra-tarballs]'); process.exit(1); } - const prefix = mkdtempSync(join(tmpdir(), 'codev-install-verify-')); let failed = false; try { - console.log(`Installing ${target} into ${prefix}...`); - execSync(`npm install -g --prefix "${prefix}" "${target}"`, { stdio: 'inherit' }); + const quoted = targets.map(t => `"${t}"`).join(' '); + console.log(`Installing ${targets.join(', ')} into ${prefix}...`); + execSync(`npm install -g --prefix "${prefix}" ${quoted}`, { stdio: 'inherit' }); const bins = ['codev', 'af', 'porch', 'consult']; for (const bin of bins) { diff --git a/packages/codev/src/agent-farm/lib/tower-client.ts b/packages/codev/src/agent-farm/lib/tower-client.ts index 6a70d0b1..bc4efc44 100644 --- a/packages/codev/src/agent-farm/lib/tower-client.ts +++ b/packages/codev/src/agent-farm/lib/tower-client.ts @@ -1,460 +1,21 @@ /** - * Tower API Client (Spec 0090 Phase 3) + * Tower API Client — re-exports from @cluesmith/codev-core * - * Provides a client for CLI commands to interact with the tower daemon. - * Handles local-key authentication and common API operations. - */ - -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { homedir } from 'node:os'; -import { randomBytes } from 'node:crypto'; - -// Tower configuration -export const DEFAULT_TOWER_PORT = 4100; -export const AGENT_FARM_DIR = resolve(homedir(), '.agent-farm'); -const LOCAL_KEY_PATH = resolve(AGENT_FARM_DIR, 'local-key'); - -// Request timeout -const REQUEST_TIMEOUT_MS = 10000; - -/** - * Workspace info returned by tower API - */ -export interface TowerWorkspace { - path: string; - name: string; - active: boolean; - proxyUrl: string; - terminals: number; -} - -/** - * Workspace status with detailed info - */ -export interface TowerWorkspaceStatus { - path: string; - name: string; - active: boolean; - terminals: Array<{ - type: 'architect' | 'builder' | 'shell'; - id: string; - label: string; - url: string; - active: boolean; - }>; -} - -/** - * Health status from tower - */ -export interface TowerHealth { - status: 'healthy' | 'degraded'; - uptime: number; - activeWorkspaces: number; - totalWorkspaces: number; - memoryUsage: number; - timestamp: string; -} - -/** - * Tunnel status from tower - */ -export interface TowerTunnelStatus { - registered: boolean; - state: string; - uptime: number | null; - towerId: string | null; - towerName: string | null; - serverUrl: string | null; - accessUrl: string | null; -} - -/** - * Tower daemon status (instances overview) - */ -export interface TowerStatus { - instances?: Array<{ workspaceName: string; running: boolean; terminals: unknown[] }>; -} - -/** - * Terminal info from tower - */ -export interface TowerTerminal { - id: string; - pid: number; - cols: number; - rows: number; - label: string; - status: 'running' | 'exited'; - createdAt: string; - wsPath: string; -} - -/** - * Get or create the local key for CLI authentication - */ -function getLocalKey(): string { - if (!existsSync(AGENT_FARM_DIR)) { - mkdirSync(AGENT_FARM_DIR, { recursive: true, mode: 0o700 }); - } - - if (!existsSync(LOCAL_KEY_PATH)) { - const key = randomBytes(32).toString('hex'); - writeFileSync(LOCAL_KEY_PATH, key, { mode: 0o600 }); - return key; - } - - return readFileSync(LOCAL_KEY_PATH, 'utf-8').trim(); -} - -/** - * Encode a workspace path for use in tower API URLs - */ -export function encodeWorkspacePath(workspacePath: string): string { - return Buffer.from(workspacePath).toString('base64url'); -} - -/** - * Decode a workspace path from tower API URL - */ -export function decodeWorkspacePath(encoded: string): string { - return Buffer.from(encoded, 'base64url').toString('utf-8'); -} - -/** - * Tower API client class - */ -export class TowerClient { - private readonly baseUrl: string; - private readonly localKey: string; - - constructor(port: number = DEFAULT_TOWER_PORT) { - this.baseUrl = `http://localhost:${port}`; - this.localKey = getLocalKey(); - } - - /** - * Make a request to the tower API - */ - async request( - path: string, - options: RequestInit = {} - ): Promise<{ ok: boolean; status: number; data?: T; error?: string }> { - try { - const response = await fetch(`${this.baseUrl}${path}`, { - ...options, - headers: { - ...options.headers, - 'codev-web-key': this.localKey, - 'Content-Type': 'application/json', - }, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - - if (!response.ok) { - const text = await response.text(); - let error: string; - try { - const json = JSON.parse(text); - error = json.error || json.message || text; - } catch { - error = text; - } - return { ok: false, status: response.status, error }; - } - - if (response.status === 204) { - return { ok: true, status: 204 }; - } - - const data = (await response.json()) as T; - return { ok: true, status: response.status, data }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes('ECONNREFUSED')) { - return { ok: false, status: 0, error: 'Tower not running' }; - } - if (message.includes('timeout')) { - return { ok: false, status: 0, error: 'Request timeout' }; - } - return { ok: false, status: 0, error: message }; - } - } - - /** - * Check if tower is running and healthy - */ - async isRunning(): Promise { - const result = await this.request('/health'); - return result.ok && result.data?.status === 'healthy'; - } - - /** - * Get tower health status - */ - async getHealth(): Promise { - const result = await this.request('/health'); - return result.ok ? result.data! : null; - } - - /** - * List all workspaces known to tower - */ - async listWorkspaces(): Promise { - const result = await this.request<{ workspaces: TowerWorkspace[] }>('/api/workspaces'); - return result.ok ? result.data!.workspaces : []; - } - - /** - * Activate a workspace (start its dashboard) - */ - async activateWorkspace( - workspacePath: string - ): Promise<{ ok: boolean; adopted?: boolean; error?: string }> { - const encoded = encodeWorkspacePath(workspacePath); - const result = await this.request<{ success: boolean; adopted?: boolean; error?: string }>( - `/api/workspaces/${encoded}/activate`, - { method: 'POST' } - ); - - if (!result.ok) { - return { ok: false, error: result.error }; - } - - return { - ok: result.data?.success ?? true, - adopted: result.data?.adopted, - error: result.data?.error, - }; - } - - /** - * Deactivate a workspace (stop its dashboard) - */ - async deactivateWorkspace( - workspacePath: string - ): Promise<{ ok: boolean; stopped?: number[]; error?: string }> { - const encoded = encodeWorkspacePath(workspacePath); - const result = await this.request<{ success: boolean; stopped?: number[]; error?: string }>( - `/api/workspaces/${encoded}/deactivate`, - { method: 'POST' } - ); - - if (!result.ok) { - return { ok: false, error: result.error }; - } - - return { - ok: result.data?.success ?? true, - stopped: result.data?.stopped, - error: result.data?.error, - }; - } - - /** - * Get status of a specific workspace - */ - async getWorkspaceStatus(workspacePath: string): Promise { - const encoded = encodeWorkspacePath(workspacePath); - const result = await this.request(`/api/workspaces/${encoded}/status`); - return result.ok ? result.data! : null; - } - - /** - * Create a terminal session - */ - async createTerminal(options: { - command?: string; - args?: string[]; - cols?: number; - rows?: number; - cwd?: string; - label?: string; - env?: Record; - persistent?: boolean; - workspacePath?: string; - type?: 'architect' | 'builder' | 'shell'; - roleId?: string; - }): Promise { - const result = await this.request('/api/terminals', { - method: 'POST', - body: JSON.stringify(options), - }); - return result.ok ? result.data! : null; - } - - /** - * List all terminal sessions - */ - async listTerminals(): Promise { - const result = await this.request<{ terminals: TowerTerminal[] }>('/api/terminals'); - return result.ok ? result.data!.terminals : []; - } - - /** - * Get terminal info - */ - async getTerminal(terminalId: string): Promise { - const result = await this.request(`/api/terminals/${terminalId}`); - return result.ok ? result.data! : null; - } - - /** - * Write data to a terminal session - */ - async writeTerminal(terminalId: string, data: string): Promise { - const result = await this.request(`/api/terminals/${terminalId}/write`, { - method: 'POST', - body: JSON.stringify({ data }), - }); - return result.ok; - } - - /** - * Kill a terminal session - */ - async killTerminal(terminalId: string): Promise { - const result = await this.request(`/api/terminals/${terminalId}`, { method: 'DELETE' }); - return result.ok; - } - - /** - * Resize a terminal - */ - async resizeTerminal( - terminalId: string, - cols: number, - rows: number - ): Promise { - const result = await this.request(`/api/terminals/${terminalId}/resize`, { - method: 'POST', - body: JSON.stringify({ cols, rows }), - }); - return result.ok ? result.data! : null; - } - - /** - * Rename a terminal session (Spec 468) - */ - async renameTerminal( - sessionId: string, - name: string, - ): Promise<{ ok: boolean; status: number; data?: { id: string; name: string }; error?: string }> { - return this.request<{ id: string; name: string }>(`/api/terminals/${sessionId}/rename`, { - method: 'PATCH', - body: JSON.stringify({ name }), - }); - } - - /** - * Get the tower dashboard URL for a workspace - */ - getWorkspaceUrl(workspacePath: string): string { - const encoded = encodeWorkspacePath(workspacePath); - return `${this.baseUrl}/workspace/${encoded}/`; - } - - /** - * Send a message to an agent via address resolution. - * Uses POST /api/send which resolves [project:]agent addresses. - */ - async sendMessage( - to: string, - message: string, - options?: { - from?: string; - workspace?: string; - fromWorkspace?: string; - raw?: boolean; - noEnter?: boolean; - interrupt?: boolean; - }, - ): Promise<{ ok: boolean; resolvedTo?: string; error?: string }> { - const result = await this.request<{ ok: boolean; resolvedTo: string }>( - '/api/send', - { - method: 'POST', - body: JSON.stringify({ - to, - message, - from: options?.from, - workspace: options?.workspace, - fromWorkspace: options?.fromWorkspace, - options: { - raw: options?.raw, - noEnter: options?.noEnter, - interrupt: options?.interrupt, - }, - }), - }, - ); - - if (!result.ok) { - return { ok: false, error: result.error }; - } - - return { ok: true, resolvedTo: result.data!.resolvedTo }; - } - - /** - * Signal the tunnel to connect or disconnect - */ - async signalTunnel(action: 'connect' | 'disconnect'): Promise { - await this.request(`/api/tunnel/${action}`, { method: 'POST' }).catch(() => {}); - } - - /** - * Get tunnel status from the running tower daemon - */ - async getTunnelStatus(): Promise { - const result = await this.request('/api/tunnel/status'); - return result.ok ? result.data! : null; - } - - /** - * Get tower daemon status (instances overview) - */ - async getStatus(): Promise { - const result = await this.request('/api/status'); - return result.ok ? result.data! : null; - } - - /** - * Send a push notification to the tower dashboard - */ - async sendNotification(payload: { - type: string; - title: string; - body: string; - workspace: string; - }): Promise { - const result = await this.request('/api/notify', { - method: 'POST', - body: JSON.stringify(payload), - }); - return result.ok; - } - - /** - * Get the WebSocket URL for a terminal - */ - getTerminalWsUrl(terminalId: string): string { - return `ws://localhost:${new URL(this.baseUrl).port}/ws/terminal/${terminalId}`; - } -} - -/** - * Default tower client instance - */ -let defaultClient: TowerClient | null = null; - -/** - * Get the default tower client - */ -export function getTowerClient(port?: number): TowerClient { - if (!defaultClient || (port && port !== DEFAULT_TOWER_PORT)) { - defaultClient = new TowerClient(port); - } - return defaultClient; -} + * All implementation lives in packages/core/src/tower-client.ts. + * This file preserves the import path for existing consumers. + */ + +export { + TowerClient, + getTowerClient, + type TowerClientOptions, + type TowerWorkspace, + type TowerWorkspaceStatus, + type TowerHealth, + type TowerTunnelStatus, + type TowerStatus, + type TowerTerminal, +} from '@cluesmith/codev-core/tower-client'; + +export { encodeWorkspacePath, decodeWorkspacePath } from '@cluesmith/codev-core/workspace'; +export { DEFAULT_TOWER_PORT, AGENT_FARM_DIR } from '@cluesmith/codev-core/constants'; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..b89ee2df --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,26 @@ +{ + "name": "@cluesmith/codev-core", + "version": "0.0.1", + "description": "Core runtime utilities for Codev — Tower client, auth, workspace helpers, escape buffer", + "type": "module", + "exports": { + "./tower-client": { "types": "./dist/tower-client.d.ts", "default": "./dist/tower-client.js" }, + "./auth": { "types": "./dist/auth.d.ts", "default": "./dist/auth.js" }, + "./workspace": { "types": "./dist/workspace.d.ts", "default": "./dist/workspace.js" }, + "./constants": { "types": "./dist/constants.d.ts", "default": "./dist/constants.js" }, + "./escape-buffer": { "types": "./dist/escape-buffer.d.ts","default": "./dist/escape-buffer.js" } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "check-types": "tsc --noEmit" + }, + "devDependencies": { + "@cluesmith/codev-types": "workspace:*", + "@types/node": "22.x", + "typescript": "^5.7.0" + }, + "license": "Apache-2.0" +} diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts new file mode 100644 index 00000000..df32cd71 --- /dev/null +++ b/packages/core/src/auth.ts @@ -0,0 +1,37 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { AGENT_FARM_DIR } from './constants.js'; + +const LOCAL_KEY_PATH = resolve(AGENT_FARM_DIR, 'local-key'); + +/** + * Read the local auth key from disk. Returns null if file doesn't exist. + * Read-only — does not create directories or generate keys. + * Safe for use in the VS Code extension. + */ +export function readLocalKey(): string | null { + try { + return readFileSync(LOCAL_KEY_PATH, 'utf-8').trim() || null; + } catch { + return null; + } +} + +/** + * Get or create the local auth key. Creates ~/.agent-farm/ and generates + * a random key if missing. CLI-only — the extension should use readLocalKey(). + */ +export function ensureLocalKey(): string { + if (!existsSync(AGENT_FARM_DIR)) { + mkdirSync(AGENT_FARM_DIR, { recursive: true, mode: 0o700 }); + } + + if (!existsSync(LOCAL_KEY_PATH)) { + const key = randomBytes(32).toString('hex'); + writeFileSync(LOCAL_KEY_PATH, key, { mode: 0o600 }); + return key; + } + + return readFileSync(LOCAL_KEY_PATH, 'utf-8').trim(); +} diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts new file mode 100644 index 00000000..0caaf88e --- /dev/null +++ b/packages/core/src/constants.ts @@ -0,0 +1,5 @@ +import { resolve } from 'node:path'; +import { homedir } from 'node:os'; + +export const DEFAULT_TOWER_PORT = 4100; +export const AGENT_FARM_DIR = resolve(homedir(), '.agent-farm'); diff --git a/packages/core/src/escape-buffer.ts b/packages/core/src/escape-buffer.ts new file mode 100644 index 00000000..45655410 --- /dev/null +++ b/packages/core/src/escape-buffer.ts @@ -0,0 +1,94 @@ +/** + * EscapeBuffer — accumulates PTY data and ensures escape sequences + * are never written to xterm split across WebSocket frames. + * + * When data arrives, any trailing incomplete escape sequence is held + * back and prepended to the next chunk. This prevents xterm parsing + * errors that cause scroll-to-top (Issue #630). + */ +export class EscapeBuffer { + private pending = ''; + + /** + * Add incoming data and return the portion safe to write to xterm. + * Any trailing incomplete escape sequence is buffered internally. + */ + write(data: string): string { + data = this.pending + data; + this.pending = ''; + + // Find the last ESC in the data + const lastEsc = data.lastIndexOf('\x1b'); + if (lastEsc === -1) return data; + + // Check if the escape sequence starting at lastEsc is complete + const tail = data.substring(lastEsc); + if (isCompleteEscape(tail)) return data; + + // Incomplete escape sequence at the end — buffer it + this.pending = tail; + return data.substring(0, lastEsc); + } + + /** + * Flush any pending data (e.g., on disconnect or cleanup). + * Returns empty string if nothing is pending. + */ + flush(): string { + const data = this.pending; + this.pending = ''; + return data; + } + + /** Whether there is buffered data waiting for completion. */ + get hasPending(): boolean { + return this.pending.length > 0; + } +} + +/** + * Check if an escape sequence (starting with ESC) is complete. + * Returns false for incomplete sequences that need more data. + */ +function isCompleteEscape(seq: string): boolean { + if (seq.length < 2) return false; // Just ESC — need more data + + const second = seq.charCodeAt(1); + + // CSI: ESC [ + if (second === 0x5b) { + for (let i = 2; i < seq.length; i++) { + const c = seq.charCodeAt(i); + if (c >= 0x40 && c <= 0x7e) return true; // Final byte found + // Parameter bytes (0x30-0x3f) and intermediate bytes (0x20-0x2f) continue + if ((c >= 0x20 && c <= 0x3f)) continue; + // Unexpected byte — treat as complete to avoid infinite buffering + return true; + } + return false; // No final byte yet + } + + // OSC: ESC ] ... BEL(0x07) or ST(ESC \) + if (second === 0x5d) { + for (let i = 2; i < seq.length; i++) { + if (seq.charCodeAt(i) === 0x07) return true; + if (seq.charCodeAt(i) === 0x1b && i + 1 < seq.length && seq.charCodeAt(i + 1) === 0x5c) return true; + } + return false; + } + + // DCS(P), APC(_), PM(^), SOS(X): ESC ... ST(ESC \) + if (second === 0x50 || second === 0x5f || second === 0x5e || second === 0x58) { + for (let i = 2; i < seq.length; i++) { + if (seq.charCodeAt(i) === 0x1b && i + 1 < seq.length && seq.charCodeAt(i + 1) === 0x5c) return true; + } + return false; + } + + // Two-byte sequences: ESC + single character (0x20-0x7e) + // Includes: ESC =, ESC >, ESC 7, ESC 8, ESC M, etc. + if (second >= 0x20 && second <= 0x7e) return true; + + // Anything else — treat as complete to avoid infinite buffering + return true; +} diff --git a/packages/core/src/tower-client.ts b/packages/core/src/tower-client.ts new file mode 100644 index 00000000..6da084e8 --- /dev/null +++ b/packages/core/src/tower-client.ts @@ -0,0 +1,381 @@ +/** + * Tower API Client + * + * Provides a client for interacting with the Tower daemon. + * Handles authentication and common API operations. + * + * Extracted from packages/codev/src/agent-farm/lib/tower-client.ts + */ + +import type { DashboardState, OverviewData } from '@cluesmith/codev-types'; +import { DEFAULT_TOWER_PORT } from './constants.js'; +import { ensureLocalKey } from './auth.js'; + +const REQUEST_TIMEOUT_MS = 10000; + +// ── Types ────────────────────────────────────────────────────── + +export interface TowerWorkspace { + path: string; + name: string; + active: boolean; + proxyUrl: string; + terminals: number; +} + +export interface TowerWorkspaceStatus { + path: string; + name: string; + active: boolean; + terminals: Array<{ + type: 'architect' | 'builder' | 'shell'; + id: string; + label: string; + url: string; + active: boolean; + }>; +} + +export interface TowerHealth { + status: 'healthy' | 'degraded'; + uptime: number; + activeWorkspaces: number; + totalWorkspaces: number; + memoryUsage: number; + timestamp: string; +} + +export interface TowerTunnelStatus { + registered: boolean; + state: string; + uptime: number | null; + towerId: string | null; + towerName: string | null; + serverUrl: string | null; + accessUrl: string | null; +} + +export interface TowerStatus { + instances?: Array<{ workspaceName: string; running: boolean; terminals: unknown[] }>; +} + +export interface TowerTerminal { + id: string; + pid: number; + cols: number; + rows: number; + label: string; + status: 'running' | 'exited'; + createdAt: string; + wsPath: string; +} + +// ── Client Options ───────────────────────────────────────────── + +export interface TowerClientOptions { + port?: number; + host?: string; + /** Injectable auth key provider. Defaults to ensureLocalKey() from disk. */ + getAuthKey?: () => string | null; +} + +// ── Client ───────────────────────────────────────────────────── + +import { encodeWorkspacePath } from './workspace.js'; + +export class TowerClient { + private readonly baseUrl: string; + private readonly getAuthKey: () => string | null; + + constructor(portOrOptions?: number | TowerClientOptions) { + const options = typeof portOrOptions === 'number' + ? { port: portOrOptions } + : portOrOptions ?? {}; + const host = options.host ?? 'localhost'; + const port = options.port ?? DEFAULT_TOWER_PORT; + this.baseUrl = `http://${host}:${port}`; + this.getAuthKey = options.getAuthKey ?? ensureLocalKey; + } + + async request( + path: string, + options: RequestInit = {} + ): Promise<{ ok: boolean; status: number; data?: T; error?: string }> { + try { + const authKey = this.getAuthKey(); + const headers: Record = { + ...options.headers as Record, + 'Content-Type': 'application/json', + }; + if (authKey) { + headers['codev-web-key'] = authKey; + } + + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) { + const text = await response.text(); + let error: string; + try { + const json = JSON.parse(text); + error = json.error || json.message || text; + } catch { + error = text; + } + return { ok: false, status: response.status, error }; + } + + if (response.status === 204) { + return { ok: true, status: 204 }; + } + + const data = (await response.json()) as T; + return { ok: true, status: response.status, data }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('ECONNREFUSED')) { + return { ok: false, status: 0, error: 'Tower not running' }; + } + if (message.includes('timeout')) { + return { ok: false, status: 0, error: 'Request timeout' }; + } + return { ok: false, status: 0, error: message }; + } + } + + async isRunning(): Promise { + const result = await this.request('/health'); + return result.ok && result.data?.status === 'healthy'; + } + + async getHealth(): Promise { + const result = await this.request('/health'); + return result.ok ? result.data! : null; + } + + async listWorkspaces(): Promise { + const result = await this.request<{ workspaces: TowerWorkspace[] }>('/api/workspaces'); + return result.ok ? result.data!.workspaces : []; + } + + async activateWorkspace( + workspacePath: string + ): Promise<{ ok: boolean; adopted?: boolean; error?: string }> { + const encoded = encodeWorkspacePath(workspacePath); + const result = await this.request<{ success: boolean; adopted?: boolean; error?: string }>( + `/api/workspaces/${encoded}/activate`, + { method: 'POST' } + ); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: result.data?.success ?? true, + adopted: result.data?.adopted, + error: result.data?.error, + }; + } + + async deactivateWorkspace( + workspacePath: string + ): Promise<{ ok: boolean; stopped?: number[]; error?: string }> { + const encoded = encodeWorkspacePath(workspacePath); + const result = await this.request<{ success: boolean; stopped?: number[]; error?: string }>( + `/api/workspaces/${encoded}/deactivate`, + { method: 'POST' } + ); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: result.data?.success ?? true, + stopped: result.data?.stopped, + error: result.data?.error, + }; + } + + async getWorkspaceStatus(workspacePath: string): Promise { + const encoded = encodeWorkspacePath(workspacePath); + const result = await this.request(`/api/workspaces/${encoded}/status`); + return result.ok ? result.data! : null; + } + + async getOverview(workspacePath?: string): Promise { + const query = workspacePath ? `?workspace=${encodeURIComponent(workspacePath)}` : ''; + const result = await this.request(`/api/overview${query}`); + return result.ok ? result.data! : null; + } + + async getWorkspaceState(workspacePath: string): Promise { + const encoded = encodeWorkspacePath(workspacePath); + const result = await this.request(`/workspace/${encoded}/api/state`); + return result.ok ? result.data! : null; + } + + async createShellTab(workspacePath: string): Promise<{ id: string; name: string; terminalId: string } | null> { + const encoded = encodeWorkspacePath(workspacePath); + const result = await this.request<{ id: string; name: string; terminalId: string }>( + `/workspace/${encoded}/api/tabs/shell`, + { method: 'POST', body: JSON.stringify({}) }, + ); + return result.ok ? result.data! : null; + } + + async createTerminal(options: { + command?: string; + args?: string[]; + cols?: number; + rows?: number; + cwd?: string; + label?: string; + env?: Record; + persistent?: boolean; + workspacePath?: string; + type?: 'architect' | 'builder' | 'shell'; + roleId?: string; + }): Promise { + const result = await this.request('/api/terminals', { + method: 'POST', + body: JSON.stringify(options), + }); + return result.ok ? result.data! : null; + } + + async listTerminals(): Promise { + const result = await this.request<{ terminals: TowerTerminal[] }>('/api/terminals'); + return result.ok ? result.data!.terminals : []; + } + + async getTerminal(terminalId: string): Promise { + const result = await this.request(`/api/terminals/${terminalId}`); + return result.ok ? result.data! : null; + } + + async writeTerminal(terminalId: string, data: string): Promise { + const result = await this.request(`/api/terminals/${terminalId}/write`, { + method: 'POST', + body: JSON.stringify({ data }), + }); + return result.ok; + } + + async killTerminal(terminalId: string): Promise { + const result = await this.request(`/api/terminals/${terminalId}`, { method: 'DELETE' }); + return result.ok; + } + + async resizeTerminal( + terminalId: string, + cols: number, + rows: number + ): Promise { + const result = await this.request(`/api/terminals/${terminalId}/resize`, { + method: 'POST', + body: JSON.stringify({ cols, rows }), + }); + return result.ok ? result.data! : null; + } + + async renameTerminal( + sessionId: string, + name: string, + ): Promise<{ ok: boolean; status: number; data?: { id: string; name: string }; error?: string }> { + return this.request<{ id: string; name: string }>(`/api/terminals/${sessionId}/rename`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }); + } + + getWorkspaceUrl(workspacePath: string): string { + const encoded = encodeWorkspacePath(workspacePath); + return `${this.baseUrl}/workspace/${encoded}/`; + } + + async sendMessage( + to: string, + message: string, + options?: { + from?: string; + workspace?: string; + fromWorkspace?: string; + raw?: boolean; + noEnter?: boolean; + interrupt?: boolean; + }, + ): Promise<{ ok: boolean; resolvedTo?: string; error?: string }> { + const result = await this.request<{ ok: boolean; resolvedTo: string }>( + '/api/send', + { + method: 'POST', + body: JSON.stringify({ + to, + message, + from: options?.from, + workspace: options?.workspace, + fromWorkspace: options?.fromWorkspace, + options: { + raw: options?.raw, + noEnter: options?.noEnter, + interrupt: options?.interrupt, + }, + }), + }, + ); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { ok: true, resolvedTo: result.data!.resolvedTo }; + } + + async signalTunnel(action: 'connect' | 'disconnect'): Promise { + await this.request(`/api/tunnel/${action}`, { method: 'POST' }).catch(() => {}); + } + + async getTunnelStatus(): Promise { + const result = await this.request('/api/tunnel/status'); + return result.ok ? result.data! : null; + } + + async getStatus(): Promise { + const result = await this.request('/api/status'); + return result.ok ? result.data! : null; + } + + async sendNotification(payload: { + type: string; + title: string; + body: string; + workspace: string; + }): Promise { + const result = await this.request('/api/notify', { + method: 'POST', + body: JSON.stringify(payload), + }); + return result.ok; + } + + getTerminalWsUrl(terminalId: string): string { + return `ws://localhost:${new URL(this.baseUrl).port}/ws/terminal/${terminalId}`; + } +} + +// ── Default client ───────────────────────────────────────────── + +let defaultClient: TowerClient | null = null; + +export function getTowerClient(port?: number): TowerClient { + if (!defaultClient || (port && port !== DEFAULT_TOWER_PORT)) { + defaultClient = new TowerClient({ port }); + } + return defaultClient; +} diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts new file mode 100644 index 00000000..77a5b0d7 --- /dev/null +++ b/packages/core/src/workspace.ts @@ -0,0 +1,13 @@ +/** + * Encode a workspace path for use in Tower API URLs. + */ +export function encodeWorkspacePath(workspacePath: string): string { + return Buffer.from(workspacePath).toString('base64url'); +} + +/** + * Decode a workspace path from a Tower API URL. + */ +export function decodeWorkspacePath(encoded: string): string { + return Buffer.from(encoded, 'base64url').toString('utf-8'); +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..799bafdd --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index eb7ae399..5e151edf 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -14,7 +14,7 @@ "test:watch": "vitest" }, "dependencies": { - "@cluesmith/codev-types": "workspace:*", + "@cluesmith/codev-core": "workspace:*", "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.12.0", @@ -25,6 +25,7 @@ "recharts": "^3.7.0" }, "devDependencies": { + "@cluesmith/codev-types": "workspace:*", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", "@types/react": "^19.0.0", diff --git a/packages/dashboard/src/lib/api.ts b/packages/dashboard/src/lib/api.ts index b192413a..bb1ec558 100644 --- a/packages/dashboard/src/lib/api.ts +++ b/packages/dashboard/src/lib/api.ts @@ -1,89 +1,17 @@ import { getApiBase } from './constants.js'; -export interface Builder { - id: string; - name: string; - port: number; - pid: number; - status: string; - phase: string; - worktree: string; - branch: string; - type: string; - projectId?: string; - terminalId?: string; - persistent?: boolean; -} - -export interface UtilTerminal { - id: string; - name: string; - port: number; - pid: number; - terminalId?: string; - persistent?: boolean; - lastDataAt?: number; -} - -export interface Annotation { - id: string; - file: string; - port: number; - pid: number; - parent: { type: string; id?: string }; -} - -export interface ArchitectState { - port: number; - pid: number; - terminalId?: string; - persistent?: boolean; -} - -export interface DashboardState { - architect: ArchitectState | null; - builders: Builder[]; - utils: UtilTerminal[]; - annotations: Annotation[]; - workspaceName?: string; - version?: string; - hostname?: string; - teamEnabled?: boolean; -} - -// Spec 587: Team tab types - -export interface TeamMemberGitHubData { - assignedIssues: { number: number; title: string; url: string }[]; - openPRs: { number: number; title: string; url: string }[]; - recentActivity: { - mergedPRs: { number: number; title: string; url: string; mergedAt: string }[]; - closedIssues: { number: number; title: string; url: string; closedAt: string }[]; - }; -} - -export interface TeamApiMember { - name: string; - github: string; - role: string; - filePath: string; - github_data: TeamMemberGitHubData | null; -} - -export interface TeamApiMessage { - author: string; - timestamp: string; - body: string; - channel: string; -} - -export interface TeamApiResponse { - enabled: boolean; - members?: TeamApiMember[]; - messages?: TeamApiMessage[]; - warnings?: string[]; - githubError?: string; -} +// Shared types from @cluesmith/codev-types +export type { + Builder, + UtilTerminal, + Annotation, + ArchitectState, + DashboardState, + TeamMemberGitHubData, + TeamApiMember, + TeamApiMessage, + TeamApiResponse, +} from '@cluesmith/codev-types'; export interface FileEntry { name: string; @@ -112,110 +40,24 @@ export async function fetchState(): Promise { return res.json(); } -// Spec 0126: Overview endpoint types and fetchers for Work view - -export interface OverviewBuilder { - id: string; - issueId: string | null; - issueTitle: string | null; - phase: string; - mode: 'strict' | 'soft'; - gates: Record; - worktreePath: string; - protocol: string; - planPhases: Array<{ id: string; title: string; status: string }>; - progress: number; - blocked: string | null; - blockedSince: string | null; - startedAt: string | null; - idleMs: number; -} - -export interface OverviewPR { - id: string; - title: string; - url: string; - reviewStatus: string; - linkedIssue: string | null; - createdAt: string; - author?: string; -} - -export interface OverviewBacklogItem { - id: string; - title: string; - url: string; - type: string; - priority: string; - hasSpec: boolean; - hasPlan: boolean; - hasReview: boolean; - hasBuilder: boolean; - createdAt: string; - author?: string; - specPath?: string; - planPath?: string; - reviewPath?: string; -} - -export interface OverviewRecentlyClosed { - id: string; - title: string; - url: string; - type: string; - closedAt: string; - prUrl?: string; - specPath?: string; - planPath?: string; - reviewPath?: string; -} - -export interface OverviewData { - builders: OverviewBuilder[]; - pendingPRs: OverviewPR[]; - backlog: OverviewBacklogItem[]; - recentlyClosed: OverviewRecentlyClosed[]; - errors?: { prs?: string; issues?: string }; -} - -// Spec 456: Analytics tab types and fetcher - -export interface ProtocolStats { - count: number; - avgWallClockHours: number | null; - avgAgentTimeHours: number | null; -} - -export interface AnalyticsResponse { - timeRange: '24h' | '7d' | '30d' | 'all'; - activity: { - prsMerged: number; - medianTimeToMergeHours: number | null; - issuesClosed: number; - medianTimeToCloseBugsHours: number | null; - projectsByProtocol: Record; - }; - consultation: { - totalCount: number; - totalCostUsd: number | null; - costByModel: Record; - avgLatencySeconds: number | null; - successRate: number | null; - byModel: Array<{ - model: string; - count: number; - avgLatency: number; - totalCost: number | null; - successRate: number; - }>; - byReviewType: Record; - byProtocol: Record; - }; - errors?: { - github?: string; - consultation?: string; - }; -} +// Shared types from @cluesmith/codev-types +export type { + OverviewBuilder, + OverviewPR, + OverviewBacklogItem, + OverviewRecentlyClosed, + OverviewData, + ProtocolStats, + AnalyticsResponse, +} from '@cluesmith/codev-types'; + +// Re-import for use in function signatures below +import type { + AnalyticsResponse, + TeamApiResponse, + OverviewData, + DashboardState, +} from '@cluesmith/codev-types'; export async function fetchAnalytics(range: string, refresh?: boolean): Promise { const params = new URLSearchParams({ range }); @@ -374,16 +216,8 @@ export async function fetchRecentFiles(): Promise { } // Spec 0097: Tunnel status and control APIs for cloud connection - -export interface TunnelStatus { - registered: boolean; - state: 'disconnected' | 'connecting' | 'connected' | 'auth_failed' | 'error'; - uptime: number | null; - towerId: string | null; - towerName: string | null; - serverUrl: string | null; - accessUrl: string | null; -} +export type { TunnelStatus } from '@cluesmith/codev-types'; +import type { TunnelStatus } from '@cluesmith/codev-types'; const ERROR_STATUS: TunnelStatus = { registered: false, state: 'error', uptime: null, diff --git a/packages/dashboard/src/lib/escapeBuffer.ts b/packages/dashboard/src/lib/escapeBuffer.ts index 45655410..5742ef46 100644 --- a/packages/dashboard/src/lib/escapeBuffer.ts +++ b/packages/dashboard/src/lib/escapeBuffer.ts @@ -1,94 +1,5 @@ /** - * EscapeBuffer — accumulates PTY data and ensures escape sequences - * are never written to xterm split across WebSocket frames. - * - * When data arrives, any trailing incomplete escape sequence is held - * back and prepended to the next chunk. This prevents xterm parsing - * errors that cause scroll-to-top (Issue #630). + * Re-export from @cluesmith/codev-core. + * Preserves the import path for existing consumers. */ -export class EscapeBuffer { - private pending = ''; - - /** - * Add incoming data and return the portion safe to write to xterm. - * Any trailing incomplete escape sequence is buffered internally. - */ - write(data: string): string { - data = this.pending + data; - this.pending = ''; - - // Find the last ESC in the data - const lastEsc = data.lastIndexOf('\x1b'); - if (lastEsc === -1) return data; - - // Check if the escape sequence starting at lastEsc is complete - const tail = data.substring(lastEsc); - if (isCompleteEscape(tail)) return data; - - // Incomplete escape sequence at the end — buffer it - this.pending = tail; - return data.substring(0, lastEsc); - } - - /** - * Flush any pending data (e.g., on disconnect or cleanup). - * Returns empty string if nothing is pending. - */ - flush(): string { - const data = this.pending; - this.pending = ''; - return data; - } - - /** Whether there is buffered data waiting for completion. */ - get hasPending(): boolean { - return this.pending.length > 0; - } -} - -/** - * Check if an escape sequence (starting with ESC) is complete. - * Returns false for incomplete sequences that need more data. - */ -function isCompleteEscape(seq: string): boolean { - if (seq.length < 2) return false; // Just ESC — need more data - - const second = seq.charCodeAt(1); - - // CSI: ESC [ - if (second === 0x5b) { - for (let i = 2; i < seq.length; i++) { - const c = seq.charCodeAt(i); - if (c >= 0x40 && c <= 0x7e) return true; // Final byte found - // Parameter bytes (0x30-0x3f) and intermediate bytes (0x20-0x2f) continue - if ((c >= 0x20 && c <= 0x3f)) continue; - // Unexpected byte — treat as complete to avoid infinite buffering - return true; - } - return false; // No final byte yet - } - - // OSC: ESC ] ... BEL(0x07) or ST(ESC \) - if (second === 0x5d) { - for (let i = 2; i < seq.length; i++) { - if (seq.charCodeAt(i) === 0x07) return true; - if (seq.charCodeAt(i) === 0x1b && i + 1 < seq.length && seq.charCodeAt(i + 1) === 0x5c) return true; - } - return false; - } - - // DCS(P), APC(_), PM(^), SOS(X): ESC ... ST(ESC \) - if (second === 0x50 || second === 0x5f || second === 0x5e || second === 0x58) { - for (let i = 2; i < seq.length; i++) { - if (seq.charCodeAt(i) === 0x1b && i + 1 < seq.length && seq.charCodeAt(i + 1) === 0x5c) return true; - } - return false; - } - - // Two-byte sequences: ESC + single character (0x20-0x7e) - // Includes: ESC =, ESC >, ESC 7, ESC 8, ESC M, etc. - if (second >= 0x20 && second <= 0x7e) return true; - - // Anything else — treat as complete to avoid infinite buffering - return true; -} +export { EscapeBuffer } from '@cluesmith/codev-core/escape-buffer'; diff --git a/packages/types/package.json b/packages/types/package.json index 629ecaed..a7dd0eaa 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -20,5 +20,5 @@ "devDependencies": { "typescript": "^5.7.0" }, - "license": "MIT" + "license": "Apache-2.0" } diff --git a/packages/vscode/LICENSE b/packages/vscode/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/packages/vscode/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/vscode/README.md b/packages/vscode/README.md index c919418f..0ba776b9 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -1,71 +1,75 @@ -# codev README +# Codev for VS Code -This is the README for your extension "codev". After writing up a brief description, we recommend including the following sections. +Bring Codev's Agent Farm into VS Code — monitor builders, open terminals, approve gates, and manage your development workflow without leaving the IDE. ## Features -Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file. - -For example if there is an image subfolder under your extension project workspace: - -\!\[feature X\]\(images/feature-x.png\) - -> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow. +- **Unified Sidebar** — Needs Attention, Builders, Pull Requests, Backlog, Team, and Status in a single pane +- **Native Terminals** — Architect and builder terminals in the editor area with full vertical height +- **Status Bar** — Connection state, builder count, blocked gates at a glance +- **Command Palette** — Open terminals, send messages, approve gates via keyboard +- **Auto-Connect** — Detects Codev workspaces and connects to Tower automatically +- **Auto-Start Tower** — Starts Tower if not running (configurable) ## Requirements -If you have any requirements or dependencies, add a section describing those and how to install and configure them. - -## Extension Settings - -Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. - -For example: - -This extension contributes the following settings: - -* `myExtension.enable`: Enable/disable this extension. -* `myExtension.thing`: Set to `blah` to do something. - -## Known Issues - -Calling out known issues can help limit users opening duplicate issues against your extension. - -## Release Notes - -Users appreciate release notes as you update your extension. - -### 1.0.0 - -Initial release of ... - -### 1.0.1 - -Fixed issue #. - -### 1.1.0 - -Added features X, Y, and Z. - ---- - -## Following extension guidelines - -Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. - -* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) - -## Working with Markdown - -You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts: - -* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux). -* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux). -* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets. - -## For more information - -* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown) -* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/) - -**Enjoy!** +- [Codev CLI](https://github.com/cluesmith/codev) installed (`npm install -g @cluesmith/codev`) +- Tower running (`afx tower start`) or auto-start enabled (default) +- A Codev workspace (`.codev/` or `codev/` directory in your project) + +## Getting Started + +1. Install the extension +2. Open a Codev project in VS Code +3. The extension auto-detects the workspace and connects to Tower +4. Click the Codev icon in the Activity Bar to see your builders, PRs, and backlog + +## Layout + +``` ++------------+----------------+----------------+ +| Codev | Architect | [#42] [#43] | +| (sidebar) | (terminal) | Builder #42 | +| | | (terminal) | +| - Attention| | | +| - Builders | Left editor | Right editor | +| - PRs | group | group | +| - Backlog | | | +| - Team | | | +| - Status | | | ++------------+----------------+----------------+ +``` + +## Commands + +| Command | Shortcut | Description | +|---------|----------|-------------| +| Codev: Open Architect Terminal | `Cmd+K, A` | Open the architect terminal in the left editor group | +| Codev: Send Message | `Cmd+K, D` | Pick a builder, type a message, send via Tower | +| Codev: Approve Gate | `Cmd+K, G` | Approve a blocked builder's gate | +| Codev: Open Builder Terminal | | Pick a builder and open its terminal | +| Codev: New Shell | | Create a new persistent shell terminal | +| Codev: Spawn Builder | | Issue number + protocol + optional branch | +| Codev: Cleanup Builder | | Remove a completed builder's worktree | +| Codev: Refresh Overview | | Manually refresh sidebar data | +| Codev: Connect Tunnel | | Connect cloud tunnel for remote access | +| Codev: Disconnect Tunnel | | Disconnect cloud tunnel | +| Codev: Cron Tasks | | List, run, enable, or disable cron tasks | +| Codev: Add Review Comment | | Insert a `REVIEW(@architect):` comment at cursor | + +## Review Comments + +- **Snippet**: Type `rev` + Tab in markdown files to insert a review comment +- **Command**: `Cmd+Shift+P` → "Codev: Add Review Comment" inserts with correct comment syntax for any file type +- **Highlighting**: Existing `REVIEW(...)` lines are highlighted with a colored background + +## Settings + +| Setting | Default | Description | +|---------|---------|-------------| +| `codev.towerHost` | `localhost` | Tower server host | +| `codev.towerPort` | `4100` | Tower server port | +| `codev.workspacePath` | auto-detect | Override workspace path | +| `codev.terminalPosition` | `editor` | Terminal placement (`editor` or `panel`) | +| `codev.autoConnect` | `true` | Connect to Tower on activation | +| `codev.autoStartTower` | `true` | Auto-start Tower if not running | diff --git a/packages/vscode/esbuild.js b/packages/vscode/esbuild.js index cc2be598..1c6dbbd0 100644 --- a/packages/vscode/esbuild.js +++ b/packages/vscode/esbuild.js @@ -35,7 +35,7 @@ async function main() { sourcesContent: false, platform: 'node', outfile: 'dist/extension.js', - external: ['vscode'], + external: ['vscode', 'bufferutil', 'utf-8-validate'], logLevel: 'silent', plugins: [ /* add to the end of plugins array */ diff --git a/packages/vscode/icons/codev.svg b/packages/vscode/icons/codev.svg new file mode 100644 index 00000000..2ae33621 --- /dev/null +++ b/packages/vscode/icons/codev.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vscode/package.json b/packages/vscode/package.json index f28e3eb6..8c3ea130 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -3,22 +3,163 @@ "publisher": "cluesmith", "displayName": "Codev", "description": "Codev helps humans and agents co-develop both the context and the code of the project.", - "version": "0.0.1", + "version": "0.2.0", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/cluesmith/codev", + "directory": "packages/vscode" + }, "engines": { "vscode": "^1.110.0" }, "categories": [ "Other" ], - "activationEvents": [], + "activationEvents": [ + "workspaceContains:.codev", + "workspaceContains:codev" + ], "main": "./dist/extension.js", "contributes": { "commands": [ { "command": "codev.helloWorld", "title": "Hello World" + }, + { + "command": "codev.openArchitectTerminal", + "title": "Codev: Open Architect Terminal" + }, + { + "command": "codev.openBuilderTerminal", + "title": "Codev: Open Builder Terminal" + }, + { + "command": "codev.newShell", + "title": "Codev: New Shell" + }, + { + "command": "codev.spawnBuilder", + "title": "Codev: Spawn Builder" + }, + { + "command": "codev.sendMessage", + "title": "Codev: Send Message" + }, + { + "command": "codev.approveGate", + "title": "Codev: Approve Gate" + }, + { + "command": "codev.cleanupBuilder", + "title": "Codev: Cleanup Builder" + }, + { + "command": "codev.refreshOverview", + "title": "Codev: Refresh Overview" + }, + { + "command": "codev.connectTunnel", + "title": "Codev: Connect Tunnel" + }, + { + "command": "codev.disconnectTunnel", + "title": "Codev: Disconnect Tunnel" + }, + { + "command": "codev.cronTasks", + "title": "Codev: Cron Tasks" + }, + { + "command": "codev.addReviewComment", + "title": "Codev: Add Review Comment" + } + ], + "snippets": [ + { + "language": "markdown", + "path": "./snippets/review.json" + } + ], + "keybindings": [ + { + "command": "codev.openArchitectTerminal", + "key": "ctrl+k a", + "mac": "cmd+k a" + }, + { + "command": "codev.sendMessage", + "key": "ctrl+k d", + "mac": "cmd+k d" + }, + { + "command": "codev.approveGate", + "key": "ctrl+k g", + "mac": "cmd+k g" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "codev", + "title": "Codev", + "icon": "icons/codev.svg" + } + ] + }, + "views": { + "codev": [ + { "id": "codev.needsAttention", "name": "Needs Attention" }, + { "id": "codev.builders", "name": "Builders" }, + { "id": "codev.pullRequests", "name": "Pull Requests" }, + { "id": "codev.backlog", "name": "Backlog" }, + { "id": "codev.recentlyClosed", "name": "Recently Closed" }, + { "id": "codev.team", "name": "Team", "when": "codev.teamEnabled" }, + { "id": "codev.status", "name": "Status" } + ] + }, + "configuration": { + "title": "Codev", + "properties": { + "codev.towerHost": { + "type": "string", + "default": "localhost", + "description": "Tower server host" + }, + "codev.towerPort": { + "type": "number", + "default": 4100, + "description": "Tower server port" + }, + "codev.workspacePath": { + "type": "string", + "default": "", + "description": "Override workspace path for Tower matching (auto-detected if empty)" + }, + "codev.terminalPosition": { + "type": "string", + "enum": ["editor", "panel"], + "default": "editor", + "description": "Where to open Codev terminals (editor area or bottom panel)" + }, + "codev.autoConnect": { + "type": "boolean", + "default": true, + "description": "Connect to Tower on activation" + }, + "codev.autoStartTower": { + "type": "boolean", + "default": true, + "description": "Auto-start Tower if not running on activation" + }, + "codev.telemetry": { + "type": "boolean", + "default": false, + "description": "No telemetry collected" + } } - ] + } }, "scripts": { "vscode:prepublish": "pnpm package", @@ -35,12 +176,15 @@ "test": "vscode-test" }, "dependencies": { - "@cluesmith/codev-types": "workspace:*" + "@cluesmith/codev-core": "workspace:*", + "@cluesmith/codev-types": "workspace:*", + "ws": "^8.18.0" }, "devDependencies": { "@types/vscode": "^1.110.0", "@types/mocha": "^10.0.10", "@types/node": "22.x", + "@types/ws": "^8.18.1", "typescript-eslint": "^8.48.1", "eslint": "^9.39.1", "esbuild": "^0.27.1", diff --git a/packages/vscode/snippets/review.json b/packages/vscode/snippets/review.json new file mode 100644 index 00000000..243663ad --- /dev/null +++ b/packages/vscode/snippets/review.json @@ -0,0 +1,7 @@ +{ + "Review Comment": { + "prefix": "rev", + "body": "REVIEW(@${1:architect}): ${2:comment}", + "description": "Insert a Codev review comment" + } +} diff --git a/packages/vscode/src/auth-wrapper.ts b/packages/vscode/src/auth-wrapper.ts new file mode 100644 index 00000000..d4bfdd24 --- /dev/null +++ b/packages/vscode/src/auth-wrapper.ts @@ -0,0 +1,76 @@ +import * as vscode from 'vscode'; +import { readLocalKey } from '@cluesmith/codev-core/auth'; + +const SECRET_KEY = 'codev-local-key'; + +/** + * Wraps the shared readLocalKey() with VS Code SecretStorage caching. + * Re-reads from disk on 401 to handle key rotation. + */ +export class AuthWrapper { + private secretStorage: vscode.SecretStorage; + private cachedKey: string | null = null; + + constructor(secretStorage: vscode.SecretStorage) { + this.secretStorage = secretStorage; + } + + /** + * Get the auth key. Returns from cache, SecretStorage, or disk (in that order). + */ + async getKey(): Promise { + if (this.cachedKey) { + return this.cachedKey; + } + + const stored = await this.secretStorage.get(SECRET_KEY); + if (stored) { + this.cachedKey = stored; + return stored; + } + + return this.readFromDisk(); + } + + /** + * Force re-read from disk. Called on 401 to handle key rotation. + */ + async refreshKey(): Promise { + this.cachedKey = null; + await this.secretStorage.delete(SECRET_KEY); + return this.readFromDisk(); + } + + /** + * Get auth key synchronously for TowerClient's getAuthKey callback. + * Returns cached value or reads from disk. SecretStorage is async + * so we prime the cache during initialization. + */ + getKeySync(): string | null { + if (this.cachedKey) { + return this.cachedKey; + } + // Fallback to direct disk read if cache not primed + const key = readLocalKey(); + if (key) { + this.cachedKey = key; + } + return key; + } + + /** + * Prime the cache from SecretStorage or disk. Call during initialization. + */ + async initialize(): Promise { + await this.getKey(); + } + + private async readFromDisk(): Promise { + const key = readLocalKey(); + if (key) { + this.cachedKey = key; + await this.secretStorage.store(SECRET_KEY, key); + } + return key; + } +} diff --git a/packages/vscode/src/commands/approve.ts b/packages/vscode/src/commands/approve.ts new file mode 100644 index 00000000..dc4c1562 --- /dev/null +++ b/packages/vscode/src/commands/approve.ts @@ -0,0 +1,40 @@ +import * as vscode from 'vscode'; +import { spawn } from 'node:child_process'; +import type { ConnectionManager } from '../connection-manager.js'; + +/** + * Codev: Approve Gate — show blocked builders, pick one, approve via porch CLI. + */ +export async function approveGate(connectionManager: ConnectionManager): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const overview = await client.getOverview(workspacePath); + const blocked = overview?.builders?.filter(b => b.blocked) ?? []; + if (blocked.length === 0) { + vscode.window.showInformationMessage('Codev: No blocked builders'); + return; + } + + const picked = await vscode.window.showQuickPick( + blocked.map(b => ({ + label: `#${b.issueId ?? b.id} ${b.issueTitle ?? ''}`, + description: `blocked on ${b.blocked}`, + id: b.id, + gate: b.blocked!, + })), + { placeHolder: 'Select gate to approve' }, + ); + if (!picked) { return; } + + const child = spawn('porch', ['approve', picked.id, picked.gate, '--a-human-explicitly-approved-this'], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + vscode.window.showInformationMessage(`Codev: Approving ${picked.gate} for #${picked.id}`); +} diff --git a/packages/vscode/src/commands/cleanup.ts b/packages/vscode/src/commands/cleanup.ts new file mode 100644 index 00000000..2f697e14 --- /dev/null +++ b/packages/vscode/src/commands/cleanup.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; +import { spawn } from 'node:child_process'; +import type { ConnectionManager } from '../connection-manager.js'; + +/** + * Codev: Cleanup Builder — pick builder, run afx cleanup. + */ +export async function cleanupBuilder(connectionManager: ConnectionManager): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const overview = await client.getOverview(workspacePath); + const builders = overview?.builders ?? []; + if (builders.length === 0) { + vscode.window.showInformationMessage('Codev: No builders to clean up'); + return; + } + + const picked = await vscode.window.showQuickPick( + builders.map(b => ({ + label: `#${b.issueId ?? b.id} ${b.issueTitle ?? ''}`, + description: b.phase, + id: b.id, + })), + { placeHolder: 'Select builder to clean up' }, + ); + if (!picked) { return; } + + const child = spawn('afx', ['cleanup', '-p', picked.id], { detached: true, stdio: 'ignore' }); + child.unref(); + vscode.window.showInformationMessage(`Codev: Cleaning up builder #${picked.id}`); +} diff --git a/packages/vscode/src/commands/cron.ts b/packages/vscode/src/commands/cron.ts new file mode 100644 index 00000000..29efbc26 --- /dev/null +++ b/packages/vscode/src/commands/cron.ts @@ -0,0 +1,38 @@ +import * as vscode from 'vscode'; +import type { ConnectionManager } from '../connection-manager.js'; + +interface CronTask { name: string; enabled: boolean } + +export async function listCronTasks(connectionManager: ConnectionManager): Promise { + const client = connectionManager.getClient(); + if (!client || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const result = await client.request<{ tasks: CronTask[] }>('/api/cron/tasks'); + if (!result.ok || !result.data?.tasks) { + vscode.window.showWarningMessage('Codev: No cron tasks found'); + return; + } + + const picked = await vscode.window.showQuickPick( + result.data.tasks.map(t => ({ + label: t.name, + description: t.enabled ? 'enabled' : 'disabled', + name: t.name, + })), + { placeHolder: 'Cron tasks' }, + ); + if (!picked) { return; } + + const action = await vscode.window.showQuickPick( + ['Run now', 'Enable', 'Disable'], + { placeHolder: `Action for ${picked.name}` }, + ); + if (!action) { return; } + + const endpoint = action === 'Run now' ? 'run' : action.toLowerCase(); + await client.request(`/api/cron/tasks/${picked.name}/${endpoint}`, { method: 'POST' }); + vscode.window.showInformationMessage(`Codev: ${action} — ${picked.name}`); +} diff --git a/packages/vscode/src/commands/review.ts b/packages/vscode/src/commands/review.ts new file mode 100644 index 00000000..a7bdcd22 --- /dev/null +++ b/packages/vscode/src/commands/review.ts @@ -0,0 +1,78 @@ +import * as vscode from 'vscode'; + +/** + * Codev: Add Review Comment — inserts a REVIEW comment at the cursor + * using the correct comment syntax for the file type. + */ +export async function addReviewComment(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage('Codev: No active editor'); + return; + } + + const syntax = getCommentSyntax(editor.document.languageId); + if (!syntax) { + vscode.window.showWarningMessage(`Codev: Cannot add review comment to ${editor.document.languageId} files`); + return; + } + + const line = editor.selection.active.line; + const indent = editor.document.lineAt(line).text.match(/^\s*/)?.[0] ?? ''; + const comment = syntax.wrap('REVIEW(@architect): '); + + await editor.edit(editBuilder => { + editBuilder.insert(new vscode.Position(line + 1, 0), `${indent}${comment}\n`); + }); + + // Move cursor into the comment + const newPos = new vscode.Position(line + 1, indent.length + comment.length - syntax.cursorOffset); + editor.selection = new vscode.Selection(newPos, newPos); +} + +interface CommentSyntax { + wrap: (text: string) => string; + cursorOffset: number; +} + +function getCommentSyntax(languageId: string): CommentSyntax | null { + switch (languageId) { + case 'javascript': + case 'typescript': + case 'javascriptreact': + case 'typescriptreact': + case 'go': + case 'rust': + case 'java': + case 'swift': + case 'kotlin': + case 'c': + case 'cpp': + case 'csharp': + case 'dart': + case 'scala': + return { wrap: (t) => `// ${t}`, cursorOffset: 0 }; + + case 'python': + case 'ruby': + case 'shellscript': + case 'bash': + case 'yaml': + case 'toml': + case 'perl': + return { wrap: (t) => `# ${t}`, cursorOffset: 0 }; + + case 'html': + case 'markdown': + case 'xml': + return { wrap: (t) => ``, cursorOffset: 4 }; + + case 'css': + case 'scss': + case 'less': + return { wrap: (t) => `/* ${t} */`, cursorOffset: 3 }; + + default: + return null; + } +} diff --git a/packages/vscode/src/commands/send.ts b/packages/vscode/src/commands/send.ts new file mode 100644 index 00000000..850ddb24 --- /dev/null +++ b/packages/vscode/src/commands/send.ts @@ -0,0 +1,40 @@ +import * as vscode from 'vscode'; +import type { ConnectionManager } from '../connection-manager.js'; + +/** + * Codev: Send Message — pick builder, type message, send via TowerClient. + */ +export async function sendMessage(connectionManager: ConnectionManager): Promise { + const client = connectionManager.getClient(); + const workspacePath = connectionManager.getWorkspacePath(); + if (!client || !workspacePath || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + + const state = await client.getWorkspaceState(workspacePath); + const builders = state?.builders?.filter(b => b.terminalId) ?? []; + if (builders.length === 0) { + vscode.window.showWarningMessage('Codev: No active builders'); + return; + } + + const picked = await vscode.window.showQuickPick( + builders.map(b => ({ label: b.name, id: b.id })), + { placeHolder: 'Select builder to send message to' }, + ); + if (!picked) { return; } + + const message = await vscode.window.showInputBox({ + prompt: `Message to ${picked.label}`, + placeHolder: 'Type your message...', + }); + if (!message) { return; } + + const result = await client.sendMessage(picked.id, message, { workspace: workspacePath }); + if (result.ok) { + vscode.window.showInformationMessage(`Codev: Message sent to ${picked.label}`); + } else { + vscode.window.showErrorMessage(`Codev: Failed to send — ${result.error}`); + } +} diff --git a/packages/vscode/src/commands/spawn.ts b/packages/vscode/src/commands/spawn.ts new file mode 100644 index 00000000..46115af9 --- /dev/null +++ b/packages/vscode/src/commands/spawn.ts @@ -0,0 +1,37 @@ +import * as vscode from 'vscode'; +import { spawn } from 'node:child_process'; + +/** + * Codev: Spawn Builder — quick-pick flow for issue + protocol + optional branch. + */ +export async function spawnBuilder(): Promise { + const issueNumber = await vscode.window.showInputBox({ + prompt: 'Issue number', + placeHolder: '42', + }); + if (!issueNumber) { return; } + + const protocol = await vscode.window.showQuickPick( + ['spir', 'aspir', 'air', 'bugfix', 'tick'], + { placeHolder: 'Select protocol' }, + ); + if (!protocol) { return; } + + const branch = await vscode.window.showInputBox({ + prompt: 'Branch name (optional — leave empty for new branch)', + placeHolder: 'feature/my-branch', + }); + + const args = ['spawn', issueNumber, '--protocol', protocol]; + if (branch) { + args.push('--branch', branch); + } + + runAfxCommand(args); +} + +function runAfxCommand(args: string[]): void { + const child = spawn('afx', args, { detached: true, stdio: 'ignore' }); + child.unref(); + vscode.window.showInformationMessage(`Codev: Running afx ${args.join(' ')}`); +} diff --git a/packages/vscode/src/commands/tunnel.ts b/packages/vscode/src/commands/tunnel.ts new file mode 100644 index 00000000..46907e39 --- /dev/null +++ b/packages/vscode/src/commands/tunnel.ts @@ -0,0 +1,22 @@ +import * as vscode from 'vscode'; +import type { ConnectionManager } from '../connection-manager.js'; + +export async function connectTunnel(connectionManager: ConnectionManager): Promise { + const client = connectionManager.getClient(); + if (!client || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + await client.signalTunnel('connect'); + vscode.window.showInformationMessage('Codev: Tunnel connecting...'); +} + +export async function disconnectTunnel(connectionManager: ConnectionManager): Promise { + const client = connectionManager.getClient(); + if (!client || connectionManager.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + await client.signalTunnel('disconnect'); + vscode.window.showInformationMessage('Codev: Tunnel disconnected'); +} diff --git a/packages/vscode/src/connection-manager.ts b/packages/vscode/src/connection-manager.ts new file mode 100644 index 00000000..e3c905d2 --- /dev/null +++ b/packages/vscode/src/connection-manager.ts @@ -0,0 +1,215 @@ +import * as vscode from 'vscode'; +import { TowerClient } from '@cluesmith/codev-core/tower-client'; +import { AuthWrapper } from './auth-wrapper.js'; +import { detectWorkspacePath, getTowerAddress } from './workspace-detector.js'; +import { SSEClient } from './sse-client.js'; +import { autoStartTower } from './tower-starter.js'; + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting'; + +/** + * Singleton managing Tower communication from the VS Code extension. + * + * Wraps TowerClient from @cluesmith/codev-core with VS Code-specific + * concerns: state machine, Output Channel, SecretStorage auth, settings. + */ +export class ConnectionManager { + private state: ConnectionState = 'disconnected'; + private client: TowerClient | null = null; + private auth: AuthWrapper; + private outputChannel: vscode.OutputChannel; + private workspacePath: string | null = null; + private sse: SSEClient | null = null; + private reconnectTimer: ReturnType | null = null; + private reconnectAttempt = 0; + private readonly maxReconnectDelay = 30000; + private disposed = false; + + private readonly stateChangeEmitter = new vscode.EventEmitter(); + readonly onStateChange = this.stateChangeEmitter.event; + + private readonly sseEventEmitter = new vscode.EventEmitter<{ type: string; data: string }>(); + readonly onSSEEvent = this.sseEventEmitter.event; + + constructor( + private context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, + ) { + this.auth = new AuthWrapper(context.secrets); + this.outputChannel = outputChannel; + } + + getState(): ConnectionState { + return this.state; + } + + getWorkspacePath(): string | null { + return this.workspacePath; + } + + getClient(): TowerClient | null { + return this.client; + } + + /** + * Initialize: detect workspace, create TowerClient, attempt connection. + */ + async initialize(): Promise { + // Prime auth cache + await this.auth.initialize(); + + // Detect workspace + this.workspacePath = detectWorkspacePath(); + if (this.workspacePath) { + this.log('INFO', `Workspace detected: ${this.workspacePath}`); + } else { + this.log('WARN', 'No Codev workspace detected'); + } + + // Create TowerClient with VS Code settings and auth + const { host, port } = getTowerAddress(); + this.client = new TowerClient({ + host, + port, + getAuthKey: () => this.auth.getKeySync(), + }); + + // Connect if autoConnect is enabled + const config = vscode.workspace.getConfiguration('codev'); + const autoConnect = config.get('autoConnect', true); + if (autoConnect) { + // Try connecting; if Tower isn't running, try auto-start + await this.connect(); + if (this.state === 'disconnected' && config.get('autoStartTower', true)) { + this.log('INFO', 'Tower not running, attempting auto-start...'); + const started = await autoStartTower(this.client!, this.workspacePath, this.outputChannel); + if (started) { + await this.connect(); + } + } + } + } + + /** + * Attempt to connect to Tower. + */ + async connect(): Promise { + if (this.disposed || !this.client) { return; } + this.setState('connecting'); + + try { + const health = await this.client.getHealth(); + if (health) { + this.setState('connected'); + this.reconnectAttempt = 0; + this.log('INFO', `Connected to Tower (status: ${health.status}, uptime: ${health.uptime}s)`); + await this.activateWorkspace(); + this.startSSE(); + } else { + this.log('WARN', 'Tower not responding'); + this.setState('disconnected'); + } + } catch (err) { + this.log('ERROR', `Connection failed: ${(err as Error).message}`); + this.setState('disconnected'); + } + } + + /** + * Schedule reconnection with exponential backoff. + */ + scheduleReconnect(): void { + if (this.disposed || this.state === 'connected') { return; } + + this.setState('reconnecting'); + const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), this.maxReconnectDelay); + this.reconnectAttempt++; + + this.log('INFO', `Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`); + this.reconnectTimer = setTimeout(() => this.connect(), delay); + } + + /** + * Handle a 401 response — refresh auth key and retry. + */ + async handleAuthFailure(): Promise { + this.log('WARN', 'Auth failed (401), re-reading key from disk'); + const newKey = await this.auth.refreshKey(); + if (newKey) { + this.log('INFO', 'Auth key refreshed'); + } else { + this.log('ERROR', 'No auth key found — check ~/.agent-farm/local-key'); + } + } + + // ── Workspace Activation ────────────────────────────────────── + + private async activateWorkspace(): Promise { + if (!this.client || !this.workspacePath) { return; } + try { + const result = await this.client.activateWorkspace(this.workspacePath); + if (result.ok) { + this.log('INFO', `Workspace activated: ${this.workspacePath}`); + } else { + this.log('WARN', `Workspace activation failed: ${result.error}`); + } + } catch (err) { + this.log('ERROR', `Workspace activation error: ${(err as Error).message}`); + } + } + + // ── SSE ─────────────────────────────────────────────────────── + + private startSSE(): void { + this.stopSSE(); + const { host, port } = getTowerAddress(); + this.sse = new SSEClient( + `http://${host}:${port}`, + this.outputChannel, + () => { + // SSE disconnected — trigger reconnection + if (!this.disposed && this.state === 'connected') { + this.log('WARN', 'SSE connection lost'); + this.setState('disconnected'); + this.scheduleReconnect(); + } + }, + ); + this.sse.onEvent((type, data) => { + this.sseEventEmitter.fire({ type, data }); + }); + this.sse.connect(); + } + + private stopSSE(): void { + if (this.sse) { + this.sse.dispose(); + this.sse = null; + } + } + + // ── Lifecycle ──────────────────────────────────────────────── + + private setState(newState: ConnectionState): void { + if (this.state !== newState) { + this.state = newState; + this.stateChangeEmitter.fire(newState); + } + } + + private log(level: string, message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine(`[${timestamp}] [${level}] ${message}`); + } + + dispose(): void { + this.disposed = true; + this.stopSSE(); + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + } + this.stateChangeEmitter.dispose(); + this.sseEventEmitter.dispose(); + this.log('INFO', 'Connection Manager disposed'); + } +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 321e8a5e..905b40dc 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,26 +1,200 @@ -// The module 'vscode' contains the VS Code extensibility API -// Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; -import { FRAME_CONTROL, FRAME_DATA, type OverviewData } from '@cluesmith/codev-types'; - -// This method is called when your extension is activated -// Your extension is activated the very first time the command is executed -export function activate(context: vscode.ExtensionContext) { - - // Verify shared types are importable - console.log(`Codev extension active — protocol frames: control=0x${FRAME_CONTROL.toString(16)}, data=0x${FRAME_DATA.toString(16)}`); - - // The command has been defined in the package.json file - // Now provide the implementation of the command with registerCommand - // The commandId parameter must match the command field in package.json - const disposable = vscode.commands.registerCommand('codev.helloWorld', () => { - // The code you place here will be executed every time your command is executed - // Display a message box to the user - vscode.window.showInformationMessage('Hello World from codev!'); +import { ConnectionManager } from './connection-manager.js'; +import { TerminalManager } from './terminal-manager.js'; +import { OverviewCache } from './views/overview-data.js'; +import { spawnBuilder } from './commands/spawn.js'; +import { sendMessage } from './commands/send.js'; +import { approveGate } from './commands/approve.js'; +import { cleanupBuilder } from './commands/cleanup.js'; +import { connectTunnel, disconnectTunnel } from './commands/tunnel.js'; +import { listCronTasks } from './commands/cron.js'; +import { addReviewComment } from './commands/review.js'; +import { activateReviewDecorations } from './review-decorations.js'; +import { NeedsAttentionProvider } from './views/needs-attention.js'; +import { BuildersProvider } from './views/builders.js'; +import { PullRequestsProvider } from './views/pull-requests.js'; +import { BacklogProvider } from './views/backlog.js'; +import { RecentlyClosedProvider } from './views/recently-closed.js'; +import { TeamProvider } from './views/team.js'; +import { StatusProvider } from './views/status.js'; + +let connectionManager: ConnectionManager | null = null; +let terminalManager: TerminalManager | null = null; +let outputChannel: vscode.OutputChannel | null = null; +let statusBarItem: vscode.StatusBarItem | null = null; + +export async function activate(context: vscode.ExtensionContext) { + // Output Channel for diagnostics + outputChannel = vscode.window.createOutputChannel('Codev'); + context.subscriptions.push(outputChannel); + + // Connection Manager + connectionManager = new ConnectionManager(context, outputChannel); + context.subscriptions.push({ dispose: () => connectionManager?.dispose() }); + + // Status bar + statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); + statusBarItem.text = '$(circle-slash) Codev: Offline'; + statusBarItem.show(); + context.subscriptions.push(statusBarItem); + + connectionManager.onStateChange((state) => { + if (!statusBarItem) { return; } + switch (state) { + case 'connected': + statusBarItem.text = '$(server) Codev: Connected'; + statusBarItem.color = undefined; + break; + case 'connecting': + statusBarItem.text = '$(sync~spin) Codev: Connecting...'; + statusBarItem.color = undefined; + break; + case 'reconnecting': + statusBarItem.text = '$(sync~spin) Codev: Reconnecting...'; + statusBarItem.color = new vscode.ThemeColor('statusBarItem.warningForeground'); + break; + case 'disconnected': + statusBarItem.text = '$(circle-slash) Codev: Offline'; + statusBarItem.color = new vscode.ThemeColor('statusBarItem.errorForeground'); + break; + } + }); + + // Terminal Manager + terminalManager = new TerminalManager(connectionManager, outputChannel); + context.subscriptions.push({ dispose: () => terminalManager?.dispose() }); + + // Update status bar with builder/gate counts + const updateStatusBarCounts = () => { + if (!statusBarItem || connectionManager?.getState() !== 'connected') { return; } + const data = overviewCache.getData(); + if (!data) { return; } + const builderCount = data.builders.length; + const blockedCount = data.builders.filter(b => b.blocked).length; + statusBarItem.text = blockedCount > 0 + ? `$(server) Codev: ${builderCount} builders · $(bell) ${blockedCount} blocked` + : `$(server) Codev: ${builderCount} builders`; + }; + + // Sidebar TreeViews + const overviewCache = new OverviewCache(connectionManager); + context.subscriptions.push({ dispose: () => overviewCache.dispose() }); + overviewCache.onDidChange(updateStatusBarCounts); + + context.subscriptions.push( + vscode.window.registerTreeDataProvider('codev.needsAttention', new NeedsAttentionProvider(overviewCache)), + vscode.window.registerTreeDataProvider('codev.builders', new BuildersProvider(overviewCache)), + vscode.window.registerTreeDataProvider('codev.pullRequests', new PullRequestsProvider(overviewCache)), + vscode.window.registerTreeDataProvider('codev.backlog', new BacklogProvider(overviewCache)), + vscode.window.registerTreeDataProvider('codev.recentlyClosed', new RecentlyClosedProvider(overviewCache)), + vscode.window.registerTreeDataProvider('codev.team', new TeamProvider(connectionManager)), + vscode.window.registerTreeDataProvider('codev.status', new StatusProvider(connectionManager)), + ); + + // Refresh overview on connect + set team visibility + connectionManager.onStateChange(async (state) => { + if (state === 'connected') { + overviewCache.refresh(); + // Check if team is enabled for this workspace + const client = connectionManager?.getClient(); + const workspacePath = connectionManager?.getWorkspacePath(); + if (client && workspacePath) { + const wsState = await client.getWorkspaceState(workspacePath); + vscode.commands.executeCommand('setContext', 'codev.teamEnabled', wsState?.teamEnabled ?? false); + } + } }); - context.subscriptions.push(disposable); + // Commands + context.subscriptions.push( + vscode.commands.registerCommand('codev.helloWorld', () => { + const state = connectionManager?.getState() ?? 'unknown'; + const workspace = connectionManager?.getWorkspacePath() ?? 'none'; + vscode.window.showInformationMessage(`Codev: ${state} | Workspace: ${workspace}`); + }), + vscode.commands.registerCommand('codev.openArchitectTerminal', async () => { + const client = connectionManager?.getClient(); + const workspacePath = connectionManager?.getWorkspacePath(); + if (!client || !workspacePath || connectionManager?.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + try { + const state = await client.getWorkspaceState(workspacePath); + if (state?.architect?.terminalId) { + await terminalManager?.openArchitect(state.architect.terminalId); + } else { + vscode.window.showWarningMessage('Codev: No architect terminal found — is the workspace activated?'); + } + } catch { + vscode.window.showErrorMessage('Codev: Failed to get workspace state'); + } + }), + vscode.commands.registerCommand('codev.openBuilderTerminal', async () => { + const client = connectionManager?.getClient(); + const workspacePath = connectionManager?.getWorkspacePath(); + if (!client || !workspacePath || connectionManager?.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + try { + const state = await client.getWorkspaceState(workspacePath); + const builders = state?.builders?.filter(b => b.terminalId) ?? []; + if (builders.length === 0) { + vscode.window.showWarningMessage('Codev: No builder terminals available'); + return; + } + const picked = await vscode.window.showQuickPick( + builders.map(b => ({ label: b.name, id: b.id, terminalId: b.terminalId! })), + { placeHolder: 'Select a builder' }, + ); + if (picked) { + await terminalManager?.openBuilder(picked.terminalId, picked.id, `Codev: ${picked.label}`); + } + } catch { + vscode.window.showErrorMessage('Codev: Failed to get builders'); + } + }), + vscode.commands.registerCommand('codev.newShell', async () => { + const client = connectionManager?.getClient(); + const workspacePath = connectionManager?.getWorkspacePath(); + if (!client || !workspacePath || connectionManager?.getState() !== 'connected') { + vscode.window.showErrorMessage('Codev: Not connected to Tower'); + return; + } + try { + const result = await client.createShellTab(workspacePath); + if (result?.terminalId) { + const shellNum = (terminalManager?.getTerminalCount() ?? 0) + 1; + await terminalManager?.openShell(result.terminalId, shellNum); + } + } catch { + vscode.window.showErrorMessage('Codev: Failed to create shell'); + } + }), + vscode.commands.registerCommand('codev.spawnBuilder', () => spawnBuilder()), + vscode.commands.registerCommand('codev.sendMessage', () => sendMessage(connectionManager!)), + vscode.commands.registerCommand('codev.approveGate', () => approveGate(connectionManager!)), + vscode.commands.registerCommand('codev.cleanupBuilder', () => cleanupBuilder(connectionManager!)), + vscode.commands.registerCommand('codev.refreshOverview', () => overviewCache.refresh()), + vscode.commands.registerCommand('codev.connectTunnel', () => connectTunnel(connectionManager!)), + vscode.commands.registerCommand('codev.disconnectTunnel', () => disconnectTunnel(connectionManager!)), + vscode.commands.registerCommand('codev.cronTasks', () => listCronTasks(connectionManager!)), + vscode.commands.registerCommand('codev.addReviewComment', () => addReviewComment()), + ); + + // Review comment decorations + activateReviewDecorations(context); + + // Connect + await connectionManager.initialize(); } -// This method is called when your extension is deactivated -export function deactivate() {} +export function deactivate() { + terminalManager?.dispose(); + terminalManager = null; + connectionManager?.dispose(); + connectionManager = null; + outputChannel = null; + statusBarItem = null; +} diff --git a/packages/vscode/src/review-decorations.ts b/packages/vscode/src/review-decorations.ts new file mode 100644 index 00000000..b42a891d --- /dev/null +++ b/packages/vscode/src/review-decorations.ts @@ -0,0 +1,53 @@ +import * as vscode from 'vscode'; + +const REVIEW_PATTERN = /\bREVIEW\s*\([^)]*\)\s*:\s*.*/g; + +const decorationType = vscode.window.createTextEditorDecorationType({ + backgroundColor: new vscode.ThemeColor('diffEditor.insertedTextBackground'), + gutterIconPath: new vscode.ThemeIcon('comment').id ? undefined : undefined, + gutterIconSize: 'contain', + isWholeLine: true, + overviewRulerColor: new vscode.ThemeColor('editorOverviewRuler.infoForeground'), + overviewRulerLane: vscode.OverviewRulerLane.Right, +}); + +/** + * Highlights REVIEW(...) comment lines with a colored background. + */ +export function activateReviewDecorations(context: vscode.ExtensionContext): void { + // Decorate on open and change + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(editor => { + if (editor) { updateDecorations(editor); } + }), + vscode.workspace.onDidChangeTextDocument(event => { + const editor = vscode.window.activeTextEditor; + if (editor && event.document === editor.document) { + updateDecorations(editor); + } + }), + ); + + // Decorate current editor + if (vscode.window.activeTextEditor) { + updateDecorations(vscode.window.activeTextEditor); + } +} + +function updateDecorations(editor: vscode.TextEditor): void { + const text = editor.document.getText(); + const ranges: vscode.DecorationOptions[] = []; + + let match: RegExpExecArray | null; + REVIEW_PATTERN.lastIndex = 0; + while ((match = REVIEW_PATTERN.exec(text)) !== null) { + const startPos = editor.document.positionAt(match.index); + const endPos = editor.document.positionAt(match.index + match[0].length); + ranges.push({ + range: new vscode.Range(startPos, endPos), + hoverMessage: 'Codev review comment', + }); + } + + editor.setDecorations(decorationType, ranges); +} diff --git a/packages/vscode/src/sse-client.ts b/packages/vscode/src/sse-client.ts new file mode 100644 index 00000000..a1906d87 --- /dev/null +++ b/packages/vscode/src/sse-client.ts @@ -0,0 +1,176 @@ +import * as vscode from 'vscode'; + +export type SSEListener = (eventType: string, data: string) => void; + +/** + * SSE client for Tower's /api/events endpoint. + * + * Handles heartbeat filtering, rate-limited refresh dispatch, + * and reconnection via the Connection Manager's state machine. + */ +export class SSEClient { + private eventSource: EventSource | null = null; + private listeners: SSEListener[] = []; + private lastRefreshAt = 0; + private pendingRefresh: ReturnType | null = null; + private readonly minRefreshInterval = 1000; // max 1 refresh/second + private disposed = false; + + constructor( + private baseUrl: string, + private outputChannel: vscode.OutputChannel, + private onDisconnect: () => void, + ) {} + + /** + * Connect to Tower SSE endpoint. + */ + connect(): void { + if (this.disposed) { return; } + this.disconnect(); + + const url = `${this.baseUrl}/api/events`; + this.log('INFO', `SSE connecting to ${url}`); + + // Use fetch-based SSE since Node.js EventSource may not be available + this.startSSE(url); + } + + /** + * Register a listener for SSE events. + */ + onEvent(listener: SSEListener): vscode.Disposable { + this.listeners.push(listener); + return new vscode.Disposable(() => { + const idx = this.listeners.indexOf(listener); + if (idx >= 0) { this.listeners.splice(idx, 1); } + }); + } + + disconnect(): void { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + if (this.pendingRefresh) { + clearTimeout(this.pendingRefresh); + this.pendingRefresh = null; + } + } + + dispose(): void { + this.disposed = true; + this.disconnect(); + this.listeners = []; + } + + private async startSSE(url: string): Promise { + try { + const response = await fetch(url, { + headers: { 'Accept': 'text/event-stream' }, + }); + + if (!response.ok || !response.body) { + this.log('WARN', `SSE connection failed: ${response.status}`); + this.onDisconnect(); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const read = async (): Promise => { + if (this.disposed) { return; } + + try { + const { done, value } = await reader.read(); + if (done) { + this.log('INFO', 'SSE stream ended'); + this.onDisconnect(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE events from buffer + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; // Keep incomplete last line + + let currentEvent = ''; + let currentData = ''; + + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + currentData = line.slice(5).trim(); + } else if (line === '') { + // Empty line = event boundary + if (currentEvent || currentData) { + this.handleEvent(currentEvent, currentData); + currentEvent = ''; + currentData = ''; + } + } + } + + // Continue reading + await read(); + } catch (err) { + if (!this.disposed) { + this.log('ERROR', `SSE read error: ${(err as Error).message}`); + this.onDisconnect(); + } + } + }; + + await read(); + } catch (err) { + if (!this.disposed) { + this.log('ERROR', `SSE connection error: ${(err as Error).message}`); + this.onDisconnect(); + } + } + } + + private handleEvent(eventType: string, data: string): void { + // Filter heartbeats — don't trigger refresh + if (eventType === 'heartbeat' || eventType === 'ping') { + return; + } + + // Rate-limit refresh dispatch + const now = Date.now(); + const elapsed = now - this.lastRefreshAt; + + if (elapsed >= this.minRefreshInterval) { + this.lastRefreshAt = now; + this.dispatch(eventType, data); + } else if (!this.pendingRefresh) { + // Schedule a deferred refresh + const delay = this.minRefreshInterval - elapsed; + this.pendingRefresh = setTimeout(() => { + this.pendingRefresh = null; + this.lastRefreshAt = Date.now(); + this.dispatch(eventType, data); + }, delay); + } + // If a refresh is already pending, skip — the pending one covers it + } + + private dispatch(eventType: string, data: string): void { + for (const listener of this.listeners) { + try { + listener(eventType, data); + } catch (err) { + this.log('ERROR', `SSE listener error: ${(err as Error).message}`); + } + } + } + + private log(level: string, message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine(`[${timestamp}] [SSE] [${level}] ${message}`); + } +} diff --git a/packages/vscode/src/terminal-adapter.ts b/packages/vscode/src/terminal-adapter.ts new file mode 100644 index 00000000..c4e8db59 --- /dev/null +++ b/packages/vscode/src/terminal-adapter.ts @@ -0,0 +1,212 @@ +import * as vscode from 'vscode'; +import WebSocket from 'ws'; +import { FRAME_CONTROL, FRAME_DATA, type ControlMessage } from '@cluesmith/codev-types'; +import { EscapeBuffer } from '@cluesmith/codev-core/escape-buffer'; + +const CHUNK_SIZE = 16384; // 16KB — chunk onDidWrite to avoid CPU spikes +const MAX_QUEUE = 1048576; // 1MB — disconnect if queue exceeds this + +/** + * VS Code Pseudoterminal backed by a Tower WebSocket connection. + * + * Translates between Tower's binary protocol (0x00/0x01 framing) + * and VS Code's string-based Pseudoterminal API. + */ +export class CodevPseudoterminal implements vscode.Pseudoterminal { + private readonly writeEmitter = new vscode.EventEmitter(); + private readonly closeEmitter = new vscode.EventEmitter(); + readonly onDidWrite = this.writeEmitter.event; + readonly onDidClose = this.closeEmitter.event; + + private ws: WebSocket | null = null; + private decoder = new TextDecoder('utf-8', { fatal: false }); + private encoder = new TextEncoder(); + private escapeBuffer = new EscapeBuffer(); + private lastSeq = 0; + private replaying = false; + private pendingResize: { cols: number; rows: number } | null = null; + private queuedBytes = 0; + private disposed = false; + + constructor( + private wsUrl: string, + private authKey: string | null, + private outputChannel: vscode.OutputChannel, + ) {} + + open(_initialDimensions: vscode.TerminalDimensions | undefined): void { + this.connect(); + } + + close(): void { + this.disposed = true; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.writeEmitter.dispose(); + this.closeEmitter.dispose(); + } + + handleInput(data: string): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return; } + + const encoded = this.encoder.encode(data); + const frame = new Uint8Array(1 + encoded.length); + frame[0] = FRAME_DATA; + frame.set(encoded, 1); + this.ws.send(frame); + } + + setDimensions(dimensions: vscode.TerminalDimensions): void { + if (this.replaying) { + // Defer resize during replay to prevent garbled rendering (Bugfix #625) + this.pendingResize = { cols: dimensions.columns, rows: dimensions.rows }; + return; + } + this.sendResize(dimensions.columns, dimensions.rows); + } + + // ── WebSocket ──────────────────────────────────────────────── + + private connect(): void { + if (this.disposed) { return; } + + this.log('INFO', `Connecting to ${this.wsUrl}`); + this.ws = new WebSocket(this.wsUrl); + this.ws.binaryType = 'arraybuffer'; + + this.ws.on('open', () => { + this.log('INFO', 'WebSocket connected'); + // Send auth via control message (not query param) + if (this.authKey) { + this.sendControl({ type: 'ping', payload: { auth: this.authKey } }); + } + }); + + this.ws.on('message', (raw: ArrayBuffer) => { + const data = Buffer.from(raw); + if (data.length === 0) { return; } + + const prefix = data[0]; + if (prefix === FRAME_DATA) { + this.handleData(data.subarray(1)); + } else if (prefix === FRAME_CONTROL) { + const json = data.subarray(1).toString('utf-8'); + try { + this.handleControlMessage(JSON.parse(json) as ControlMessage); + } catch { + this.log('WARN', `Invalid control frame: ${json}`); + } + } + }); + + this.ws.on('close', () => { + if (!this.disposed) { + this.log('WARN', 'WebSocket closed'); + this.writeEmitter.fire('\x1b[33m[Codev: Connection lost, reconnecting...]\x1b[0m\r\n'); + // Reconnection handled by terminal-manager + } + }); + + this.ws.on('error', (err) => { + this.log('ERROR', `WebSocket error: ${err.message}`); + }); + } + + reconnect(wsUrl?: string): void { + if (wsUrl) { this.wsUrl = wsUrl; } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.decoder = new TextDecoder('utf-8', { fatal: false }); + this.escapeBuffer = new EscapeBuffer(); + this.connect(); + } + + // ── Data handling ──────────────────────────────────────────── + + private handleData(payload: Buffer): void { + // Backpressure check + this.queuedBytes += payload.length; + if (this.queuedBytes > MAX_QUEUE) { + this.log('WARN', 'Backpressure exceeded 1MB — disconnecting for replay'); + this.queuedBytes = 0; + this.reconnect(); + return; + } + + const text = this.decoder.decode(payload, { stream: true }); + const safe = this.escapeBuffer.write(text); + if (safe.length === 0) { return; } + + // Chunk large writes to avoid CPU spikes + if (safe.length <= CHUNK_SIZE) { + this.writeEmitter.fire(safe); + this.queuedBytes = 0; + } else { + this.writeChunked(safe); + } + } + + private writeChunked(text: string): void { + let offset = 0; + const writeNext = (): void => { + if (this.disposed || offset >= text.length) { + this.queuedBytes = 0; + return; + } + const chunk = text.substring(offset, offset + CHUNK_SIZE); + offset += CHUNK_SIZE; + this.writeEmitter.fire(chunk); + setImmediate(writeNext); + }; + writeNext(); + } + + private handleControlMessage(msg: ControlMessage): void { + switch (msg.type) { + case 'seq': + this.lastSeq = (msg.payload.seq as number) ?? this.lastSeq; + break; + case 'pong': + break; + case 'pause': + this.replaying = true; + break; + case 'resume': + this.replaying = false; + // Flush deferred resize + if (this.pendingResize) { + this.sendResize(this.pendingResize.cols, this.pendingResize.rows); + this.pendingResize = null; + } + break; + case 'error': + this.log('ERROR', `Server error: ${JSON.stringify(msg.payload)}`); + break; + } + } + + // ── Helpers ────────────────────────────────────────────────── + + private sendControl(msg: ControlMessage): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return; } + const json = JSON.stringify(msg); + const jsonBuf = Buffer.from(json, 'utf-8'); + const frame = Buffer.allocUnsafe(1 + jsonBuf.length); + frame[0] = FRAME_CONTROL; + jsonBuf.copy(frame, 1); + this.ws.send(frame); + } + + private sendResize(cols: number, rows: number): void { + this.sendControl({ type: 'resize', payload: { cols, rows } }); + } + + private log(level: string, message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine(`[${timestamp}] [Terminal] [${level}] ${message}`); + } +} diff --git a/packages/vscode/src/terminal-manager.ts b/packages/vscode/src/terminal-manager.ts new file mode 100644 index 00000000..08c78814 --- /dev/null +++ b/packages/vscode/src/terminal-manager.ts @@ -0,0 +1,143 @@ +import * as vscode from 'vscode'; +import { CodevPseudoterminal } from './terminal-adapter.js'; +import type { ConnectionManager } from './connection-manager.js'; +import { encodeWorkspacePath } from '@cluesmith/codev-core/workspace'; + +const MAX_TERMINALS = 10; + +interface ManagedTerminal { + terminal: vscode.Terminal; + pty: CodevPseudoterminal; + type: 'architect' | 'builder' | 'shell'; + id: string; +} + +/** + * Manages VS Code terminal instances backed by Tower PTY sessions. + * Handles WebSocket pool, editor layout, and terminal lifecycle. + */ +export class TerminalManager { + private terminals = new Map(); + private outputChannel: vscode.OutputChannel; + private connectionManager: ConnectionManager; + + constructor(connectionManager: ConnectionManager, outputChannel: vscode.OutputChannel) { + this.connectionManager = connectionManager; + this.outputChannel = outputChannel; + } + + /** + * Open the architect terminal. + */ + async openArchitect(terminalId: string): Promise { + if (this.terminals.has('architect')) { + // Focus existing + this.terminals.get('architect')!.terminal.show(); + return; + } + await this.openTerminal(terminalId, 'architect', 'Codev: Architect'); + } + + /** + * Open a builder terminal. + */ + async openBuilder(terminalId: string, builderId: string, label: string): Promise { + const key = `builder-${builderId}`; + if (this.terminals.has(key)) { + this.terminals.get(key)!.terminal.show(); + return; + } + await this.openTerminal(terminalId, 'builder', label, key); + } + + /** + * Open a shell terminal. + */ + async openShell(terminalId: string, shellNumber: number): Promise { + const key = `shell-${shellNumber}`; + if (this.terminals.has(key)) { + this.terminals.get(key)!.terminal.show(); + return; + } + await this.openTerminal(terminalId, 'shell', `Codev: Shell #${shellNumber}`, key); + } + + getTerminalCount(): number { + return this.terminals.size; + } + + // ── Internal ───────────────────────────────────────────────── + + private async openTerminal( + terminalId: string, + type: 'architect' | 'builder' | 'shell', + name: string, + key?: string, + ): Promise { + if (this.terminals.size >= MAX_TERMINALS) { + vscode.window.showWarningMessage(`Too many terminals (${MAX_TERMINALS} max) — close unused terminals`); + return; + } + + const wsUrl = this.buildWsUrl(terminalId); + if (!wsUrl) { + vscode.window.showErrorMessage('Cannot open terminal — no workspace detected'); + return; + } + + const authKey = await this.getAuthKey(); + const pty = new CodevPseudoterminal(wsUrl, authKey, this.outputChannel); + const position = vscode.workspace.getConfiguration('codev').get('terminalPosition', 'editor'); + const location = position === 'editor' + ? { viewColumn: type === 'architect' ? vscode.ViewColumn.One : vscode.ViewColumn.Two } + : vscode.TerminalLocation.Panel; + + const terminal = vscode.window.createTerminal({ name, pty, location }); + + const mapKey = key ?? type; + this.terminals.set(mapKey, { terminal, pty, type, id: terminalId }); + + // Clean up when terminal is closed by user + const disposable = vscode.window.onDidCloseTerminal((t) => { + if (t === terminal) { + pty.close(); + this.terminals.delete(mapKey); + disposable.dispose(); + } + }); + + terminal.show(true); + } + + private buildWsUrl(terminalId: string): string | null { + const workspacePath = this.connectionManager.getWorkspacePath(); + if (!workspacePath) { return null; } + + const config = vscode.workspace.getConfiguration('codev'); + const host = config.get('towerHost', 'localhost'); + const port = config.get('towerPort', 4100); + const encoded = encodeWorkspacePath(workspacePath); + + return `ws://${host}:${port}/workspace/${encoded}/ws/terminal/${terminalId}`; + } + + private async getAuthKey(): Promise { + const client = this.connectionManager.getClient(); + if (!client) { return null; } + // TowerClient's getAuthKey is synchronous + return (client as any).getAuthKey?.() ?? null; + } + + private log(level: string, message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine(`[${timestamp}] [TerminalManager] [${level}] ${message}`); + } + + dispose(): void { + for (const [, managed] of this.terminals) { + managed.pty.close(); + managed.terminal.dispose(); + } + this.terminals.clear(); + } +} diff --git a/packages/vscode/src/tower-starter.ts b/packages/vscode/src/tower-starter.ts new file mode 100644 index 00000000..660d08d7 --- /dev/null +++ b/packages/vscode/src/tower-starter.ts @@ -0,0 +1,81 @@ +import * as vscode from 'vscode'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { TowerClient } from '@cluesmith/codev-core/tower-client'; + +/** + * Auto-start Tower as a detached process. + * Resolves the afx binary path, spawns it, then polls health until ready. + */ +export async function autoStartTower( + client: TowerClient, + workspacePath: string | null, + outputChannel: vscode.OutputChannel, +): Promise { + const log = (level: string, msg: string) => { + outputChannel.appendLine(`[${new Date().toISOString()}] [Tower] [${level}] ${msg}`); + }; + + // Check if already running + if (await client.isRunning()) { + log('INFO', 'Tower already running'); + return true; + } + + // Resolve afx path + const afxPath = resolveAfxPath(workspacePath); + if (!afxPath) { + log('ERROR', 'Cannot find afx binary — install @cluesmith/codev globally or check PATH'); + return false; + } + + log('INFO', `Starting Tower via ${afxPath}`); + + try { + // Spawn as detached process — Tower outlives VS Code + const child = spawn(afxPath, ['tower', 'start'], { + detached: true, + stdio: 'ignore', + cwd: workspacePath ?? undefined, + }); + child.unref(); + + // Poll health with backoff (max 10 attempts) + for (let attempt = 1; attempt <= 10; attempt++) { + await sleep(500 * attempt); + if (await client.isRunning()) { + log('INFO', `Tower started (attempt ${attempt})`); + return true; + } + log('INFO', `Waiting for Tower... (attempt ${attempt}/10)`); + } + + log('ERROR', 'Tower did not start within timeout'); + return false; + } catch (err) { + log('ERROR', `Failed to start Tower: ${(err as Error).message}`); + return false; + } +} + +/** + * Resolve the afx binary path. + * Checks: workspace node_modules/.bin, then global PATH. + */ +function resolveAfxPath(workspacePath: string | null): string | null { + // Check workspace node_modules/.bin + if (workspacePath) { + const localPath = resolve(workspacePath, 'node_modules', '.bin', 'afx'); + if (existsSync(localPath)) { + return localPath; + } + } + + // Fall back to global — assume it's on PATH + return 'afx'; +} + +function sleep(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} diff --git a/packages/vscode/src/views/backlog.ts b/packages/vscode/src/views/backlog.ts new file mode 100644 index 00000000..57d3051e --- /dev/null +++ b/packages/vscode/src/views/backlog.ts @@ -0,0 +1,34 @@ +import * as vscode from 'vscode'; +import type { OverviewCache } from './overview-data.js'; + +export class BacklogProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(private cache: OverviewCache) { + cache.onDidChange(() => this.changeEmitter.fire()); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(): vscode.TreeItem[] { + const data = this.cache.getData(); + if (!data) { return []; } + + return data.backlog.map(item => { + const author = item.author ? ` @${item.author}` : ''; + const ti = new vscode.TreeItem(`#${item.id} ${item.title}${author}`); + ti.tooltip = item.url; + ti.contextValue = 'backlog-item'; + ti.iconPath = new vscode.ThemeIcon('issues'); + ti.command = { + command: 'vscode.open', + title: 'Open in Browser', + arguments: [vscode.Uri.parse(item.url)], + }; + return ti; + }); + } +} diff --git a/packages/vscode/src/views/builders.ts b/packages/vscode/src/views/builders.ts new file mode 100644 index 00000000..dde6ddeb --- /dev/null +++ b/packages/vscode/src/views/builders.ts @@ -0,0 +1,31 @@ +import * as vscode from 'vscode'; +import type { OverviewCache } from './overview-data.js'; + +export class BuildersProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(private cache: OverviewCache) { + cache.onDidChange(() => this.changeEmitter.fire()); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(): vscode.TreeItem[] { + const data = this.cache.getData(); + if (!data) { return []; } + + return data.builders.map(b => { + const phase = b.blocked ? `[${b.blocked}] blocked` : `[${b.phase}]`; + const item = new vscode.TreeItem(`#${b.issueId ?? b.id} ${b.issueTitle ?? ''} ${phase}`); + item.tooltip = `Protocol: ${b.protocol} | Mode: ${b.mode} | Progress: ${b.progress}%`; + item.contextValue = 'builder'; + item.iconPath = b.blocked + ? new vscode.ThemeIcon('debug-pause', new vscode.ThemeColor('testing.iconFailed')) + : new vscode.ThemeIcon('play', new vscode.ThemeColor('testing.iconPassed')); + return item; + }); + } +} diff --git a/packages/vscode/src/views/needs-attention.ts b/packages/vscode/src/views/needs-attention.ts new file mode 100644 index 00000000..50bc9624 --- /dev/null +++ b/packages/vscode/src/views/needs-attention.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; +import type { OverviewCache } from './overview-data.js'; + +export class NeedsAttentionProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(private cache: OverviewCache) { + cache.onDidChange(() => this.changeEmitter.fire()); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(): vscode.TreeItem[] { + const data = this.cache.getData(); + if (!data) { return []; } + + const items: vscode.TreeItem[] = []; + + // Blocked builders + for (const b of data.builders.filter(b => b.blocked)) { + const waitTime = b.blockedSince + ? `(${timeSince(b.blockedSince)})` + : ''; + const item = new vscode.TreeItem(`#${b.issueId ?? b.id} — blocked on ${b.blocked} ${waitTime}`); + item.iconPath = new vscode.ThemeIcon('bell', new vscode.ThemeColor('notificationsWarningIcon.foreground')); + item.contextValue = 'blocked-builder'; + items.push(item); + } + + // PRs needing review + for (const pr of data.pendingPRs.filter(p => p.reviewStatus === 'review_required')) { + const item = new vscode.TreeItem(`PR #${pr.id} — ready for review`); + item.iconPath = new vscode.ThemeIcon('git-pull-request'); + item.contextValue = 'pr-needs-review'; + items.push(item); + } + + return items; + } +} + +function timeSince(isoDate: string): string { + const ms = Date.now() - new Date(isoDate).getTime(); + const minutes = Math.floor(ms / 60000); + if (minutes < 60) { return `${minutes}m`; } + const hours = Math.floor(minutes / 60); + if (hours < 24) { return `${hours}h`; } + return `${Math.floor(hours / 24)}d`; +} diff --git a/packages/vscode/src/views/overview-data.ts b/packages/vscode/src/views/overview-data.ts new file mode 100644 index 00000000..cb530dde --- /dev/null +++ b/packages/vscode/src/views/overview-data.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; +import type { OverviewData } from '@cluesmith/codev-types'; +import type { ConnectionManager } from '../connection-manager.js'; + +/** + * Shared cache for /api/overview data. + * Refreshed on SSE events, consumed by all Work View TreeDataProviders. + */ +export class OverviewCache { + private data: OverviewData | null = null; + private loading = false; + + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChange = this.changeEmitter.event; + + constructor(private connectionManager: ConnectionManager) { + // Refresh on SSE events + connectionManager.onSSEEvent(() => { + this.refresh(); + }); + } + + getData(): OverviewData | null { + return this.data; + } + + async refresh(): Promise { + if (this.loading) { return; } + this.loading = true; + + try { + const client = this.connectionManager.getClient(); + if (!client || this.connectionManager.getState() !== 'connected') { + this.data = null; + this.changeEmitter.fire(); + return; + } + + const workspacePath = this.connectionManager.getWorkspacePath(); + this.data = await client.getOverview(workspacePath ?? undefined) ?? null; + } finally { + this.loading = false; + this.changeEmitter.fire(); + } + } + + dispose(): void { + this.changeEmitter.dispose(); + } +} diff --git a/packages/vscode/src/views/pull-requests.ts b/packages/vscode/src/views/pull-requests.ts new file mode 100644 index 00000000..daec9aea --- /dev/null +++ b/packages/vscode/src/views/pull-requests.ts @@ -0,0 +1,34 @@ +import * as vscode from 'vscode'; +import type { OverviewCache } from './overview-data.js'; + +export class PullRequestsProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(private cache: OverviewCache) { + cache.onDidChange(() => this.changeEmitter.fire()); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(): vscode.TreeItem[] { + const data = this.cache.getData(); + if (!data) { return []; } + + return data.pendingPRs.map(pr => { + const author = pr.author ? ` @${pr.author}` : ''; + const item = new vscode.TreeItem(`#${pr.id} ${pr.title}${author} (${pr.reviewStatus})`); + item.tooltip = pr.url; + item.contextValue = 'pull-request'; + item.iconPath = new vscode.ThemeIcon('git-pull-request'); + item.command = { + command: 'vscode.open', + title: 'Open in Browser', + arguments: [vscode.Uri.parse(pr.url)], + }; + return item; + }); + } +} diff --git a/packages/vscode/src/views/recently-closed.ts b/packages/vscode/src/views/recently-closed.ts new file mode 100644 index 00000000..23fb345d --- /dev/null +++ b/packages/vscode/src/views/recently-closed.ts @@ -0,0 +1,35 @@ +import * as vscode from 'vscode'; +import type { OverviewCache } from './overview-data.js'; + +export class RecentlyClosedProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(private cache: OverviewCache) { + cache.onDidChange(() => this.changeEmitter.fire()); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(): vscode.TreeItem[] { + const data = this.cache.getData(); + if (!data) { return []; } + + return data.recentlyClosed.map(item => { + const ti = new vscode.TreeItem(`#${item.id} ${item.title} (${item.type})`); + ti.tooltip = item.url; + ti.contextValue = 'recently-closed'; + ti.iconPath = new vscode.ThemeIcon('check'); + if (item.prUrl) { + ti.command = { + command: 'vscode.open', + title: 'Open PR', + arguments: [vscode.Uri.parse(item.prUrl)], + }; + } + return ti; + }); + } +} diff --git a/packages/vscode/src/views/status.ts b/packages/vscode/src/views/status.ts new file mode 100644 index 00000000..4e202694 --- /dev/null +++ b/packages/vscode/src/views/status.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode'; +import type { TunnelStatus } from '@cluesmith/codev-types'; +import type { ConnectionManager } from '../connection-manager.js'; + +export class StatusProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changeEmitter.event; + + constructor(private connectionManager: ConnectionManager) { + connectionManager.onStateChange(() => this.changeEmitter.fire()); + connectionManager.onSSEEvent(() => this.changeEmitter.fire()); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + async getChildren(): Promise { + const items: vscode.TreeItem[] = []; + const client = this.connectionManager.getClient(); + const state = this.connectionManager.getState(); + + // Tower status + const towerItem = new vscode.TreeItem(`Tower: ${state}`); + towerItem.iconPath = state === 'connected' + ? new vscode.ThemeIcon('check', new vscode.ThemeColor('testing.iconPassed')) + : new vscode.ThemeIcon('circle-slash', new vscode.ThemeColor('testing.iconFailed')); + items.push(towerItem); + + if (client && state === 'connected') { + // Tunnel status + try { + const tunnel = await client.getTunnelStatus(); + if (tunnel) { + const label = tunnel.state === 'connected' + ? `Tunnel: ${tunnel.towerName ?? 'connected'}` + : `Tunnel: ${tunnel.state}`; + const tunnelItem = new vscode.TreeItem(label); + tunnelItem.iconPath = tunnel.state === 'connected' + ? new vscode.ThemeIcon('cloud', new vscode.ThemeColor('testing.iconPassed')) + : new vscode.ThemeIcon('cloud-download'); + items.push(tunnelItem); + } + } catch { /* ignore */ } + + // Cron tasks + try { + const result = await client.request<{ tasks: Array<{ name: string; enabled: boolean }> }>('/api/cron/tasks'); + if (result.ok && result.data?.tasks) { + const running = result.data.tasks.filter(t => t.enabled).length; + const cronItem = new vscode.TreeItem(`Cron: ${result.data.tasks.length} tasks (${running} enabled)`); + cronItem.iconPath = new vscode.ThemeIcon('clock'); + items.push(cronItem); + } + } catch { /* ignore */ } + } + + return items; + } +} diff --git a/packages/vscode/src/views/team.ts b/packages/vscode/src/views/team.ts new file mode 100644 index 00000000..185d80ef --- /dev/null +++ b/packages/vscode/src/views/team.ts @@ -0,0 +1,96 @@ +import * as vscode from 'vscode'; +import type { TeamApiResponse } from '@cluesmith/codev-types'; +import type { ConnectionManager } from '../connection-manager.js'; + +export class TeamProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changeEmitter.event; + private data: TeamApiResponse | null = null; + + constructor(private connectionManager: ConnectionManager) { + connectionManager.onSSEEvent(() => this.refresh()); + } + + refresh(): void { + this.fetchData(); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + getChildren(element?: vscode.TreeItem): vscode.TreeItem[] { + if (!this.data?.enabled || !this.data.members) { return []; } + + // Root level — member list + if (!element) { + return this.data.members.map(m => { + const item = new vscode.TreeItem( + `@${m.github} (${m.role})`, + vscode.TreeItemCollapsibleState.Collapsed, + ); + item.contextValue = 'team-member'; + item.iconPath = new vscode.ThemeIcon('person'); + (item as any)._memberData = m; + return item; + }); + } + + // Member details + const member = (element as any)._memberData; + if (!member?.github_data) { return []; } + + const items: vscode.TreeItem[] = []; + const ghd = member.github_data; + + if (ghd.assignedIssues?.length) { + items.push(...ghd.assignedIssues.map((i: any) => { + const ti = new vscode.TreeItem(`Working on: #${i.number} ${i.title}`); + ti.iconPath = new vscode.ThemeIcon('issues'); + return ti; + })); + } + + if (ghd.openPRs?.length) { + items.push(...ghd.openPRs.map((p: any) => { + const ti = new vscode.TreeItem(`Open PR: #${p.number} ${p.title}`); + ti.iconPath = new vscode.ThemeIcon('git-pull-request'); + return ti; + })); + } + + const merged = ghd.recentActivity?.mergedPRs?.length ?? 0; + const closed = ghd.recentActivity?.closedIssues?.length ?? 0; + if (merged || closed) { + const ti = new vscode.TreeItem(`Last 7d: ${merged} merged, ${closed} closed`); + ti.iconPath = new vscode.ThemeIcon('graph'); + items.push(ti); + } + + return items; + } + + private async fetchData(): Promise { + const client = this.connectionManager.getClient(); + const workspacePath = this.connectionManager.getWorkspacePath(); + if (!client || !workspacePath) { + this.data = null; + this.changeEmitter.fire(); + return; + } + + try { + const result = await client.request( + `/workspace/${encodeWorkspacePath(workspacePath)}/api/team`, + ); + this.data = result.ok ? result.data ?? null : null; + } catch { + this.data = null; + } + this.changeEmitter.fire(); + } +} + +function encodeWorkspacePath(p: string): string { + return Buffer.from(p).toString('base64url'); +} diff --git a/packages/vscode/src/workspace-detector.ts b/packages/vscode/src/workspace-detector.ts new file mode 100644 index 00000000..296e1535 --- /dev/null +++ b/packages/vscode/src/workspace-detector.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Traverse up from a directory to find the .codev/config.json root. + * Returns the project root directory (parent of .codev/), or null. + */ +export function findProjectRoot(startDir: string): string | null { + let dir = startDir; + const root = path.parse(dir).root; + + while (dir !== root) { + if (fs.existsSync(path.join(dir, '.codev')) + || fs.existsSync(path.join(dir, 'codev'))) { + return dir; + } + dir = path.dirname(dir); + } + return null; +} + +/** + * Detect the workspace path for Tower communication. + * Priority: setting override > workspace folder traversal. + */ +export function detectWorkspacePath(): string | null { + const override = vscode.workspace.getConfiguration('codev').get('workspacePath'); + if (override) { + return override; + } + + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { + return null; + } + + return findProjectRoot(folders[0].uri.fsPath); +} + +/** + * Get Tower host and port from VS Code settings. + */ +export function getTowerAddress(): { host: string; port: number } { + const config = vscode.workspace.getConfiguration('codev'); + return { + host: config.get('towerHost', 'localhost'), + port: config.get('towerPort', 4100), + }; +} diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json index d28dac34..8d16eede 100644 --- a/packages/vscode/tsconfig.json +++ b/packages/vscode/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "bundler", "rootDir": "src", "declaration": false, - "declarationMap": false + "declarationMap": false, + "types": ["node", "mocha"] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a499c3ae..a48cfbb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.41 version: 0.2.105(zod@4.3.6) + '@cluesmith/codev-core': + specifier: workspace:* + version: link:../core '@google/genai': specifier: ^1.0.0 version: 1.50.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) @@ -95,11 +98,23 @@ importers: packages/config: {} - packages/dashboard: - dependencies: + packages/core: + devDependencies: '@cluesmith/codev-types': specifier: workspace:* version: link:../types + '@types/node': + specifier: 22.x + version: 22.19.17 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + + packages/dashboard: + dependencies: + '@cluesmith/codev-core': + specifier: workspace:* + version: link:../core '@xterm/addon-canvas': specifier: ^0.7.0 version: 0.7.0(@xterm/xterm@5.5.0) @@ -125,6 +140,9 @@ importers: specifier: ^3.7.0 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@19.2.5)(react@19.2.5)(redux@5.0.1) devDependencies: + '@cluesmith/codev-types': + specifier: workspace:* + version: link:../types '@testing-library/jest-dom': specifier: ^6.6.0 version: 6.9.1 @@ -161,9 +179,15 @@ importers: packages/vscode: dependencies: + '@cluesmith/codev-core': + specifier: workspace:* + version: link:../core '@cluesmith/codev-types': specifier: workspace:* version: link:../types + ws: + specifier: ^8.18.0 + version: 8.20.0 devDependencies: '@types/mocha': specifier: ^10.0.10 @@ -174,6 +198,9 @@ importers: '@types/vscode': specifier: ^1.110.0 version: 1.115.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 '@vscode/test-cli': specifier: ^0.0.12 version: 0.0.12 @@ -804,56 +831,66 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} @@ -1037,66 +1074,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}