mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-01 13:09:42 +02:00
Compare commits
24
Commits
@@ -1,5 +1,17 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- **Plan Usage Limits chip (new).** A header chip now shows your live Claude plan usage — the 5-hour and weekly windows as a percentage — parsed from Claude Code's statusLine telemetry (CLI v2.1.80+). It's opt-in via **App Settings → Display → "Plan Usage Limits"** (default OFF). The toggle is **per-device**: turn it on at your desk without it appearing on your phone. Telemetry collection is decoupled from display, so one device's preference never affects another's, and the last-known value replays instantly on reconnect. Distinct from auto-resume (which reacts to the limit _message_) — this proactively shows the live %.
|
||||
|
||||
**Attachments.** New attachment history drawer to browse files referenced by a session (COD-39), plus document previews and thumbnails on attachment cards (COD-38). The header **Attachments button is now opt-in** (default OFF) via **App Settings → Display → "Attachments Button"**, per-device like the Response Viewer button.
|
||||
|
||||
**Settings & models.** Added Opus 4.6 options to the Claude Model picker. Removed the legacy Token Count / Show Cost header toggles and moved Plan Usage Limits to the top of the Display settings. Slimmed the Skin picker control to match its row.
|
||||
|
||||
**Mobile & header polish.** Restored the response-viewer (eye) button on phones; kept the phone header minimal (settings gear + lifecycle log stay in the toolbar). Added two regression guards so header controls can't silently leak onto the mobile header again — a CI-runnable static policy check plus a real-browser E2E test.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
@@ -56,7 +56,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.0.0 (must match `package.json`)
|
||||
**Version**: 1.1.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -118,7 +118,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Domain | Key files | Notes |
|
||||
|--------|-----------|-------|
|
||||
| **Entry** | `src/index.ts`, `src/cli.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts` | |
|
||||
| **Session** | `src/session.ts` ★, `src/session-manager.ts`, `src/session-auto-ops.ts`, `src/session-cli-builder.ts`, `src/session-lifecycle-log.ts`, `src/session-task-cache.ts`, `src/usage-limit-patterns.ts`, `src/usage-telemetry.ts` | |
|
||||
| **Mux** | `src/mux-interface.ts`, `src/mux-factory.ts`, `src/tmux-manager.ts` ★ | |
|
||||
| **Respawn** | `src/respawn-controller.ts` ★ + 4 helpers (`-adaptive-timing`, `-health`, `-metrics`, `-patterns`) | Read `docs/respawn-state-machine.md` first |
|
||||
| **Ralph** | `src/ralph-tracker.ts` ★, `src/ralph-loop.ts` + 5 helpers (`-config`, `-fix-plan-watcher`, `-plan-tracker`, `-stall-detector`, `-status-parser`) | Read `docs/ralph-wiggum-guide.md` first |
|
||||
@@ -128,16 +128,17 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Tasks** | `src/task.ts`, `src/task-queue.ts`, `src/task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `src/run-summary.ts`, `src/session-lifecycle-log.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.7K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
|
||||
|
||||
**Config**: `src/config/` — 10 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
**Config**: `src/config/` — 12 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap`, `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -156,6 +157,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`.
|
||||
|
||||
**Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). Codeman injects its OWN statusLine exporter (`generateStatusLineCommand()` in `hooks-config.ts`, identified by the `/api/status-telemetry` marker — it only ever adds/updates/removes a statusLine that is *ours*, never a user's hand-authored one) that POSTs the blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated under a tunnel) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (so injecting our statusLine doesn't blank the in-terminal footer). `plan-usage-latest.ts` holds the process-wide last value, replayed in the SSE init snapshot (`getLightState`) so the header chip (`#planUsageChip`, toggled by `showPlanUsageLimits` in settings-ui.js) renders immediately on page load / reconnect without per-browser localStorage. Claude-mode only. **Distinct from auto-resume** (which reacts to the limit *message*; this proactively shows the live %). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`.
|
||||
|
||||
**Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`.
|
||||
|
||||
**External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM).
|
||||
@@ -168,6 +171,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
|
||||
**Attachments** (live external document references; COD-37/#119 core, COD-38/#120 previews, COD-39/#121 history): all wiring in `file-routes.ts`. **Registry** (`attachment-registry.ts`): an **in-memory** map of a stable `attachmentId` → an absolute, `realpath`-resolved, extension-allowlisted file path, so browser requests (`GET /api/sessions/:id/attachments/:attachmentId/raw`) never carry arbitrary absolute paths; `POST /api/sessions/:id/attachments` registers one. **Magic links** (`attachment-magic.ts`): parses `codeman://attach?...` out of terminal output — ⚠️ this scanner is prompt-injectable, so the scan path is **force-confined to the session workspace** (a hostile prompt could otherwise make it read arbitrary host files over SSE); emits the `attachment:detected` SSE event. Security gate is an extension **allowlist** (`isSupportedAttachmentExtension`, in the registry/magic modules), not a blocklist; a separate path layer (`config/attachment-guard.ts`) confines reads to the workspace (`attachmentConfineToWorkspace`) and blocks sensitive trees (`/root`, `/etc`). **Previews + thumbnails** (COD-38): `:attachmentId/preview` + `:attachmentId/thumbnail` (and the workspace-file equivalents `file-preview`/`file-thumbnail`) render Office docs/PDFs via external converters (`pdftoppm` / LibreOffice `soffice` / Word-COM `powershell`); `document-preview-cache.ts` is a shared disk cache (de-dups *identical* in-flight inputs), `document-thumbnailer.ts` does best-effort first-page images, and `document-conversion-limiter.ts` is a **global converter-spawn concurrency cap** (`runWithConversionLimit`) — without it, N distinct large docs detected at once fork N multi-minute converter processes = a localhost fork-bomb-shaped resource-exhaustion vector. **History drawer** (COD-39): `session-attachment-history.ts` tracks the last `ATTACHMENT_HISTORY_LIMIT` (100) attachments per session (`Session._attachmentHistory`, persisted via `SessionState.attachmentHistory`, replayed so externals re-register on reconnect); `GET /api/sessions/:id/attachments` is the list endpoint. ⚠️ The history drawer's launcher button is desktop-only — hidden on phones (regression-guarded; see `mobile-header-buttons-policy` test). Session-local files keep using the existing workspace-scoped `file-routes` paths; the registry is only for explicit live externals.
|
||||
|
||||
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
|
||||
|
||||
### Frontend
|
||||
@@ -184,6 +189,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Gesture-control source lives in-repo** at `packages/gesture-control/` (workspace package `codeman-gesture-control`, was the standalone `Ark0N/codeman-gesture-control` repo). The transport-agnostic core is `src/gesture/*` (MediaPipe GestureRecognizer → One-Euro-filtered cursor → pinch state machine); `src/codeman/entry.ts` is the Codeman *consumer* that maps grab/drag/drop onto real `.session-tab`/toolbar buttons and is the bundle entry. **Edit there, then run `npm run build:gesture`** (`scripts/build-gesture-bundle.mjs` → esbuild bundles `entry.ts`, MediaPipe JS included, into `src/web/public/gesture/gesture-codeman.js`) and **commit the regenerated bundle** — the committed bundle is what dev/`tsx` serves (no bundler at runtime), and `scripts/build.mjs` reruns the same step so prod always reflects current source. The MediaPipe **wasm + model** are NOT bundled — loaded at runtime from same-origin `/gesture/wasm` + `/gesture/gesture_recognizer.task`, fetched by `scripts/fetch-gesture-assets.mjs` (gitignored, see Gotchas). `entry.ts` mounts `window.__codemanGesture = new GestureBridge()` idempotently at module-eval. A standalone vite playground (`npm run dev` in the package — fake tabs, no Codeman) lets you iterate on gesture *feel* in isolation. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision` in `packages/gesture-control/package.json`.
|
||||
|
||||
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applyTheme()`/`applyTerminalSkin()` apply it live on save and keep the standalone `codeman:skin` key + the settings object in sync. `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (help), Ctrl+W (kill), Ctrl+Tab (next), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font).
|
||||
@@ -201,7 +208,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
| **QR Auth** | Single-use 6-char tokens (60s TTL) for tunnel login. See `docs/qr-auth-plan.md` |
|
||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR has separate limiter |
|
||||
| **Hook bypass** | `/api/hook-event` exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
|
||||
| **Hook bypass** | `/api/hook-event` (and `/api/status-telemetry`, the statusLine exporter) exempt from auth (localhost-only, schema-validated). While the **managed tunnel** runs, the bypass additionally requires the per-instance `X-Codeman-Hook-Secret` header (COD-54, `config/hook-secret.ts`): hook curls cat the secret file at exec time via `$CODEMAN_HOOK_SECRET_FILE` (session env), failures rate-limit in a dedicated bucket (never lock out login). External loopback proxies (own cloudflared/`tailscale serve`) aren't detected — plain bypass still applies there. Tunnel enable also **refuses** without `CODEMAN_PASSWORD` unless `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` (COD-55) |
|
||||
| **Env vars** | `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies, comma-separated; bare `.suffix` matches subdomains) |
|
||||
| **Validation** | Zod schemas, path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`) |
|
||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||
@@ -212,7 +219,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~136 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~146 handlers across 16 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# Plan Usage Limits Display — Design & As-Built
|
||||
|
||||
> **Status: SHIPPED — deployed to prod + pushed to master, not yet released (2026-06-14).** Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). Commits `c82f6c8` (feature) → `4d9d93d` (end-to-end fixes) → `eae225b` (per-user reconcile) → `95fb5fc` (init-snapshot replay). Full suite green (2869), CI green. No changeset/version bump yet.
|
||||
>
|
||||
> Two surfaces from one `statusLine` callback:
|
||||
> - **Header chip** (top-right) — account-wide **plan limits**: `5h 35% · 7d 38%`, per-window green/yellow/red.
|
||||
> - **In-terminal statusline footer** — the **current session's** status: `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%`.
|
||||
>
|
||||
> The `rate_limits` JSON schema below was **empirically confirmed** against Claude Code 2.1.177 on a Claude Max account; see the Verification appendix to reproduce.
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman had no proactive view of how much of the Claude subscription is left. It only learned about limits **reactively**: `usage-limit-patterns.ts` regex-scrapes ANSI-stripped terminal output for footer strings like `5-hour limit reached ∙ resets 8pm`, extracting only the **reset time**, and only *after* Claude has already stalled. There was no "73% of your 5-hour limit used" anywhere.
|
||||
|
||||
We wanted a live, always-visible gauge so the operator can see a wall coming and pace overnight/autonomous runs — without hijacking the in-terminal statusline, which should keep showing the current session's status.
|
||||
|
||||
## Data source: the statusline `rate_limits` JSON
|
||||
|
||||
Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a configured `statusLine.command` on stdin after each render. On Pro/Max subscriptions that blob includes `rate_limits`. **This is the only channel that exposes plan-limit data** (see rejected alternatives) — so the feature *must* set a statusLine command, which is why the footer is also reconstructed by it (below).
|
||||
|
||||
### Confirmed schema (real captured payload)
|
||||
|
||||
```jsonc
|
||||
"rate_limits": {
|
||||
"five_hour": { "used_percentage": 15, "resets_at": 1781409000 }, // → 2026-06-14T03:50:00Z
|
||||
"seven_day": { "used_percentage": 34, "resets_at": 1781827200 } // → 2026-06-19T00:00:00Z
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `rate_limits.five_hour.used_percentage` | `number` 0–100 | Integer-valued in practice; treat as `number`, don't assume decimals. |
|
||||
| `rate_limits.five_hour.resets_at` | `number` | **Epoch SECONDS** (10 digits). `×1000` for a JS `Date`. |
|
||||
| `rate_limits.seven_day.{used_percentage,resets_at}` | same | |
|
||||
|
||||
**Confirmed facts & gotchas:**
|
||||
|
||||
- **Only two windows exist: `five_hour` and `seven_day`.** There is **no separate Opus-weekly field**, even on a Max/Opus account.
|
||||
- `rate_limits` is **absent on the first render**, **present after the first API response**. UI degrades to "no chip yet."
|
||||
- statusLine fires **only in interactive TUI mode**, never `--print`. Fine — Codeman sessions are interactive TUIs (and so are Codeman-spawned ones in tmux).
|
||||
- **Subscriber-gated.** Absent for API-key / non-subscriber auth.
|
||||
|
||||
### Bonus telemetry in the same payload — used for the footer
|
||||
|
||||
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
|
||||
|
||||
### Alternatives considered & rejected
|
||||
|
||||
| Source | Why not |
|
||||
|--------|---------|
|
||||
| OAuth endpoint `api.anthropic.com/api/oauth/usage` | Undocumented, aggressively rate-limited, needs the **encrypted** OAuth token. Only worth it for *dollar spend*. |
|
||||
| `/usage` slash command | Interactive-only, no programmatic output. |
|
||||
| On-disk `~/.claude/` files | No usage state persisted (only `daemon.status.json` = auto-updater supervisor). |
|
||||
| CLI flag (`claude usage` / `--check-usage`) | Does not exist. |
|
||||
| `StopFailure` hook | Carries only an `error_type` on *failure* — no live percentages. |
|
||||
|
||||
## As-built architecture
|
||||
|
||||
```
|
||||
Claude TUI (any Claude session, incl. linked-case/real-repo sessions)
|
||||
│ renders statusline after each assistant msg (+ /compact, mode change)
|
||||
▼
|
||||
statusLine.command (settings.local.json) ──reads stdin JSON──▶
|
||||
curl -sk POST $CODEMAN_API_URL/api/status-telemetry {sessionId, data}
|
||||
(X-Codeman-Hook-Secret: $(cat $CODEMAN_HOOK_SECRET_FILE))
|
||||
│ ◀── HTTP 200 text/plain = current-SESSION status string ──┘
|
||||
▼
|
||||
printf '%s' "$body" → in-terminal footer: "Opus 4.8 (1M context) in:… out:… ctx:…%"
|
||||
|
||||
server (status-telemetry-routes.ts):
|
||||
parse rate_limits → (if changed) store last-known + broadcast SSE session:statusTelemetry → header chip
|
||||
parse model/tokens/ctx → return the session-status footer string
|
||||
▼
|
||||
app.js: _onSessionStatusTelemetry → chip (per-window colors) + localStorage save
|
||||
handleInit → chip from init-snapshot planUsage (fresh-load replay)
|
||||
```
|
||||
|
||||
### 1. The exporter — `generateStatusLineCommand()` in `hooks-config.ts`
|
||||
|
||||
Mirrors the hook `curlCmd()`. Reads the stdin JSON, POSTs `{sessionId, data}` to a **fixed** loopback path, and prints the response body back to stdout (print-through, so the footer stays useful). The managed-session env carries `$CODEMAN_SESSION_ID` / `$CODEMAN_API_URL` / `$CODEMAN_HOOK_SECRET_FILE` (from `tmux-manager.buildEnvExports()`).
|
||||
|
||||
```bash
|
||||
INPUT=$(cat 2>/dev/null || echo '{}'); \
|
||||
printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | \
|
||||
curl -sk -X POST "$CODEMAN_API_URL/api/status-telemetry" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" \
|
||||
--data @- 2>/dev/null || echo codeman
|
||||
```
|
||||
|
||||
⚠️ **`curl -sk`, not `curl -s`.** Prod is loopback **HTTPS with a self-signed cert**; without `-k`, curl returns `000` and the statusline silently shows nothing. `-k` is safe (loopback only). *(The existing hook curls use `-s` without `-k` and have the same latent issue on HTTPS installs — a known, separate follow-up.)*
|
||||
|
||||
### 2. Endpoint — `POST /api/status-telemetry` (`status-telemetry-routes.ts`)
|
||||
|
||||
Fixed path (sessionId in the **body**, not the URL) so the auth exemption is an exact-match like `/api/hook-event` (`middleware/auth.ts`: loopback-only; `X-Codeman-Hook-Secret`-gated while a tunnel runs). Schema `StatusTelemetrySchema` in `schemas.ts` validates the subset; unknown keys are stripped. Pure parsing/formatting in `usage-telemetry.ts`:
|
||||
|
||||
- `parseStatusTelemetry(data)` → `{ fiveHour, sevenDay, … }` or `null`. On change (signature dedup; statusline fires often), store last-known (`plan-usage-latest.ts`) and `broadcast('session:statusTelemetry', { sessionId, …telemetry })`.
|
||||
- `parseSessionStatus(data)` + `formatSessionStatusText()` → the **footer** string `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` (returned as `text/plain`). Available from the first render, even before `rate_limits` appears.
|
||||
|
||||
### 3. SSE + frontend chip
|
||||
|
||||
`session:statusTelemetry` registered in `sse-events.ts` + `constants.js`. `app.js`:
|
||||
- `_onSessionStatusTelemetry` → `updatePlanUsageChip(data)` + save to `localStorage['codeman:planUsage']`.
|
||||
- `updatePlanUsageChip` renders two `5h`/`7d` windows; **per-window color by usage** — green `<60%`, yellow `60–84%`, red `≥85%` (`pu-green/pu-yellow/pu-red`); bold labels/values; reset times in the tooltip. `resets_at*1000 → Date`.
|
||||
- Chip element ships hidden (`header-plan-usage--hidden`); `applyHeaderVisibilitySettings()` reveals it client-side when the setting is on (response-viewer pattern — **no `renderIndexHtml` strip**, which kept the "title-only" render contract intact).
|
||||
|
||||
### 4. Chip data robustness — three layers
|
||||
|
||||
1. **Live:** `session:statusTelemetry` SSE on every distinct render.
|
||||
2. **Fresh load / reconnect:** server stores the latest in `plan-usage-latest.ts`; `getLightState()` includes it as `planUsage`; the per-connection **init snapshot** replays it; `handleInit` paints the chip immediately (authoritative over localStorage). Null until the first telemetry of the process.
|
||||
3. **Offline / cross-restart:** `restorePlanUsageChip()` reads `localStorage` on load (12h freshness guard).
|
||||
|
||||
### 5. Injection lifecycle — works for *any* user, never self-destructs
|
||||
|
||||
The setting `showPlanUsageLimits` is **synced** (in `settings.json`, not a per-device `displayKey`).
|
||||
|
||||
- **On toggle** (`PUT /api/settings`, `system-routes.ts`): reconcile the exporter across **all active Claude sessions' working dirs** — inject on enable, remove on disable. Server-side and authoritative, so existing sessions get the footer + feed the chip *immediately*, no new session needed, no dependency on a client's synced localStorage.
|
||||
- **On session create** (`session-routes.ts`): **ADD-ONLY** — inject when `statusLineTelemetry` is true; **never remove**. Sessions in a repo share one `settings.local.json`, so a single create-with-false (e.g. a client whose synced setting hadn't loaded) must not yank the statusLine out from under other live sessions. Removal happens only via the explicit toggle.
|
||||
- `applyStatusLineConfig()` is **`isOurs`-guarded** (matches `/api/status-telemetry`), so a user's own hand-authored statusLine is never touched, and it **updates an out-of-date ours-command** so fixes (e.g. `-k`) propagate. **No `CASES_DIR` gate** — runs for linked cases / real repos (where sessions actually run), mirroring `updateCaseModel`.
|
||||
|
||||
## Codeman-specific considerations
|
||||
|
||||
1. **Account-global limits.** The 5h/7d pools are shared across all sessions on the account → one shared header chip (freshest sample wins), not a per-tab bar.
|
||||
2. **The footer is owned, by necessity.** A statusLine command always replaces Claude's default footer. Since `rate_limits` *only* arrives via statusLine, we reconstruct a useful **session-status** footer (model · tokens · ctx %) from the same payload rather than showing the limits there.
|
||||
3. **`isOurs`-guarded.** Never removes/overwrites a user's own statusLine on disable; only manages the Codeman exporter.
|
||||
4. **Security envelope unchanged.** The exporter runs arbitrary shell every render — same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`); reuses the hook-secret gate.
|
||||
5. **Claude-only.** OpenCode/Codex emit no `rate_limits` JSON; injection is gated to `mode === 'claude'`.
|
||||
6. **Future — auto-resume synergy.** Live percentages would let `SessionAutoOps` pre-arm *before* the wall instead of reacting to the stall footer. Not built.
|
||||
|
||||
## Files shipped
|
||||
|
||||
- `src/usage-telemetry.ts` — pure parse/format (`parseStatusTelemetry`, `parseSessionStatus`, `formatSessionStatusText`, `telemetrySignature`) + `test/usage-telemetry.test.ts`.
|
||||
- `src/hooks-config.ts` — `generateStatusLineCommand()` (`curl -sk`), `applyStatusLineConfig()` (add/update/remove, `isOurs`-guarded).
|
||||
- `src/web/routes/status-telemetry-routes.ts` — `POST /api/status-telemetry`.
|
||||
- `src/web/plan-usage-latest.ts` — process-wide last-known store for init replay.
|
||||
- `src/web/schemas.ts` — `StatusTelemetrySchema` + `showPlanUsageLimits` + create-payload `statusLineTelemetry`.
|
||||
- `src/web/middleware/auth.ts` — exemption extended to `/api/status-telemetry`.
|
||||
- `src/web/routes/session-routes.ts` — add-only create-time injection.
|
||||
- `src/web/routes/system-routes.ts` — settings-toggle reconcile.
|
||||
- `src/web/server.ts` — `getLightState().planUsage` (init snapshot).
|
||||
- `src/web/sse-events.ts` + `constants.js` — `session:statusTelemetry`.
|
||||
- Frontend: `app.js` (`_onSessionStatusTelemetry`, `updatePlanUsageChip`, `restorePlanUsageChip`, `handleInit`), `settings-ui.js` (toggle + `applyHeaderVisibilitySettings`), `index.html` (chip + toggle row), `styles.css` (chip + colors), `session-ui.js` (create payload).
|
||||
|
||||
## Bugs E2E testing caught (that unit tests didn't)
|
||||
|
||||
The first "shipped" build passed every test and was broken in practice. End-to-end testing on the real install (the lesson: drive a REAL session, observe the REAL output) surfaced:
|
||||
|
||||
1. **`CASES_DIR` injection gate** excluded the user's whole workflow — sessions run in linked cases / real repos, not under `~/codeman-cases`. → dropped the gate.
|
||||
2. **`curl -s` → `000`** on the loopback self-signed HTTPS cert; statusline silently empty. → `curl -sk`.
|
||||
3. **Remove-on-create-false + shared `settings.local.json`** let a single stale client yank the statusLine out from under all sessions in a repo. → add-only on create; removal only via the toggle reconcile.
|
||||
4. **Chip blank after reload** (localStorage-only, lost on restart/fresh browser). → server-side last-known in the init snapshot.
|
||||
|
||||
## Open questions / future
|
||||
|
||||
- **Schema stability.** `rate_limits` is officially shipped but undocumented in exact shape; the parser is tolerant (renders whatever windows exist, ignores unknown).
|
||||
- **Hook `curl -s` parity.** Hooks share the no-`-k` issue on HTTPS installs — worth fixing the hook curl too (separate change; covered by `cod54` tests).
|
||||
- **Disable cleanliness.** Disabling removes the statusLine from active sessions; a brand-new session created by a *stale* client could re-add it (chip still hidden, footer benign). Fully server-authoritative create-time injection (read the setting server-side instead of the payload flag) would close this — deferred.
|
||||
|
||||
## Verification appendix — how the schema was captured (reproducible)
|
||||
|
||||
Captured without touching global settings or any real session:
|
||||
|
||||
1. Throwaway dir `/tmp/sl-capture` with an exporter `dump.sh` that appends stdin to `payloads.jsonl` and prints `cap`; a `settings.json` pointing `statusLine.command` at it.
|
||||
2. `--print` mode does **not** render a statusline → no capture (confirms TUI-only). Must use interactive.
|
||||
3. Launch interactive Claude in an **isolated tmux socket** (`tmux -L slcap`, never `-L codeman`) inside the temp dir, `--settings /tmp/sl-capture/settings.json` (no global mutation). Confirm the workspace-trust dialog (appears even with `--dangerously-skip-permissions`), then send a one-line prompt (literal text + Enter separately, Ink-style).
|
||||
4. After the first response, `rate_limits` appears in the **second** captured record (absent in the first). Inspect with `jq '.rate_limits'`.
|
||||
5. Tear down: `tmux -L slcap kill-server` + `rm -rf /tmp/sl-capture`; verify the `codeman` socket is untouched.
|
||||
|
||||
Related: `docs/claude-code-hooks-reference.md` (hook callback pattern), `src/usage-limit-patterns.ts` (reactive fallback), `docs/respawn-state-machine.md` (auto-resume interplay).
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @fileoverview Global concurrency limiter for spawning external document
|
||||
* converters (pdftoppm / LibreOffice `soffice` / Word-COM `powershell.exe`).
|
||||
*
|
||||
* Without a cap, N simultaneous thumbnail/preview requests for *distinct*
|
||||
* documents fork N converter processes at once — each held open for up to the
|
||||
* multi-minute conversion timeout. That is a localhost resource-exhaustion
|
||||
* (fork-bomb-shaped) vector: a handful of large PDFs detected at once can pin
|
||||
* CPU and RAM. This module serializes converter spawns down to a small fixed
|
||||
* pool; excess spawns queue (FIFO) until a slot frees. The in-flight cache in
|
||||
* `document-preview-cache.ts` already de-dups *identical* inputs; this bounds
|
||||
* the *distinct* case the cache can't.
|
||||
*
|
||||
* Permit accounting transfers the slot directly to the next waiter on release
|
||||
* (rather than decrement-then-reacquire) so the active count can never exceed
|
||||
* the cap even under interleaved async resumption.
|
||||
*
|
||||
* NOT re-entrant: never call `runWithConversionLimit` from inside a task that is
|
||||
* already holding a slot — a nested acquire under a full pool would deadlock.
|
||||
* The converter call sites only ever acquire once per request (the office path
|
||||
* acquires for `soffice` and `pdftoppm` sequentially, not nested).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Max converter processes allowed to run concurrently across the whole process.
|
||||
* Override with CODEMAN_MAX_DOCUMENT_CONVERSIONS (clamped to >= 1).
|
||||
*/
|
||||
const MAX_CONCURRENT_DOCUMENT_CONVERSIONS = (() => {
|
||||
const raw = Number(process.env.CODEMAN_MAX_DOCUMENT_CONVERSIONS);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3;
|
||||
})();
|
||||
|
||||
let active = 0;
|
||||
const waiters: Array<() => void> = [];
|
||||
|
||||
/** Test/diagnostic hook: converters currently holding a slot. */
|
||||
export function getActiveConversionCount(): number {
|
||||
return active;
|
||||
}
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
if (active < MAX_CONCURRENT_DOCUMENT_CONVERSIONS) {
|
||||
active++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise<void>((resolve) => waiters.push(resolve));
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
const next = waiters.shift();
|
||||
if (next) {
|
||||
// Hand the slot straight to the next waiter — `active` stays at the cap.
|
||||
next();
|
||||
} else {
|
||||
active--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `task` once a converter slot is free, releasing the slot afterward. */
|
||||
export async function runWithConversionLimit<T>(task: () => Promise<T>): Promise<T> {
|
||||
await acquire();
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* @fileoverview Shared disk cache for expensive Office document previews.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
|
||||
/**
|
||||
* Cap on persistent converted-PDF files kept in DOCUMENT_PREVIEW_CACHE_DIR.
|
||||
* The cache key embeds the source mtime, so every edit to a doc orphans its
|
||||
* prior PDF; without a cap the dir grows unbounded across long-running sessions.
|
||||
* Override with CODEMAN_MAX_PREVIEW_CACHE_FILES (clamped to >= 1).
|
||||
*/
|
||||
const MAX_PREVIEW_CACHE_FILES = (() => {
|
||||
const raw = Number(process.env.CODEMAN_MAX_PREVIEW_CACHE_FILES);
|
||||
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 100;
|
||||
})();
|
||||
function buildWordExportPdfScript(sourcePath: string, outputPath: string): string {
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$source = ${toPowerShellSingleQuotedString(sourcePath)}
|
||||
$output = ${toPowerShellSingleQuotedString(outputPath)}
|
||||
$word = $null
|
||||
$doc = $null
|
||||
try {
|
||||
$word = New-Object -ComObject Word.Application
|
||||
$word.Visible = $false
|
||||
$word.DisplayAlerts = 0
|
||||
$doc = $word.Documents.Open($source)
|
||||
$doc.ExportAsFixedFormat($output, 17)
|
||||
} finally {
|
||||
if ($null -ne $doc) {
|
||||
$doc.Close($false) | Out-Null
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
|
||||
}
|
||||
if ($null -ne $word) {
|
||||
$word.Quit() | Out-Null
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
|
||||
}
|
||||
[System.GC]::Collect()
|
||||
[System.GC]::WaitForPendingFinalizers()
|
||||
}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
type OfficePreviewConverter = 'msword' | 'libreoffice';
|
||||
|
||||
const inFlightOfficeConversions = new Map<string, Promise<string | null>>();
|
||||
|
||||
export function clearDocumentPreviewCache(): void {
|
||||
inFlightOfficeConversions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort LRU-ish eviction for the persistent converted-PDF cache: keeps at
|
||||
* most MAX_PREVIEW_CACHE_FILES `*.pdf` files in `cacheDir`, deleting the oldest
|
||||
* by mtime once over the cap. Never throws — a pruning failure must not fail the
|
||||
* conversion that triggered it. Only `*.pdf` files are considered, so the
|
||||
* transient `work-*` mkdtemp dirs are ignored.
|
||||
*/
|
||||
export async function pruneDocumentPreviewCache(cacheDir: string): Promise<void> {
|
||||
try {
|
||||
const entries = await fs.readdir(cacheDir);
|
||||
const pdfs = entries.filter((name) => name.toLowerCase().endsWith('.pdf'));
|
||||
if (pdfs.length <= MAX_PREVIEW_CACHE_FILES) return;
|
||||
|
||||
const stats = await Promise.all(
|
||||
pdfs.map(async (name) => {
|
||||
const fullPath = join(cacheDir, name);
|
||||
try {
|
||||
const stat = await fs.stat(fullPath);
|
||||
return { fullPath, mtimeMs: stat.mtimeMs ?? 0 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const sorted = stats.filter((s): s is { fullPath: string; mtimeMs: number } => s !== null);
|
||||
sorted.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
||||
const toRemove = sorted.slice(0, Math.max(0, sorted.length - MAX_PREVIEW_CACHE_FILES));
|
||||
await Promise.all(toRemove.map((entry) => fs.rm(entry.fullPath, { force: true }).catch(() => {})));
|
||||
} catch {
|
||||
// Best-effort: pruning must never break a conversion.
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
|
||||
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||
if (ext !== 'docx' && ext !== 'pptx') return null;
|
||||
|
||||
let sourceStat;
|
||||
try {
|
||||
sourceStat = await fs.stat(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const converter of getOfficePreviewConverters(filePath, ext)) {
|
||||
const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter);
|
||||
const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter);
|
||||
|
||||
if (await fileExists(cachePath)) {
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
const inFlightKey = `${converter}:${cacheKey}`;
|
||||
const inFlight = inFlightOfficeConversions.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
const converted = await inFlight;
|
||||
if (converted) return converted;
|
||||
continue;
|
||||
}
|
||||
|
||||
const conversion =
|
||||
converter === 'msword'
|
||||
? convertWordDocumentToCachedPdf(filePath, cachePath)
|
||||
: convertLibreOfficeDocumentToCachedPdf(filePath, cachePath);
|
||||
inFlightOfficeConversions.set(inFlightKey, conversion);
|
||||
try {
|
||||
const converted = await conversion;
|
||||
if (converted) return converted;
|
||||
} finally {
|
||||
inFlightOfficeConversions.delete(inFlightKey);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] {
|
||||
if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) {
|
||||
return ['msword', 'libreoffice'];
|
||||
}
|
||||
return ['libreoffice'];
|
||||
}
|
||||
|
||||
function createDocumentPreviewCacheKey(
|
||||
filePath: string,
|
||||
extension: string,
|
||||
size: number,
|
||||
mtimeMs: number,
|
||||
converter: OfficePreviewConverter
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs }))
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string {
|
||||
if (converter === 'msword') {
|
||||
const windowsCacheDir = getWindowsUserTempCacheDir(filePath);
|
||||
if (windowsCacheDir) {
|
||||
return join(windowsCacheDir, `${cacheKey}.pdf`);
|
||||
}
|
||||
}
|
||||
|
||||
return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
return typeof stat.isFile !== 'function' || stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||
const outputPath = wslMountPathToWindowsPath(cachePath);
|
||||
if (!outputPath) return null;
|
||||
|
||||
let sourceCopyPath: string | undefined;
|
||||
|
||||
try {
|
||||
await fs.mkdir(dirname(cachePath), { recursive: true });
|
||||
sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`);
|
||||
await fs.copyFile(filePath, sourceCopyPath);
|
||||
|
||||
const sourcePath = wslMountPathToWindowsPath(sourceCopyPath);
|
||||
if (!sourcePath) return null;
|
||||
|
||||
await runWithConversionLimit(() =>
|
||||
execFileAsync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-EncodedCommand',
|
||||
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
|
||||
],
|
||||
{
|
||||
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (await fileExists(cachePath)) {
|
||||
await pruneDocumentPreviewCache(dirname(cachePath));
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`,
|
||||
getCacheErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (sourceCopyPath) {
|
||||
await fs.rm(sourceCopyPath, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
||||
let workDir: string | undefined;
|
||||
try {
|
||||
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
|
||||
const outDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
|
||||
workDir = outDir;
|
||||
const profileDir = join(outDir, 'profile');
|
||||
await fs.mkdir(profileDir, { recursive: true });
|
||||
|
||||
await runWithConversionLimit(() =>
|
||||
execFileAsync(
|
||||
'soffice',
|
||||
[
|
||||
'--headless',
|
||||
'--nologo',
|
||||
'--nofirststartwizard',
|
||||
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
outDir,
|
||||
filePath,
|
||||
],
|
||||
{
|
||||
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const converted = (await fs.readdir(outDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
|
||||
if (!converted) return null;
|
||||
|
||||
await fs.rename(join(workDir, converted), cachePath);
|
||||
await pruneDocumentPreviewCache(DOCUMENT_PREVIEW_CACHE_DIR);
|
||||
return cachePath;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`,
|
||||
getCacheErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (workDir) {
|
||||
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wslMountPathToWindowsPath(filePath: string): string | null {
|
||||
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
|
||||
if (!match) return null;
|
||||
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
function getWindowsUserTempCacheDir(filePath: string): string | null {
|
||||
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//);
|
||||
if (!match) return null;
|
||||
return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`;
|
||||
}
|
||||
|
||||
function toPowerShellSingleQuotedString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function encodePowerShellCommand(script: string): string {
|
||||
return Buffer.from(script, 'utf16le').toString('base64');
|
||||
}
|
||||
|
||||
export function getPreviewPdfDownloadName(fileName: string, extension: string): string {
|
||||
return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`;
|
||||
}
|
||||
|
||||
function getCacheErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview Best-effort first-page thumbnails for attachment cards.
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, extname, join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { getOfficePreviewPdfPath } from './document-preview-cache.js';
|
||||
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
export interface ThumbnailResult {
|
||||
content: Buffer;
|
||||
contentType: 'image/png';
|
||||
}
|
||||
|
||||
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
|
||||
const ext = extension.toLowerCase().replace(/^\./, '');
|
||||
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
|
||||
if (ext === 'png') {
|
||||
return { content: await fs.readFile(filePath), contentType: 'image/png' };
|
||||
}
|
||||
|
||||
if (ext === 'pdf') {
|
||||
return renderPdfFirstPage(filePath);
|
||||
}
|
||||
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
return renderOfficeFirstPage(filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err));
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderOfficeFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||
try {
|
||||
const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, ''));
|
||||
if (!previewPdfPath) return null;
|
||||
return await renderPdfFirstPage(previewPdfPath);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`,
|
||||
getThumbnailErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPdfFirstPage(filePath: string): Promise<ThumbnailResult | null> {
|
||||
let previewDir: string | undefined;
|
||||
try {
|
||||
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
|
||||
const prefix = join(previewDir, basename(filePath, extname(filePath)));
|
||||
await runWithConversionLimit(() =>
|
||||
execFileAsync('pdftoppm', ['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix], {
|
||||
timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
);
|
||||
const content = await fs.readFile(`${prefix}.png`);
|
||||
return { content, contentType: 'image/png' };
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`,
|
||||
getThumbnailErrorMessage(err)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
if (previewDir) {
|
||||
await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getThumbnailErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
+165
-71
@@ -31,6 +31,30 @@ import { join } from 'node:path';
|
||||
import type { HookEventType } from './types.js';
|
||||
import { HOOK_TIMEOUT_MS } from './config/auth-config.js';
|
||||
|
||||
/**
|
||||
* Serializes read-modify-write access to a `settings.local.json` path. Every
|
||||
* writer in this module (hooks, env, model, statusLine) shares this map, so
|
||||
* concurrent updates to the SAME file — e.g. session-create writing hooks/model
|
||||
* while an App-Settings toggle injects the statusLine into the same repo — can't
|
||||
* lose each other's changes through interleaved read-then-write. Per-path chains
|
||||
* are independent; the map self-prunes when a path's chain goes idle.
|
||||
*/
|
||||
const settingsWriteLocks = new Map<string, Promise<unknown>>();
|
||||
function withSettingsLock<T>(path: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = settingsWriteLocks.get(path) ?? Promise.resolve();
|
||||
const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome
|
||||
// Tail never rejects, so a failed write doesn't poison subsequent writers.
|
||||
const tail = run.then(
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
settingsWriteLocks.set(path, tail);
|
||||
void tail.then(() => {
|
||||
if (settingsWriteLocks.get(path) === tail) settingsWriteLocks.delete(path);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the hooks section for .claude/settings.local.json
|
||||
*
|
||||
@@ -103,29 +127,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
||||
if (keysToRemove.length === 0) return;
|
||||
|
||||
const settingsPath = join(casePath, '.claude', 'settings.local.json');
|
||||
if (!existsSync(settingsPath)) return;
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(settingsPath)) return;
|
||||
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
|
||||
const env = existing.env as Record<string, string> | undefined;
|
||||
if (!env) return;
|
||||
|
||||
let changed = false;
|
||||
for (const key of keysToRemove) {
|
||||
if (key in env) {
|
||||
delete env[key];
|
||||
changed = true;
|
||||
let existing: Record<string, unknown>;
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
existing.env = env;
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
const env = existing.env as Record<string, string> | undefined;
|
||||
if (!env) return;
|
||||
|
||||
let changed = false;
|
||||
for (const key of keysToRemove) {
|
||||
if (key in env) {
|
||||
delete env[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
existing.env = env;
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,30 +160,31 @@ export async function stripCaseEnvKeys(casePath: string, keysToRemove: readonly
|
||||
*/
|
||||
export async function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
const currentEnv = (existing.env as Record<string, string>) || {};
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
if (value) {
|
||||
currentEnv[key] = value;
|
||||
} else {
|
||||
delete currentEnv[key];
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
existing.env = currentEnv;
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
const currentEnv = (existing.env as Record<string, string>) || {};
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
if (value) {
|
||||
currentEnv[key] = value;
|
||||
} else {
|
||||
delete currentEnv[key];
|
||||
}
|
||||
}
|
||||
existing.env = currentEnv;
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,26 +193,27 @@ export async function updateCaseEnvVars(casePath: string, envVars: Record<string
|
||||
*/
|
||||
export async function updateCaseModel(casePath: string, model: string | null): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
|
||||
if (model) {
|
||||
existing.model = model;
|
||||
} else {
|
||||
delete existing.model;
|
||||
}
|
||||
if (model) {
|
||||
existing.model = model;
|
||||
} else {
|
||||
delete existing.model;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,22 +222,88 @@ export async function updateCaseModel(casePath: string, model: string | null): P
|
||||
*/
|
||||
export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
if (!existsSync(claudeDir)) {
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
// If file is malformed or doesn't exist, start fresh
|
||||
existing = {};
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
// If file is malformed or doesn't exist, start fresh
|
||||
existing = {};
|
||||
}
|
||||
|
||||
const hooksConfig = generateHooksConfig();
|
||||
const merged = { ...existing, ...hooksConfig };
|
||||
const hooksConfig = generateHooksConfig();
|
||||
const merged = { ...existing, ...hooksConfig };
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
||||
|
||||
/**
|
||||
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
|
||||
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
|
||||
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
|
||||
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
|
||||
* (present in every managed session via tmux setenv), so the config is static.
|
||||
*/
|
||||
export function generateStatusLineCommand(): string {
|
||||
// `curl -sk`: CODEMAN_API_URL is loopback HTTPS with a self-signed cert in the
|
||||
// production setup; without -k curl returns 000 and the statusline shows
|
||||
// nothing. -k is safe here (loopback only). Falls back to a brand string so the
|
||||
// footer is never blank if Codeman is unreachable.
|
||||
return (
|
||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- 2>/dev/null || echo codeman`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove Codeman's plan-usage statusLine exporter in
|
||||
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
||||
* (command targets `/api/status-telemetry`), so a user's hand-authored
|
||||
* statusLine is never removed OR overwritten — on both the enable and disable
|
||||
* paths we bail out when an existing statusLine isn't ours. Callers gate on
|
||||
* Claude mode. Merges, preserving all other keys (hooks, env, model).
|
||||
*/
|
||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
|
||||
await withSettingsLock(settingsPath, async () => {
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
}
|
||||
|
||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
|
||||
|
||||
if (enabled) {
|
||||
const desired = generateStatusLineCommand();
|
||||
if (isOurs && current?.command === desired) return; // already current — skip rewrite
|
||||
if (current && !isOurs) return; // user has their OWN statusLine — never clobber it
|
||||
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
||||
existing.statusLine = { type: 'command', command: desired }; // add, or update an out-of-date ours
|
||||
} else {
|
||||
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
||||
delete existing.statusLine;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,12 +20,8 @@ import { KeyedDebouncer } from './utils/index.js';
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Supported image file extensions (lowercase) */
|
||||
// PNG stays on the image-popup path: it's the dominant screenshot format and the
|
||||
// frontend only wires the `image:detected` popup today. The attachment-card UI
|
||||
// that would consume `attachment:detected` for images is out of scope for this
|
||||
// PR, so routing PNG to it would silently break the dropped-screenshot popup.
|
||||
const IMAGE_POPUP_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||
const ATTACHMENT_EXTENSIONS = new Set(['.pdf', '.docx', '.pptx']);
|
||||
const IMAGE_POPUP_EXTENSIONS = new Set(['.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
|
||||
const ATTACHMENT_EXTENSIONS = new Set(['.png', '.pdf', '.docx', '.pptx']);
|
||||
const DETECTED_FILE_EXTENSIONS = new Set([...IMAGE_POPUP_EXTENSIONS, ...ATTACHMENT_EXTENSIONS]);
|
||||
|
||||
/** Time to wait for file writes to stabilize (ms) */
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename, extname } from 'node:path';
|
||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
||||
import type { AttachmentDetectedEvent } from './types/tools.js';
|
||||
import { getAttachmentType } from './attachment-registry.js';
|
||||
|
||||
export const ATTACHMENT_HISTORY_LIMIT = 100;
|
||||
|
||||
export interface ExternalAttachmentHistoryInput {
|
||||
sessionId: string;
|
||||
externalPath: string;
|
||||
fileName?: string;
|
||||
extension?: string;
|
||||
size: number;
|
||||
mtimeMs?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export function normalizeAttachmentExtension(extensionOrPath: string): string {
|
||||
const value = extensionOrPath.startsWith('.') ? extensionOrPath : extname(extensionOrPath) || extensionOrPath;
|
||||
return value.toLowerCase().replace(/^\./, '');
|
||||
}
|
||||
|
||||
function historyKey(item: SessionAttachmentHistoryItem): string {
|
||||
if (item.source === 'external' && item.externalPath) {
|
||||
return `external:${item.externalPath}`;
|
||||
}
|
||||
return `detected:${item.relativePath || item.fileName}`;
|
||||
}
|
||||
|
||||
function safeExternalHistoryId(item: SessionAttachmentHistoryItem): string {
|
||||
const source = item.externalPath || item.id || item.fileName;
|
||||
const digest = createHash('sha256').update(source).digest('hex').slice(0, 16);
|
||||
return `external:${digest}:${item.fileName}`;
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentHistoryItem(item: SessionAttachmentHistoryItem): SessionAttachmentHistoryItem {
|
||||
const { externalPath: _externalPath, ...safe } = item;
|
||||
return {
|
||||
...safe,
|
||||
id: item.source === 'external' ? safeExternalHistoryId(item) : item.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAttachmentHistory(
|
||||
history: readonly SessionAttachmentHistoryItem[]
|
||||
): SessionAttachmentHistoryItem[] {
|
||||
return history.map(sanitizeAttachmentHistoryItem);
|
||||
}
|
||||
|
||||
export function upsertAttachmentHistory(
|
||||
history: readonly SessionAttachmentHistoryItem[],
|
||||
item: SessionAttachmentHistoryItem
|
||||
): SessionAttachmentHistoryItem[] {
|
||||
const nextKey = historyKey(item);
|
||||
return [item, ...history.filter((existing) => historyKey(existing) !== nextKey)].slice(0, ATTACHMENT_HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
export function buildDetectedAttachmentHistoryItem(event: AttachmentDetectedEvent): SessionAttachmentHistoryItem {
|
||||
return {
|
||||
id: `detected:${event.relativePath || event.fileName}`,
|
||||
sessionId: event.sessionId,
|
||||
fileName: event.fileName,
|
||||
extension: normalizeAttachmentExtension(event.extension),
|
||||
attachmentType: event.attachmentType,
|
||||
size: event.size,
|
||||
mtimeMs: 0,
|
||||
timestamp: event.timestamp,
|
||||
source: 'detected',
|
||||
relativePath: event.relativePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExternalAttachmentHistoryItem(
|
||||
input: ExternalAttachmentHistoryInput
|
||||
): SessionAttachmentHistoryItem {
|
||||
const extension = normalizeAttachmentExtension(input.extension || input.fileName || input.externalPath);
|
||||
return {
|
||||
id: `external:${createHash('sha256').update(input.externalPath).digest('hex').slice(0, 16)}:${
|
||||
input.fileName || basename(input.externalPath)
|
||||
}`,
|
||||
sessionId: input.sessionId,
|
||||
fileName: input.fileName || basename(input.externalPath),
|
||||
extension,
|
||||
attachmentType: getAttachmentType(extension),
|
||||
size: input.size,
|
||||
mtimeMs: input.mtimeMs ?? 0,
|
||||
timestamp: input.timestamp ?? Date.now(),
|
||||
source: 'external',
|
||||
externalPath: input.externalPath,
|
||||
};
|
||||
}
|
||||
@@ -81,6 +81,11 @@ import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { detectUsageLimitPause } from './usage-limit-patterns.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
import { parseAttachmentMagicLinks } from './attachment-magic.js';
|
||||
import {
|
||||
sanitizeAttachmentHistory,
|
||||
upsertAttachmentHistory as upsertAttachmentHistoryList,
|
||||
} from './session-attachment-history.js';
|
||||
import type { SessionAttachmentHistoryItem } from './types/session.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
@@ -314,6 +319,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Bounded dedup set for terminal attachment magic-links already requested.
|
||||
private _attachmentMagicSeen = new Set<string>();
|
||||
private _attachmentHistory: SessionAttachmentHistoryItem[] = [];
|
||||
|
||||
// Nice prioritying configuration
|
||||
private _niceConfig: NiceConfig = { ...DEFAULT_NICE_CONFIG };
|
||||
@@ -405,6 +411,8 @@ export class Session extends EventEmitter {
|
||||
envOverrides?: Record<string, string>;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Restored per-session attachment history. May include server-private external paths. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
}
|
||||
) {
|
||||
super();
|
||||
@@ -471,6 +479,9 @@ export class Session extends EventEmitter {
|
||||
if (config.effort && isEffortLevel(config.effort)) {
|
||||
this._effort = config.effort;
|
||||
}
|
||||
if (config.attachmentHistory && config.attachmentHistory.length > 0) {
|
||||
this.restoreAttachmentHistory(config.attachmentHistory);
|
||||
}
|
||||
|
||||
// Initialize task tracker and forward events (store handlers for cleanup)
|
||||
this._taskTracker = new TaskTracker();
|
||||
@@ -897,6 +908,30 @@ export class Session extends EventEmitter {
|
||||
return this._status === 'idle' || this._status === 'busy';
|
||||
}
|
||||
|
||||
get attachmentHistory(): SessionAttachmentHistoryItem[] {
|
||||
return sanitizeAttachmentHistory(this._attachmentHistory);
|
||||
}
|
||||
|
||||
upsertAttachmentHistory(item: SessionAttachmentHistoryItem): void {
|
||||
this._attachmentHistory = upsertAttachmentHistoryList(this._attachmentHistory, item);
|
||||
}
|
||||
|
||||
restoreAttachmentHistory(history: SessionAttachmentHistoryItem[] | undefined): void {
|
||||
this._attachmentHistory = [];
|
||||
for (const item of [...(history ?? [])].reverse()) {
|
||||
// Guard against malformed/legacy on-disk entries (null, non-object, or
|
||||
// missing required fields). historyKey() dereferences source/fileName, so
|
||||
// a bad item would otherwise throw inside the constructor and abort the
|
||||
// entire mux-recovery loop.
|
||||
if (!item || typeof item !== 'object' || !item.source || !item.fileName) continue;
|
||||
this.upsertAttachmentHistory(item);
|
||||
}
|
||||
}
|
||||
|
||||
getAttachmentHistoryForPersist(): SessionAttachmentHistoryItem[] | undefined {
|
||||
return this._attachmentHistory.length > 0 ? this._attachmentHistory.map((item) => ({ ...item })) : undefined;
|
||||
}
|
||||
|
||||
toState(): SessionState {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -936,6 +971,7 @@ export class Session extends EventEmitter {
|
||||
codexConfig: this._codexConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
// envOverrides intentionally NOT on the public SessionState type — they must not
|
||||
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
|
||||
// can carry secrets). For disk persistence, session-manager calls
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
import type { RespawnConfig } from './respawn.js';
|
||||
import type { AttachmentDetectedType } from './tools.js';
|
||||
|
||||
/** Status of a Claude session */
|
||||
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
@@ -101,6 +102,40 @@ export interface SessionConfig {
|
||||
*/
|
||||
export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink';
|
||||
|
||||
export type SessionAttachmentHistorySource = 'detected' | 'external';
|
||||
|
||||
/**
|
||||
* Session-scoped attachment history entry.
|
||||
*
|
||||
* `externalPath` is server-private. It may be present in the internal persisted
|
||||
* history copy, but API-bound session state must sanitize it before returning
|
||||
* to the browser.
|
||||
*/
|
||||
export interface SessionAttachmentHistoryItem {
|
||||
/** Stable history identity used for dedupe and list rendering */
|
||||
id: string;
|
||||
/** Codeman session ID this item belongs to */
|
||||
sessionId: string;
|
||||
/** Display filename */
|
||||
fileName: string;
|
||||
/** Lowercase extension without a leading dot */
|
||||
extension: string;
|
||||
/** Viewer category used by the web UI */
|
||||
attachmentType: AttachmentDetectedType;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified timestamp in milliseconds, if known */
|
||||
mtimeMs: number;
|
||||
/** Last time this attachment was seen or explicitly published */
|
||||
timestamp: number;
|
||||
/** How the attachment entered the session */
|
||||
source: SessionAttachmentHistorySource;
|
||||
/** Workspace-relative path for detected session files */
|
||||
relativePath?: string;
|
||||
/** Server-private absolute path for explicitly published external files */
|
||||
externalPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current state of a session
|
||||
*/
|
||||
@@ -183,6 +218,8 @@ export interface SessionState {
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort?: EffortLevel;
|
||||
/** Sanitized per-session attachment history. */
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
|
||||
*
|
||||
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
|
||||
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
|
||||
* object with the 5-hour rolling and 7-day weekly plan windows. The
|
||||
* Codeman-managed statusLine exporter (see `hooks-config.generateStatusLineCommand`)
|
||||
* POSTs that blob to `/api/status-telemetry`; these helpers normalize the subset
|
||||
* Codeman displays and format the compact in-terminal footer string.
|
||||
*
|
||||
* Confirmed schema (empirically captured, CC 2.1.177, Claude Max — see
|
||||
* `docs/usage-limits-display-plan.md`):
|
||||
* rate_limits.{five_hour,seven_day}.{used_percentage: number 0-100,
|
||||
* resets_at: number EPOCH-SECONDS}
|
||||
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
|
||||
* before the first API response and for non-subscriber auth — both yield null.
|
||||
*
|
||||
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
|
||||
*
|
||||
* @module usage-telemetry
|
||||
*/
|
||||
|
||||
/** A single normalized plan-usage window. */
|
||||
export interface UsageWindow {
|
||||
/** Percent of the window consumed, 0–100. */
|
||||
usedPercentage: number;
|
||||
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
/** Normalized telemetry Codeman broadcasts to the UI. */
|
||||
export interface StatusTelemetry {
|
||||
fiveHour?: UsageWindow;
|
||||
sevenDay?: UsageWindow;
|
||||
/** Context-window percent used, 0–100 (bonus field from the same payload). */
|
||||
contextUsedPercentage?: number;
|
||||
/** Session cost in USD (bonus field). */
|
||||
costUsd?: number;
|
||||
/** Model display name, e.g. "Opus 4.8 (1M context)" (bonus field). */
|
||||
modelDisplayName?: string;
|
||||
}
|
||||
|
||||
/** Raw subset of the statusline stdin JSON (snake_case, as Claude emits it). */
|
||||
export interface RawStatuslinePayload {
|
||||
rate_limits?: {
|
||||
five_hour?: { used_percentage?: number; resets_at?: number };
|
||||
seven_day?: { used_percentage?: number; resets_at?: number };
|
||||
};
|
||||
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
|
||||
cost?: { total_cost_usd?: number };
|
||||
model?: { display_name?: string };
|
||||
}
|
||||
|
||||
function clampPct(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function parseWindow(w?: { used_percentage?: number; resets_at?: number }): UsageWindow | undefined {
|
||||
if (!w || typeof w.used_percentage !== 'number' || typeof w.resets_at !== 'number') return undefined;
|
||||
if (!Number.isFinite(w.resets_at) || w.resets_at <= 0) return undefined;
|
||||
return { usedPercentage: clampPct(w.used_percentage), resetAt: Math.round(w.resets_at * 1000) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw statusline payload to the telemetry Codeman displays. Returns
|
||||
* null when there is no plan-limit data to show (pre-first-response or a
|
||||
* non-subscriber account) so the caller can skip broadcasting.
|
||||
*/
|
||||
export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): StatusTelemetry | null {
|
||||
if (!data) return null;
|
||||
const fiveHour = parseWindow(data.rate_limits?.five_hour);
|
||||
const sevenDay = parseWindow(data.rate_limits?.seven_day);
|
||||
if (!fiveHour && !sevenDay) return null;
|
||||
|
||||
const t: StatusTelemetry = {};
|
||||
if (fiveHour) t.fiveHour = fiveHour;
|
||||
if (sevenDay) t.sevenDay = sevenDay;
|
||||
if (typeof data.context_window?.used_percentage === 'number') {
|
||||
t.contextUsedPercentage = clampPct(data.context_window.used_percentage);
|
||||
}
|
||||
if (typeof data.cost?.total_cost_usd === 'number' && Number.isFinite(data.cost.total_cost_usd)) {
|
||||
t.costUsd = data.cost.total_cost_usd;
|
||||
}
|
||||
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
|
||||
t.modelDisplayName = data.model.display_name.slice(0, 60);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current-session status for the in-terminal statusline footer. This is the
|
||||
* "status of the current session" the user sees in Claude's footer — distinct
|
||||
* from the account-wide plan limits, which live ONLY in the Codeman header chip.
|
||||
*/
|
||||
export interface SessionStatus {
|
||||
modelDisplayName?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
contextUsedPercentage?: number;
|
||||
}
|
||||
|
||||
/** Group a non-negative integer with thousands separators: 562411 → "562,411". */
|
||||
function withCommas(n: number): string {
|
||||
return Math.max(0, Math.round(n))
|
||||
.toString()
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
}
|
||||
|
||||
/** Extract current-session status (footer) from the raw payload. */
|
||||
export function parseSessionStatus(data: RawStatuslinePayload | undefined): SessionStatus | null {
|
||||
if (!data) return null;
|
||||
const s: SessionStatus = {};
|
||||
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
|
||||
s.modelDisplayName = data.model.display_name.slice(0, 60);
|
||||
}
|
||||
const cw = data.context_window;
|
||||
if (typeof cw?.total_input_tokens === 'number' && Number.isFinite(cw.total_input_tokens)) {
|
||||
s.inputTokens = Math.max(0, cw.total_input_tokens);
|
||||
}
|
||||
if (typeof cw?.total_output_tokens === 'number' && Number.isFinite(cw.total_output_tokens)) {
|
||||
s.outputTokens = Math.max(0, cw.total_output_tokens);
|
||||
}
|
||||
if (typeof cw?.used_percentage === 'number') {
|
||||
s.contextUsedPercentage = clampPct(cw.used_percentage);
|
||||
}
|
||||
return Object.keys(s).length ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the in-terminal statusline footer: the CURRENT SESSION's status —
|
||||
* `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits,
|
||||
* which live in the Codeman header chip. Claude requires a statusLine command to
|
||||
* emit the rate_limits JSON at all, so this is what that command prints back.
|
||||
*/
|
||||
export function formatSessionStatusText(s: SessionStatus | null): string {
|
||||
if (!s) return 'codeman';
|
||||
const groups: string[] = [];
|
||||
if (s.modelDisplayName) groups.push(s.modelDisplayName);
|
||||
const tok: string[] = [];
|
||||
if (s.inputTokens != null) tok.push(`in:${withCommas(s.inputTokens)}`);
|
||||
if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`);
|
||||
if (tok.length) groups.push(tok.join(' '));
|
||||
if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`);
|
||||
return groups.length ? groups.join(' ') : 'codeman';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature for change-detection — the statusline fires on every
|
||||
* assistant message, so the route only rebroadcasts when this value changes.
|
||||
*
|
||||
* Keys on EXACTLY the values the header chip displays: the two windows' ROUNDED
|
||||
* percentages (the chip renders `Math.round`) + their reset times. Deliberately
|
||||
* excludes contextUsedPercentage / costUsd / modelDisplayName — none are shown
|
||||
* in the chip, and contextUsedPercentage in particular drifts on every assistant
|
||||
* message, which would defeat the dedup and fan out a redundant SSE broadcast +
|
||||
* localStorage write + identical chip re-render each time.
|
||||
*/
|
||||
export function telemetrySignature(t: StatusTelemetry): string {
|
||||
return JSON.stringify([
|
||||
t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null,
|
||||
t.fiveHour?.resetAt ?? null,
|
||||
t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null,
|
||||
t.sevenDay?.resetAt ?? null,
|
||||
]);
|
||||
}
|
||||
@@ -107,8 +107,9 @@ export function registerAuthMiddleware(
|
||||
}
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events come from local Claude Code hooks (curl from localhost) — no
|
||||
// Basic-Auth credentials available. Validated downstream by HookEventSchema.
|
||||
// Hook events + statusline telemetry come from local Claude Code (curl from
|
||||
// localhost) — no Basic-Auth credentials available. Validated downstream by
|
||||
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
|
||||
//
|
||||
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
|
||||
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
|
||||
@@ -119,7 +120,7 @@ export function registerAuthMiddleware(
|
||||
// - tunnel not running (loopback-only, the normal case) → keep the plain
|
||||
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
|
||||
// credential-less hook channel keep working.
|
||||
if (req.url === '/api/hook-event' && req.method === 'POST') {
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
|
||||
*
|
||||
* The status-telemetry route writes the latest broadcast value here; the SSE
|
||||
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
|
||||
* chip shows immediately on a fresh page load / SSE reconnect — before any new
|
||||
* statusline render arrives, and without relying on per-browser localStorage.
|
||||
*
|
||||
* Null until the first telemetry of the process; cleared naturally on restart.
|
||||
*
|
||||
* @module plan-usage-latest
|
||||
*/
|
||||
|
||||
let latest: Record<string, unknown> | null = null;
|
||||
|
||||
export function setLatestPlanUsage(value: Record<string, unknown>): void {
|
||||
latest = value;
|
||||
}
|
||||
|
||||
export function getLatestPlanUsage(): Record<string, unknown> | null {
|
||||
return latest;
|
||||
}
|
||||
@@ -157,6 +157,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
|
||||
// Scheduled runs
|
||||
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
|
||||
@@ -222,6 +223,7 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Images
|
||||
[SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'],
|
||||
[SSE_EVENTS.ATTACHMENT_DETECTED, '_onAttachmentDetected'],
|
||||
|
||||
// Tunnel
|
||||
[SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'],
|
||||
@@ -386,6 +388,11 @@ class CodemanApp {
|
||||
// Image popup windows (auto-open for detected screenshots/images)
|
||||
this.imagePopups = new Map(); // Map<imageId, { element, sessionId, filePath }>
|
||||
this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE;
|
||||
this.attachmentCards = new Map(); // Map<attachmentId, { element, sessionId, filePath }>
|
||||
this.attachmentCardStack = null;
|
||||
this.attachmentHistoryCounts = new Map(); // Map<sessionId, count>
|
||||
this.attachmentHistoryItems = [];
|
||||
this.attachmentHistoryDrawerOpen = false;
|
||||
|
||||
// File browser state (methods in panels-ui.js)
|
||||
this.fileBrowserData = null;
|
||||
@@ -580,6 +587,7 @@ class CodemanApp {
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.restorePlanUsageChip();
|
||||
this.applySkin();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
@@ -759,6 +767,7 @@ class CodemanApp {
|
||||
if (e.key === 'Escape') {
|
||||
this.closeAllPanels();
|
||||
this.closeHelp();
|
||||
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
|
||||
}
|
||||
|
||||
// Alt+1-9: switch to Codeman session by index
|
||||
@@ -1812,6 +1821,58 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
|
||||
// sample from any session drives the shared header chip.
|
||||
_onSessionStatusTelemetry(data) {
|
||||
this.updatePlanUsageChip(data);
|
||||
// Persist last-known so the chip shows immediately on the next page load /
|
||||
// SSE reconnect, instead of staying blank until a session next renders.
|
||||
try {
|
||||
localStorage.setItem('codeman:planUsage', JSON.stringify({ t: Date.now(), data }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Repopulate the chip from the last-known value on page load (account-global,
|
||||
// slow-moving; ignored if older than 12h). Live events refresh it.
|
||||
restorePlanUsageChip() {
|
||||
try {
|
||||
const raw = localStorage.getItem('codeman:planUsage');
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw);
|
||||
if (saved?.data && Date.now() - (saved.t || 0) < 12 * 3600 * 1000) {
|
||||
this.updatePlanUsageChip(saved.data);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
updatePlanUsageChip(data) {
|
||||
const chip = document.getElementById('planUsageChip');
|
||||
if (!chip || !data) return;
|
||||
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
|
||||
const five = pct(data.fiveHour);
|
||||
const seven = pct(data.sevenDay);
|
||||
if (five === null && seven === null) return;
|
||||
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
|
||||
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
|
||||
// innerHTML here is XSS-safe ONLY because every interpolated value is a
|
||||
// coerced finite number and the labels/classes are fixed literals. If a
|
||||
// string field (e.g. modelDisplayName, which the route also broadcasts) is
|
||||
// ever shown in this chip, render it via textContent — never interpolate an
|
||||
// untrusted string into this template.
|
||||
const seg = (label, p) => {
|
||||
if (p === null) return '';
|
||||
const n = Math.round(Number(p));
|
||||
if (!Number.isFinite(n)) return '';
|
||||
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
|
||||
};
|
||||
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</span>');
|
||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||
chip.title =
|
||||
`Claude plan usage\n` +
|
||||
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
|
||||
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
|
||||
}
|
||||
|
||||
// Scheduled runs
|
||||
_onScheduledCreated(data) {
|
||||
this.currentRun = data;
|
||||
@@ -2165,6 +2226,7 @@ class CodemanApp {
|
||||
KeyboardHandler.init();
|
||||
// Clear tab alerts
|
||||
this.tabAlerts.clear();
|
||||
this.attachmentHistoryCounts.clear();
|
||||
// Clear shown completions (used for duplicate notification prevention)
|
||||
if (this._shownCompletions) {
|
||||
this._shownCompletions.clear();
|
||||
@@ -2210,6 +2272,10 @@ class CodemanApp {
|
||||
this._serverCjkOverride = data.inputCjkForm || false;
|
||||
this._updateCjkInputState();
|
||||
|
||||
// Plan-usage chip: server's last-known telemetry, so it shows immediately on
|
||||
// a fresh load / reconnect (authoritative; wins over the localStorage restore).
|
||||
if (data.planUsage) this.updatePlanUsageChip(data.planUsage);
|
||||
|
||||
// Update version displays (header and toolbar)
|
||||
if (data.version) {
|
||||
const versionEl = this.$('versionDisplay');
|
||||
@@ -3164,6 +3230,10 @@ class CodemanApp {
|
||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||
this._updateActiveTabImmediate(sessionId);
|
||||
this.renderSessionTabs();
|
||||
this.updateAttachmentHistoryBadge?.();
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this.loadAttachmentHistory?.(sessionId);
|
||||
}
|
||||
this._updateLocalEchoState();
|
||||
|
||||
// Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
|
||||
@@ -3534,10 +3604,15 @@ class CodemanApp {
|
||||
this.projectInsights.delete(sessionId);
|
||||
this.pendingHooks.delete(sessionId);
|
||||
this.tabAlerts.delete(sessionId);
|
||||
this.attachmentHistoryCounts.delete(sessionId);
|
||||
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === sessionId) {
|
||||
this.closeAttachmentHistory?.();
|
||||
}
|
||||
this.terminalLoadStates.delete(sessionId);
|
||||
this.clearCountdownTimers(sessionId);
|
||||
this.closeSessionLogViewerWindows(sessionId);
|
||||
this.closeSessionImagePopups(sessionId);
|
||||
this.closeSessionAttachmentCards(sessionId);
|
||||
this.closeSessionSubagentWindows(sessionId, true);
|
||||
|
||||
// Clean up idle timer
|
||||
|
||||
@@ -251,6 +251,7 @@ const SSE_EVENTS = {
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
|
||||
+22
-15
@@ -83,7 +83,7 @@
|
||||
<!-- Detached single-session window title (shown only in solo mode) -->
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right mobile-collapsed" id="headerRight">
|
||||
<div class="header-right" id="headerRight">
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
@@ -114,7 +114,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -976,6 +981,13 @@
|
||||
|
||||
<!-- Header Displays Section -->
|
||||
<div class="settings-section-header">Header Displays</div>
|
||||
<div class="settings-item" title="Show Claude plan usage limits (5-hour & weekly) in the header. Applies to newly created sessions.">
|
||||
<span class="settings-item-label">Plan Usage Limits</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowPlanUsageLimits">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show A-/A+ font size buttons in header">
|
||||
<span class="settings-item-label">Font Controls</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -990,20 +1002,6 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show total token count in header">
|
||||
<span class="settings-item-label">Token Count</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowTokenCount" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show estimated cost next to token count">
|
||||
<span class="settings-item-label">Show Cost ($)</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowCost">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show session lifecycle log button in header">
|
||||
<span class="settings-item-label">Lifecycle Log</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1018,6 +1016,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the attachments button in header (opens the attachment history drawer)">
|
||||
<span class="settings-item-label">Attachments Button</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowAttachmentsButton">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
|
||||
<span class="settings-item-label">Multi-monitor Button</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -1178,6 +1183,8 @@
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="opus[1m]">Opus (1M context)</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-opus-4-6[1m]">Opus 4.6 (1M context)</option>
|
||||
<option value="claude-opus-4-6">Opus 4.6</option>
|
||||
<option value="sonnet">Sonnet</option>
|
||||
<option value="haiku">Haiku</option>
|
||||
</select>
|
||||
|
||||
+52
-47
@@ -101,31 +101,12 @@ html.mobile-init .file-browser-panel {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Inline, always-visible header utilities (eye / multimonitor). The
|
||||
position:fixed collapsible tray (02fa3f3) had its expand toggle reverted on
|
||||
master but left this container permanently `mobile-collapsed` → the response
|
||||
viewer eye became unreachable on phones. Restored to the simple inline flow. */
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(52px + var(--safe-area-top));
|
||||
left: calc(0.5rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: calc(100vw - 1rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.35rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.btn-icon-header:not(.btn-sm) {
|
||||
@@ -435,32 +416,10 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
|
||||
.header-right {
|
||||
position: fixed;
|
||||
top: calc(40px + var(--safe-area-top));
|
||||
left: calc(0.3rem + var(--safe-area-left));
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 0.2rem;
|
||||
gap: 0.1rem;
|
||||
max-width: calc(100vw - 0.6rem - var(--safe-area-left) - var(--safe-area-right));
|
||||
padding: 0.25rem;
|
||||
background: rgba(10, 10, 10, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
flex-shrink: 0;
|
||||
border-left: none;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.header-right::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-right.mobile-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Smaller header buttons on mobile */
|
||||
@@ -475,7 +434,9 @@ html.mobile-init .file-browser-panel {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar */
|
||||
/* Hide header settings gear and lifecycle log on mobile - settings moved to toolbar.
|
||||
(The attachments button is opt-in / default-hidden everywhere via its own
|
||||
--hidden marker, so it needs no mobile-specific rule here.) */
|
||||
.btn-icon-header.btn-settings,
|
||||
.btn-icon-header.btn-lifecycle-log {
|
||||
display: none !important;
|
||||
@@ -2346,3 +2307,47 @@ html.mobile-init .file-browser-panel {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
/* Attachment history (COD-18): full-screen sheet on phones */
|
||||
.attachment-history-drawer {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
border-left: none;
|
||||
border-radius: 0;
|
||||
padding-top: var(--safe-area-top);
|
||||
padding-left: var(--safe-area-left);
|
||||
padding-right: var(--safe-area-right);
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
}
|
||||
|
||||
.attachment-history-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.attachment-history-list {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.attachment-history-item {
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.attachment-history-thumb {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
.attachment-history-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.attachment-history-actions button {
|
||||
padding: 4px 7px;
|
||||
}
|
||||
}
|
||||
|
||||
+483
-3
@@ -2443,8 +2443,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
},
|
||||
|
||||
async openFilePreview(filePath) {
|
||||
if (!this.activeSessionId || !filePath) return;
|
||||
async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) {
|
||||
if (!sessionId || !filePath) return;
|
||||
|
||||
const overlay = this.$('filePreviewOverlay');
|
||||
const titleEl = this.$('filePreviewTitle');
|
||||
@@ -2459,8 +2459,53 @@ Object.assign(CodemanApp.prototype, {
|
||||
bodyEl.innerHTML = '<div class="binary-message">Loading...</div>';
|
||||
footerEl.textContent = '';
|
||||
|
||||
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
||||
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
if (IMAGE_EXTS.has(ext)) {
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(`${base}/raw`)}" alt="${escapeHtml(filePath)}">`;
|
||||
} else if (ext === 'pdf') {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/raw`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else if (ext === 'docx' || ext === 'pptx') {
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(`${base}/preview`)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${base}/raw`);
|
||||
if (!res.ok) throw new Error('Failed to load attachment');
|
||||
const text = await res.text();
|
||||
bodyEl.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Workspace-path (auto-detected, unregistered) attachments: Office docs are
|
||||
// converted to PDF server-side via the file-preview route; PDFs stream raw.
|
||||
// Both render inline in an iframe. Without this, docx/pptx/pdf fall through
|
||||
// to file-content below, which would dump the binary bytes as mojibake.
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
if (ext === 'pdf') {
|
||||
footerEl.textContent = 'PDF';
|
||||
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${this.activeSessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`);
|
||||
if (!res.ok) throw new Error('Failed to load file');
|
||||
|
||||
const result = await res.json();
|
||||
@@ -2498,6 +2543,441 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.filePreviewContent = '';
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Attachment Cards (detected documents/images)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// SSE `attachment:detected` consumer: surface a dismissible card for the file
|
||||
// and bump the per-session history unread count (refreshing the open drawer).
|
||||
_onAttachmentDetected(data) {
|
||||
console.log('[Attachment Detected]', data);
|
||||
this.addAttachmentCard(data);
|
||||
if (data.sessionId) {
|
||||
const current =
|
||||
this.attachmentHistoryCounts.get(data.sessionId) ??
|
||||
this.sessions.get(data.sessionId)?.attachmentHistory?.length ??
|
||||
0;
|
||||
this.attachmentHistoryCounts.set(data.sessionId, Math.min(current + 1, 100));
|
||||
if (data.sessionId === this.activeSessionId) {
|
||||
this.updateAttachmentHistoryBadge();
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this._debouncedCall(
|
||||
'attachmentHistoryRefresh',
|
||||
() => {
|
||||
// The drawer may have closed or the active session changed during
|
||||
// the debounce window — don't refresh for a stale session.
|
||||
if (this.attachmentHistoryDrawerOpen && this.activeSessionId === data.sessionId) {
|
||||
this.loadAttachmentHistory(data.sessionId);
|
||||
}
|
||||
},
|
||||
250
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Lazily create the floating stack the cards live in (appended to <body>).
|
||||
ensureAttachmentCardStack() {
|
||||
let stack = this.attachmentCardStack || document.getElementById('attachmentCardStack');
|
||||
if (!stack) {
|
||||
stack = document.createElement('div');
|
||||
stack.id = 'attachmentCardStack';
|
||||
stack.className = 'attachment-card-stack';
|
||||
document.body.appendChild(stack);
|
||||
}
|
||||
this.attachmentCardStack = stack;
|
||||
return stack;
|
||||
},
|
||||
|
||||
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
|
||||
const url = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
addAttachmentCard(attachmentEvent) {
|
||||
const {
|
||||
sessionId,
|
||||
relativePath,
|
||||
fileName,
|
||||
timestamp,
|
||||
size,
|
||||
attachmentType,
|
||||
extension,
|
||||
attachmentId,
|
||||
rawUrl,
|
||||
previewUrl,
|
||||
thumbnailUrl,
|
||||
} = attachmentEvent;
|
||||
const filePath = relativePath || fileName;
|
||||
const cardId = attachmentId || `${sessionId}-${timestamp}-${fileName}`;
|
||||
|
||||
if (this.attachmentCards.has(cardId)) {
|
||||
const existing = this.attachmentCards.get(cardId);
|
||||
existing.element.focus?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const MAX_ATTACHMENT_CARDS = 10;
|
||||
if (this.attachmentCards.size >= MAX_ATTACHMENT_CARDS) {
|
||||
const oldestId = this.attachmentCards.keys().next().value;
|
||||
if (oldestId) this.closeAttachmentCard(oldestId);
|
||||
}
|
||||
|
||||
const stack = this.ensureAttachmentCardStack();
|
||||
const session = this.sessions.get(sessionId);
|
||||
const sessionName = session?.name || sessionId.substring(0, 8);
|
||||
const attachmentRawUrl =
|
||||
rawUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
const attachmentPreviewUrl =
|
||||
previewUrl ||
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
|
||||
const attachmentThumbnailUrl =
|
||||
thumbnailUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
|
||||
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
|
||||
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = `attachment-card attachment-${escapeHtml(attachmentType || 'file')}`;
|
||||
card.tabIndex = 0;
|
||||
card.dataset.attachmentId = cardId;
|
||||
card.dataset.previewUrl = attachmentPreviewUrl || '';
|
||||
card.innerHTML = `
|
||||
<div class="attachment-thumbnail">
|
||||
${attachmentThumbnailUrl ? `<img class="attachment-thumbnail-img" src="${escapeHtml(attachmentThumbnailUrl)}" alt="">` : ''}
|
||||
<div class="attachment-thumbnail-fallback ${attachmentThumbnailUrl ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
|
||||
</div>
|
||||
<div class="attachment-card-main">
|
||||
<div class="attachment-file-name" title="${escapeHtml(filePath)}">${escapeHtml(fileName)}</div>
|
||||
<div class="attachment-file-meta">
|
||||
<span>${escapeHtml(sessionName)}</span>
|
||||
<span>${this.formatFileSize(size || 0)}</span>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
<button type="button" class="attachment-preview-btn">Preview</button>
|
||||
<a href="${escapeHtml(downloadUrl)}">Download</a>
|
||||
<button type="button" class="attachment-open-btn">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="attachment-close-btn" title="Dismiss">×</button>
|
||||
`;
|
||||
|
||||
const attachmentThumbnailImg = card.querySelector('.attachment-thumbnail-img');
|
||||
if (attachmentThumbnailImg) {
|
||||
attachmentThumbnailImg.onerror = () => {
|
||||
attachmentThumbnailImg.remove();
|
||||
card.querySelector('.attachment-thumbnail-fallback')?.classList.add('visible');
|
||||
};
|
||||
}
|
||||
|
||||
card.querySelector('.attachment-preview-btn')?.addEventListener('click', () => {
|
||||
this.openFilePreview(filePath, sessionId, attachmentId || null);
|
||||
});
|
||||
card.querySelector('.attachment-open-btn')?.addEventListener('click', () => {
|
||||
this.openAttachmentInNewTab(sessionId, filePath, attachmentId || null);
|
||||
});
|
||||
card.querySelector('.attachment-close-btn')?.addEventListener('click', () => {
|
||||
this.closeAttachmentCard(cardId);
|
||||
});
|
||||
|
||||
stack.prepend(card);
|
||||
this.attachmentCards.set(cardId, { element: card, sessionId, filePath });
|
||||
this._refreshAttachmentClearAll();
|
||||
},
|
||||
|
||||
// Centralized show/hide for the stack's "Clear all" control. Both addAttachmentCard and
|
||||
// closeAttachmentCard call this so the control appears on the 2nd card and hides at <=1.
|
||||
_refreshAttachmentClearAll() {
|
||||
const stack = this.attachmentCardStack;
|
||||
if (!stack) return;
|
||||
let control = stack.querySelector('.attachment-clear-all');
|
||||
if (this.attachmentCards.size < 2) {
|
||||
if (control) control.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (!control) {
|
||||
control = document.createElement('button');
|
||||
control.type = 'button';
|
||||
control.className = 'attachment-clear-all';
|
||||
control.textContent = 'Clear all';
|
||||
control.title = 'Dismiss all attachment cards';
|
||||
control.addEventListener('click', () => this.closeAllAttachmentCards());
|
||||
stack.prepend(control);
|
||||
}
|
||||
control.hidden = false;
|
||||
},
|
||||
|
||||
closeAttachmentCard(attachmentId) {
|
||||
const cardData = this.attachmentCards.get(attachmentId);
|
||||
if (!cardData) return;
|
||||
cardData.element.remove();
|
||||
this.attachmentCards.delete(attachmentId);
|
||||
if (this.attachmentCardStack && this.attachmentCards.size === 0) {
|
||||
this.attachmentCardStack.remove();
|
||||
this.attachmentCardStack = null;
|
||||
} else {
|
||||
this._refreshAttachmentClearAll();
|
||||
}
|
||||
},
|
||||
|
||||
closeAllAttachmentCards() {
|
||||
for (const attachmentId of [...this.attachmentCards.keys()]) {
|
||||
this.closeAttachmentCard(attachmentId);
|
||||
}
|
||||
},
|
||||
|
||||
closeSessionAttachmentCards(sessionId) {
|
||||
const toClose = [];
|
||||
for (const [attachmentId, data] of this.attachmentCards) {
|
||||
if (data.sessionId === sessionId) toClose.push(attachmentId);
|
||||
}
|
||||
for (const attachmentId of toClose) {
|
||||
this.closeAttachmentCard(attachmentId);
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Attachment History Drawer
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
updateAttachmentHistoryBadge(count = null) {
|
||||
const badge = document.getElementById('attachmentHistoryBadge');
|
||||
const button = document.getElementById('attachmentsHistoryBtn');
|
||||
const sessionId = this.activeSessionId;
|
||||
const nextCount = count ?? (sessionId ? this.attachmentHistoryCounts.get(sessionId) || 0 : 0);
|
||||
if (badge) {
|
||||
badge.textContent = nextCount > 99 ? '99+' : String(nextCount);
|
||||
badge.style.display = nextCount > 0 ? '' : 'none';
|
||||
}
|
||||
if (button) {
|
||||
button.classList.toggle('active', this.attachmentHistoryDrawerOpen);
|
||||
button.setAttribute('aria-expanded', this.attachmentHistoryDrawerOpen ? 'true' : 'false');
|
||||
}
|
||||
},
|
||||
|
||||
ensureAttachmentHistoryDrawer() {
|
||||
let drawer = document.getElementById('attachmentHistoryDrawer');
|
||||
if (drawer) return drawer;
|
||||
|
||||
drawer = document.createElement('aside');
|
||||
drawer.id = 'attachmentHistoryDrawer';
|
||||
drawer.className = 'attachment-history-drawer';
|
||||
drawer.setAttribute('aria-label', 'Attachment history');
|
||||
drawer.innerHTML = `
|
||||
<div class="attachment-history-header">
|
||||
<div>
|
||||
<div class="attachment-history-title">Attachments</div>
|
||||
<div class="attachment-history-subtitle" id="attachmentHistorySubtitle">0 files</div>
|
||||
</div>
|
||||
<div class="attachment-history-header-actions">
|
||||
<button type="button" class="btn-icon-sm" id="attachmentHistoryRefreshBtn" title="Refresh" aria-label="Refresh attachments">↻</button>
|
||||
<button type="button" class="btn-icon-sm" id="attachmentHistoryCloseBtn" title="Close" aria-label="Close attachments">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-history-list" id="attachmentHistoryList"></div>
|
||||
`;
|
||||
document.body.appendChild(drawer);
|
||||
drawer.querySelector('#attachmentHistoryRefreshBtn')?.addEventListener('click', () => {
|
||||
this.loadAttachmentHistory(this.activeSessionId);
|
||||
});
|
||||
drawer.querySelector('#attachmentHistoryCloseBtn')?.addEventListener('click', () => {
|
||||
this.closeAttachmentHistory();
|
||||
});
|
||||
return drawer;
|
||||
},
|
||||
|
||||
async toggleAttachmentHistory() {
|
||||
if (this.attachmentHistoryDrawerOpen) {
|
||||
this.closeAttachmentHistory();
|
||||
return;
|
||||
}
|
||||
await this.openAttachmentHistory();
|
||||
},
|
||||
|
||||
async openAttachmentHistory() {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
this.attachmentHistoryDrawerOpen = true;
|
||||
drawer.classList.add('open');
|
||||
this.updateAttachmentHistoryBadge();
|
||||
await this.loadAttachmentHistory(this.activeSessionId);
|
||||
},
|
||||
|
||||
closeAttachmentHistory() {
|
||||
const drawer = document.getElementById('attachmentHistoryDrawer');
|
||||
this.attachmentHistoryDrawerOpen = false;
|
||||
drawer?.classList.remove('open');
|
||||
// Cancel any pending debounced refresh so it can't fire against a closed drawer.
|
||||
if (this._debounceTimers?.attachmentHistoryRefresh) {
|
||||
clearTimeout(this._debounceTimers.attachmentHistoryRefresh);
|
||||
this._debounceTimers.attachmentHistoryRefresh = null;
|
||||
}
|
||||
this.updateAttachmentHistoryBadge();
|
||||
},
|
||||
|
||||
async loadAttachmentHistory(sessionId = this.activeSessionId) {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
const list = drawer.querySelector('#attachmentHistoryList');
|
||||
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
|
||||
if (!list || !subtitle) return;
|
||||
|
||||
if (!sessionId) {
|
||||
this.attachmentHistoryItems = [];
|
||||
subtitle.textContent = 'No session';
|
||||
list.innerHTML = '<div class="attachment-history-empty">No active session</div>';
|
||||
this.updateAttachmentHistoryBadge(0);
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = '<div class="attachment-history-empty">Loading...</div>';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/attachments`);
|
||||
if (!res.ok) throw new Error('Failed to load attachments');
|
||||
const result = await res.json();
|
||||
if (!result.success) throw new Error(result.error || 'Failed to load attachments');
|
||||
const items = result.data?.items || [];
|
||||
this.attachmentHistoryItems = items;
|
||||
this.attachmentHistoryCounts.set(sessionId, items.length);
|
||||
this.updateAttachmentHistoryBadge(items.length);
|
||||
this.renderAttachmentHistory(items);
|
||||
} catch (err) {
|
||||
console.error('Failed to load attachment history:', err);
|
||||
subtitle.textContent = 'Unavailable';
|
||||
list.innerHTML = `<div class="attachment-history-empty">Error: ${escapeHtml(err.message)}</div>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderAttachmentHistory(items = this.attachmentHistoryItems || []) {
|
||||
const drawer = this.ensureAttachmentHistoryDrawer();
|
||||
const list = drawer.querySelector('#attachmentHistoryList');
|
||||
const subtitle = drawer.querySelector('#attachmentHistorySubtitle');
|
||||
if (!list || !subtitle) return;
|
||||
|
||||
subtitle.textContent = `${items.length} ${items.length === 1 ? 'file' : 'files'}`;
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div class="attachment-history-empty">
|
||||
<div class="attachment-history-empty-title">No attachments yet</div>
|
||||
<div>Show a file here by running:</div>
|
||||
<code>codeman attach /absolute/path/to/file.pptx</code>
|
||||
<div>Supports .pptx, .docx, .pdf, .png, .md, and .txt.</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map((item) => this.renderAttachmentHistoryItem(item)).join('');
|
||||
list.querySelectorAll('.attachment-history-thumb-img').forEach((img) => {
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
const fallback = img.closest('.attachment-history-thumb')?.querySelector('.attachment-history-thumb-fallback');
|
||||
fallback?.classList.add('visible');
|
||||
};
|
||||
});
|
||||
list.querySelectorAll('[data-attachment-action]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-history-id');
|
||||
const action = button.getAttribute('data-attachment-action');
|
||||
if (!id || !action) return;
|
||||
if (action === 'preview') this.previewAttachmentHistoryItem(id);
|
||||
if (action === 'download') this.downloadAttachmentHistoryItem(id);
|
||||
if (action === 'open') this.openAttachmentHistoryItem(id);
|
||||
if (action === 'reshow') this.reshowAttachmentCard(id);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
renderAttachmentHistoryItem(item) {
|
||||
const typeLabel = (item.extension || item.attachmentType || 'file').toUpperCase();
|
||||
const meta = [
|
||||
item.source === 'external' ? 'published' : 'workspace',
|
||||
this.formatFileSize(item.size || 0),
|
||||
item.missing ? 'missing' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • ');
|
||||
const thumb =
|
||||
item.thumbnailUrl && !item.missing
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
|
||||
: '';
|
||||
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
|
||||
return `
|
||||
<div class="attachment-history-item ${item.missing ? 'missing' : ''}" data-history-item="${escapeHtml(item.id)}">
|
||||
<div class="attachment-history-thumb">
|
||||
${thumb}
|
||||
<div class="attachment-history-thumb-fallback ${thumb ? '' : 'visible'}">${escapeHtml(typeLabel)}</div>
|
||||
</div>
|
||||
<div class="attachment-history-item-main">
|
||||
<div class="attachment-history-file-name" title="${escapeHtml(item.fileName)}">${escapeHtml(item.fileName)}</div>
|
||||
<div class="attachment-history-meta">${escapeHtml(meta)}</div>
|
||||
<div class="attachment-history-actions">
|
||||
<button type="button" data-attachment-action="preview" data-history-id="${escapeHtml(item.id)}" ${disabled}>Preview</button>
|
||||
<button type="button" data-attachment-action="download" data-history-id="${escapeHtml(item.id)}" ${disabled}>Download</button>
|
||||
<button type="button" data-attachment-action="open" data-history-id="${escapeHtml(item.id)}" ${disabled}>Open</button>
|
||||
<button type="button" data-attachment-action="reshow" data-history-id="${escapeHtml(item.id)}" ${disabled}>Card</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
getAttachmentHistoryItem(itemId) {
|
||||
return (this.attachmentHistoryItems || []).find((item) => item.id === itemId) || null;
|
||||
},
|
||||
|
||||
previewAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
const path = item.relativePath || item.fileName;
|
||||
this.openFilePreview(path, item.sessionId, item.attachmentId || null);
|
||||
// Close the drawer so the preview window is unobstructed.
|
||||
this.closeAttachmentHistory();
|
||||
},
|
||||
|
||||
openAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
if (item.rawUrl || item.url) {
|
||||
window.open(item.rawUrl || item.url, '_blank');
|
||||
return;
|
||||
}
|
||||
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
|
||||
},
|
||||
|
||||
downloadAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing || !item.downloadUrl) return;
|
||||
window.open(item.downloadUrl, '_blank');
|
||||
},
|
||||
|
||||
reshowAttachmentCard(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
this.addAttachmentCard({
|
||||
sessionId: item.sessionId,
|
||||
relativePath: item.relativePath,
|
||||
fileName: item.fileName,
|
||||
// Use the item's own timestamp (not Date.now()) so the derived cardId is
|
||||
// stable across clicks — re-showing focuses the existing card instead of
|
||||
// stacking a duplicate.
|
||||
timestamp: item.timestamp ?? Date.now(),
|
||||
size: item.size,
|
||||
attachmentType: item.attachmentType,
|
||||
extension: item.extension,
|
||||
attachmentId: item.attachmentId,
|
||||
rawUrl: item.rawUrl,
|
||||
previewUrl: item.previewUrl,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
});
|
||||
},
|
||||
|
||||
copyFilePreviewContent() {
|
||||
if (this.filePreviewContent) {
|
||||
navigator.clipboard.writeText(this.filePreviewContent).then(() => {
|
||||
|
||||
@@ -370,6 +370,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
// Plan-usage statusLine exporter (App Settings → Display). The server
|
||||
// ADDS our exporter on create when true; when false it intentionally
|
||||
// leaves any existing exporter in place (a per-repo settings.local.json
|
||||
// is shared by sibling sessions, so create-with-false must not yank it
|
||||
// — see the comment in session-routes create). Disabling the setting
|
||||
// removes it via the App Settings toggle path (system-routes), not here.
|
||||
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
|
||||
@@ -305,16 +305,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false;
|
||||
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true;
|
||||
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? defaults.showTokenCount ?? true;
|
||||
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
|
||||
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1352,7 +1352,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
async saveAppSettings() {
|
||||
// Gesture overlay is injected at page render (server-side), so a change to it
|
||||
// only takes effect on reload — remember the prior value to decide below.
|
||||
const _prevGestureEnabled = (this.loadAppSettingsFromStorage().gestureControlEnabled ?? false) === true;
|
||||
const _prev = this.loadAppSettingsFromStorage();
|
||||
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
|
||||
const settings = {
|
||||
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
|
||||
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
|
||||
@@ -1360,15 +1361,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Header visibility settings
|
||||
showFontControls: document.getElementById('appSettingsShowFontControls').checked,
|
||||
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
|
||||
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
|
||||
showCost: document.getElementById('appSettingsShowCost').checked,
|
||||
showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked,
|
||||
showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked,
|
||||
showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked,
|
||||
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
|
||||
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
|
||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -1396,6 +1397,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
};
|
||||
|
||||
// The "Token Count" / "Show Cost ($)" header toggles were removed from the
|
||||
// UI, but their features still read settings.showTokenCount / settings.showCost
|
||||
// (applyHeaderVisibilitySettings, the header cost render). saveAppSettings
|
||||
// rebuilds `settings` fresh from the DOM (a full replacement, not a merge), so
|
||||
// without this these keys would be DROPPED on every save and fall back to their
|
||||
// defaults — silently re-enabling the token chip for anyone who'd turned it off,
|
||||
// with no UI left to turn it back off. Preserve the prior stored preference.
|
||||
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
|
||||
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
|
||||
|
||||
// Save to localStorage
|
||||
this.saveAppSettingsToStorage(settings);
|
||||
this._updateLocalEchoState();
|
||||
@@ -1506,12 +1517,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Apply keyboard bar mode
|
||||
KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple');
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
|
||||
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, ...serverSettings } = settings;
|
||||
// Save to server (includes notification prefs for cross-browser persistence).
|
||||
// Strip device-specific DISPLAY keys so they never sync across devices —
|
||||
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
|
||||
// is per-device too (desktop can show the usage chip while mobile stays hidden).
|
||||
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
|
||||
// ENABLE only, so a device with the chip OFF never strips the exporter that
|
||||
// another device's chip depends on — see system-routes settings handler).
|
||||
const {
|
||||
localEchoEnabled: _leo,
|
||||
cjkInputEnabled: _cjk,
|
||||
extendedKeyboardBar: _ekb,
|
||||
skin: _skin,
|
||||
showPlanUsageLimits: _pul,
|
||||
showAttachmentsButton: _ahb,
|
||||
...serverSettings
|
||||
} = settings;
|
||||
try {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
...(settings.showPlanUsageLimits ? { statusLineTelemetry: true } : {}),
|
||||
notificationPreferences: notifPrefsToSave,
|
||||
voiceSettings,
|
||||
});
|
||||
@@ -1658,6 +1683,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
showFileBrowser: false,
|
||||
showSubagents: false,
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
showAttachmentsButton: false,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1760,6 +1787,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer);
|
||||
}
|
||||
|
||||
// Hide the attachments (history) button when disabled. Opt-in, default OFF —
|
||||
// marker class, base is display:inline-flex !important.
|
||||
const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
const attachmentsBtn = document.getElementById('attachmentsHistoryBtn');
|
||||
if (attachmentsBtn) {
|
||||
attachmentsBtn.classList.toggle('btn-attachments-history--hidden', !showAttachmentsButton);
|
||||
}
|
||||
|
||||
// Multi-monitor button — hidden by default (App Settings → Display → "Header
|
||||
// Displays"). The server renders the correct initial state on every reload;
|
||||
// this handles a live toggle from a settings save (no reload). Toggle the
|
||||
@@ -1770,6 +1805,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
|
||||
// Limits"). Server renders the initial state on reload; this handles a live
|
||||
// toggle from a settings save. Marker class (base is display:inline-flex
|
||||
// !important), matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
const planUsageChip = document.getElementById('planUsageChip');
|
||||
if (planUsageChip) {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
@@ -1952,6 +1997,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
async loadAppSettingsFromServer(settingsPromise = null) {
|
||||
// One-time migration: showPlanUsageLimits became a per-device display setting.
|
||||
// Before this, it synced from the server, so the (separate) mobile settings blob
|
||||
// may carry a stale `true` the user never enabled on this device. Clear it once
|
||||
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
|
||||
try {
|
||||
if (
|
||||
MobileDetection.getDeviceType() === 'mobile' &&
|
||||
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
|
||||
) {
|
||||
const s = this.loadAppSettingsFromStorage();
|
||||
if (s && s.showPlanUsageLimits) {
|
||||
s.showPlanUsageLimits = false;
|
||||
this.saveAppSettingsToStorage(s);
|
||||
}
|
||||
localStorage.setItem('codeman:planUsagePerDeviceMigrated', '1');
|
||||
}
|
||||
} catch {
|
||||
/* best-effort migration */
|
||||
}
|
||||
try {
|
||||
const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.success === true ? env.data : env);
|
||||
if (settings) {
|
||||
@@ -1966,8 +2030,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
|
||||
]);
|
||||
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
|
||||
// can show it while mobile stays hidden. It used to sync, so an older
|
||||
// server.json may still carry `true` — drop it so the server value is NEVER
|
||||
// seeded into a device that didn't explicitly enable it (collection is handled
|
||||
// separately via the statusLineTelemetry action, not this display flag).
|
||||
delete appSettings.showPlanUsageLimits;
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
// (prevents cross-device overwrite while fixing settings re-enabling on fresh loads)
|
||||
|
||||
@@ -1107,6 +1107,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .header-plan-usage,
|
||||
body.solo-mode .btn-lifecycle-log {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -3943,6 +3944,12 @@ body.solo-mode .btn-lifecycle-log {
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
flex: 0 1 auto;
|
||||
/* Match the field to its settings row: the base .form-select (0.8rem / 0.5rem
|
||||
padding) renders bigger and taller than the row, and the daylight skins'
|
||||
Manrope font exaggerates it further. Slim it to the row's 0.75rem text. */
|
||||
font-size: 0.75rem;
|
||||
padding-top: 0.3rem;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.settings-item-multiline {
|
||||
@@ -8321,6 +8328,72 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* "Attachments" header button — opt-in (App Settings → Display), hidden by
|
||||
default. Same pattern as the response viewer: a base inline-flex !important so
|
||||
an inline style can't override it, and a more-specific marker rule to hide. */
|
||||
.btn-attachments-history {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
.btn-attachments-history.btn-attachments-history--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||
default via the marker class below; the server strips it at render when the
|
||||
setting is on and the client toggles it live on save. */
|
||||
.header-plan-usage {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 11px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
|
||||
.header-plan-usage .pu-win {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.header-plan-usage .pu-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
opacity: 0.9;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.header-plan-usage .pu-val {
|
||||
font-weight: 700;
|
||||
}
|
||||
.header-plan-usage .pu-sep {
|
||||
margin: 0 7px;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.45;
|
||||
}
|
||||
/* Green/yellow/red by how much of the window is used up. */
|
||||
.header-plan-usage .pu-green {
|
||||
color: #3fb950;
|
||||
}
|
||||
.header-plan-usage .pu-yellow {
|
||||
color: #d6a01a;
|
||||
}
|
||||
.header-plan-usage .pu-red {
|
||||
color: #f85149;
|
||||
}
|
||||
/* Marker class must out-specify the display:inline-flex !important above. */
|
||||
.header-plan-usage.header-plan-usage--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -9175,6 +9248,386 @@ body.touch-device.cjk-input-visible .main {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.attachment-card-stack {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: calc(var(--toolbar-height) + 18px);
|
||||
z-index: 1900;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(540px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attachment-clear-all {
|
||||
align-self: flex-end;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 5px;
|
||||
background: rgba(19, 19, 22, 0.96);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.attachment-clear-all:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.attachment-clear-all[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-card {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr) 24px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(19, 19, 22, 0.96);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.attachment-thumbnail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: auto;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #f8f8fb;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.attachment-thumbnail-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment-thumbnail-fallback {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
background: #20202a;
|
||||
}
|
||||
|
||||
.attachment-thumbnail-fallback.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attachment-type-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 6px;
|
||||
background: #20202a;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border-light);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.attachment-card-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-file-name {
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-file-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.attachment-actions button,
|
||||
.attachment-actions a {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-actions button:hover,
|
||||
.attachment-actions a:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.attachment-close-btn {
|
||||
align-self: start;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-close-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* PDF/Office previews render in an iframe inside the preview body. */
|
||||
.file-preview-body iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Positioning context so the absolutely-positioned badge anchors to the icon,
|
||||
not the header bar (mirrors .btn-notifications). */
|
||||
.btn-attachments-history {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attachment-history-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 1px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Attachment History Drawer */
|
||||
.attachment-history-drawer {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
right: 0;
|
||||
width: 390px;
|
||||
max-width: calc(100vw - 24px);
|
||||
height: calc(100vh - var(--header-height) - var(--toolbar-height));
|
||||
height: calc(100dvh - var(--header-height) - var(--toolbar-height));
|
||||
background: rgba(19, 19, 22, 0.98);
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.18s ease;
|
||||
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.attachment-history-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.attachment-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attachment-history-title {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.attachment-history-subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.attachment-history-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.attachment-history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.attachment-history-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 160px;
|
||||
padding: 24px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attachment-history-empty-title {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachment-history-empty code {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
padding: 5px 7px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.attachment-history-item {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.attachment-history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.attachment-history-item.missing {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.attachment-history-thumb {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 112px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: #f8f8fb;
|
||||
}
|
||||
|
||||
.attachment-history-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.attachment-history-thumb-fallback {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #20202a;
|
||||
color: var(--text);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.attachment-history-thumb-fallback.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attachment-history-item-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-history-file-name {
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-history-meta {
|
||||
margin-top: 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.attachment-history-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.attachment-history-actions button {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text);
|
||||
font-size: 0.67rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-history-actions button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.attachment-history-actions button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
=== v1.0 Carbon Aurora · Daylight overrides ===
|
||||
Single appended block — trivially removable. Retones the hardcoded
|
||||
|
||||
@@ -11,15 +11,22 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
AttachmentRegistrationError,
|
||||
attachmentRecordToEvent,
|
||||
attachmentRegistry,
|
||||
buildFileThumbnailRoute,
|
||||
isSupportedAttachmentExtension,
|
||||
registerExternalAttachment,
|
||||
type AttachmentRecord,
|
||||
} from '../../attachment-registry.js';
|
||||
import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, SessionPort } from '../ports/index.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -159,7 +166,207 @@ async function resolveServableAttachmentPath(
|
||||
return resolved ? pathToCheck : record.filePath;
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
/**
|
||||
* Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and
|
||||
* stream it inline. PDF/PNG and text formats don't need conversion — callers
|
||||
* redirect those to the raw route instead.
|
||||
*/
|
||||
async function serveConvertedPreview(
|
||||
reply: FastifyReply,
|
||||
resolvedPath: string,
|
||||
fileName: string,
|
||||
extension: string
|
||||
): Promise<void> {
|
||||
if (extension !== 'docx' && extension !== 'pptx') {
|
||||
reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension);
|
||||
if (!previewPath) {
|
||||
reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(previewPath);
|
||||
reply.header('Content-Type', 'application/pdf');
|
||||
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('Content-Length', content.length);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.send(content);
|
||||
} catch (err) {
|
||||
reply
|
||||
.code(500)
|
||||
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */
|
||||
async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise<void> {
|
||||
const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension);
|
||||
if (!thumbnail) {
|
||||
reply.code(204).send();
|
||||
return;
|
||||
}
|
||||
|
||||
reply.header('Content-Type', thumbnail.contentType);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.send(thumbnail.content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session's working dir from the live session, falling back to the
|
||||
* persisted record so preview/thumbnail requests keep working for a session
|
||||
* that has since detached. Sends a 404 and returns undefined when unknown.
|
||||
*/
|
||||
function getKnownSessionWorkingDir(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string,
|
||||
reply: FastifyReply
|
||||
): string | undefined {
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) return liveSession.workingDir;
|
||||
|
||||
const stored = ctx.store.getSession(sessionId);
|
||||
if (stored) return stored.workingDir;
|
||||
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Persisted sessions carry the private (externalPath-bearing) history under a
|
||||
// `__attachmentHistory` key so the list route can re-register external files.
|
||||
type StoredSessionWithPrivateAttachmentHistory = SessionState & {
|
||||
__attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
};
|
||||
|
||||
type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPath'> & {
|
||||
missing: boolean;
|
||||
rawUrl?: string;
|
||||
url?: string;
|
||||
previewUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
downloadUrl?: string;
|
||||
attachmentId?: string;
|
||||
};
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
|
||||
function getSessionAttachmentHistory(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId: string
|
||||
): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined {
|
||||
const liveSession = ctx.sessions.get(sessionId);
|
||||
if (liveSession) {
|
||||
return {
|
||||
workingDir: liveSession.workingDir,
|
||||
history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined;
|
||||
if (!stored) return undefined;
|
||||
|
||||
return {
|
||||
workingDir: stored.workingDir,
|
||||
history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// History item for a file detected inside the workspace: re-stat for live
|
||||
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
|
||||
async function buildDetectedAttachmentRouteItem(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
item: SessionAttachmentHistoryItem
|
||||
): Promise<AttachmentHistoryRouteItem> {
|
||||
const safe = sanitizeAttachmentHistoryItem(item);
|
||||
if (!item.relativePath) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, item.relativePath);
|
||||
if (!validated) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
let size = item.size;
|
||||
let mtimeMs = item.mtimeMs;
|
||||
try {
|
||||
const stat = await fs.stat(validated.resolvedPath);
|
||||
size = stat.size;
|
||||
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
||||
} catch {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
const encodedPath = encodeURIComponent(item.relativePath);
|
||||
const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`;
|
||||
const previewUrl =
|
||||
item.extension === 'docx' || item.extension === 'pptx'
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}`
|
||||
: rawUrl;
|
||||
const thumbnailUrl = isSupportedAttachmentExtension(item.extension)
|
||||
? buildFileThumbnailRoute(sessionId, item.relativePath)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...safe,
|
||||
size,
|
||||
mtimeMs,
|
||||
missing: false,
|
||||
rawUrl,
|
||||
url: rawUrl,
|
||||
previewUrl,
|
||||
thumbnailUrl,
|
||||
downloadUrl: appendDownloadFlag(rawUrl),
|
||||
};
|
||||
}
|
||||
|
||||
// History item for an explicitly published external file: re-register it to mint
|
||||
// a fresh id + by-id routes (the guard runs again), or mark it missing.
|
||||
async function buildExternalAttachmentRouteItem(
|
||||
sessionId: string,
|
||||
item: SessionAttachmentHistoryItem,
|
||||
sessionWorkingDir?: string
|
||||
): Promise<AttachmentHistoryRouteItem> {
|
||||
const safe = sanitizeAttachmentHistoryItem(item);
|
||||
if (!item.externalPath) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir });
|
||||
return {
|
||||
...safe,
|
||||
fileName: event.fileName,
|
||||
extension: event.extension,
|
||||
attachmentType: event.attachmentType,
|
||||
size: event.size,
|
||||
missing: false,
|
||||
attachmentId: event.attachmentId,
|
||||
rawUrl: event.rawUrl,
|
||||
url: event.rawUrl,
|
||||
previewUrl: event.previewUrl,
|
||||
thumbnailUrl: event.thumbnailUrl,
|
||||
downloadUrl: appendDownloadFlag(event.rawUrl),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AttachmentRegistrationError) {
|
||||
return { ...safe, missing: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -495,6 +702,70 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
});
|
||||
|
||||
// List a session's attachment history (live session or persisted), resolving
|
||||
// each entry to current metadata + routes. External entries are re-registered.
|
||||
app.get('/api/sessions/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const sessionHistory = getSessionAttachmentHistory(ctx, id);
|
||||
if (!sessionHistory) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`));
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await Promise.all(
|
||||
sessionHistory.history.map((item) =>
|
||||
(item.source === 'external'
|
||||
? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir)
|
||||
: buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item)
|
||||
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
count: items.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Metadata poll for a single registered attachment (re-stats for live
|
||||
// size/mtime as the underlying file is rewritten).
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return;
|
||||
const event = attachmentRecordToEvent(record);
|
||||
let size = record.size;
|
||||
let mtimeMs = record.mtimeMs;
|
||||
try {
|
||||
const stat = await fs.stat(record.filePath);
|
||||
size = stat.size;
|
||||
mtimeMs = stat.mtimeMs ?? mtimeMs;
|
||||
} catch {
|
||||
// File temporarily unavailable mid-write — keep cached values.
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: record.fileName,
|
||||
size,
|
||||
mtimeMs,
|
||||
type: record.attachmentType,
|
||||
extension: record.extension,
|
||||
url: event.rawUrl,
|
||||
previewUrl: event.previewUrl,
|
||||
thumbnailUrl: event.thumbnailUrl,
|
||||
attachmentId: record.attachmentId,
|
||||
fileName: record.fileName,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Serve the raw bytes of a registered attachment by id. Re-checks the
|
||||
// attachment-guard policy on every request (defense-in-depth) before streaming.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => {
|
||||
@@ -515,6 +786,97 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
});
|
||||
|
||||
// Serve a converted PDF preview of a registered attachment by id. Office docs
|
||||
// convert server-side; PDF/PNG/text redirect to the raw route.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
||||
if (!servePath) return;
|
||||
|
||||
// Only Office formats need server-side conversion; PDF/PNG and text formats
|
||||
// (md/txt) preview directly from their raw bytes.
|
||||
if (record.extension !== 'docx' && record.extension !== 'pptx') {
|
||||
reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveConvertedPreview(reply, servePath, record.fileName, record.extension);
|
||||
});
|
||||
|
||||
// Serve a first-page thumbnail of a registered attachment by id.
|
||||
app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => {
|
||||
const { id, attachmentId } = req.params as { id: string; attachmentId: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
const record = getAttachmentOr404(reply, id, attachmentId);
|
||||
if (!record) return;
|
||||
const servePath = await resolveServableAttachmentPath(reply, record, workingDir);
|
||||
if (!servePath) return;
|
||||
await serveThumbnail(reply, servePath, record.extension);
|
||||
});
|
||||
|
||||
// Serve converted document previews for a workspace-relative path. DOCX/PPTX
|
||||
// are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw.
|
||||
app.get('/api/sessions/:id/file-preview', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
const { resolvedPath } = validated;
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
if (ext !== 'docx' && ext !== 'pptx') {
|
||||
reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveConvertedPreview(reply, resolvedPath, filePath, ext);
|
||||
});
|
||||
|
||||
// Serve a first-page thumbnail for a workspace-relative path.
|
||||
app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { path: filePath } = req.query as { path?: string };
|
||||
const workingDir = getKnownSessionWorkingDir(ctx, id, reply);
|
||||
if (!workingDir) return;
|
||||
|
||||
if (!filePath) {
|
||||
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
const validated = validateSessionFilePath(workingDir, filePath);
|
||||
if (!validated) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
if (!isSupportedAttachmentExtension(ext)) {
|
||||
reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type'));
|
||||
return;
|
||||
}
|
||||
|
||||
await serveThumbnail(reply, validated.resolvedPath, ext);
|
||||
});
|
||||
|
||||
// Stream file content via tail -f (SSE endpoint)
|
||||
app.get('/api/sessions/:id/tail-file', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -9,6 +9,7 @@ export { registerFileRoutes } from './file-routes.js';
|
||||
export { registerScheduledRoutes } from './scheduled-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -297,6 +297,21 @@ export function registerSessionRoutes(
|
||||
await updateCaseModel(workingDir, body.modelOverride || null);
|
||||
}
|
||||
|
||||
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
|
||||
// Limits"). Claude-only; runs for ANY working dir (linked cases / real repos,
|
||||
// where most sessions live), mirroring updateCaseModel above.
|
||||
//
|
||||
// ADD-ONLY: we never remove on create. Sessions in a repo share one
|
||||
// settings.local.json, so a single create-with-false (e.g. a client whose
|
||||
// synced setting hadn't loaded yet) must NOT yank the statusLine out from
|
||||
// under other live sessions in that repo — that breaks their footer + the
|
||||
// chip's data feed for everyone. The exporter is benign when the chip is off
|
||||
// (the footer just shows session status). isOurs-guarded so a user's own
|
||||
// statusLine is never touched.
|
||||
if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
|
||||
await applyStatusLineConfig(workingDir, true);
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @fileoverview Status-telemetry route.
|
||||
*
|
||||
* Receives Claude Code statusline payloads POSTed by the Codeman-managed
|
||||
* statusLine exporter (see `hooks-config.generateStatusLineCommand`) and
|
||||
* broadcasts the parsed plan-usage limits (5-hour + weekly) to SSE clients for
|
||||
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
|
||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||
*
|
||||
* Returns a compact plain-text status string for the exporter to print as the
|
||||
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
|
||||
* the terminal footer blank.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { StatusTelemetrySchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parseStatusTelemetry,
|
||||
parseSessionStatus,
|
||||
formatSessionStatusText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../../usage-telemetry.js';
|
||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
// Last broadcast telemetry signature per session — the statusline fires on
|
||||
// every assistant message, so we only rebroadcast when the value changes.
|
||||
const lastSig = new Map<string, string>();
|
||||
|
||||
app.post('/api/status-telemetry', async (req, reply) => {
|
||||
const { sessionId, data } = parseBody(StatusTelemetrySchema, req.body);
|
||||
|
||||
reply.type('text/plain; charset=utf-8');
|
||||
|
||||
// Unknown session — minimal footer, no broadcast.
|
||||
if (!ctx.sessions.has(sessionId)) {
|
||||
lastSig.delete(sessionId);
|
||||
return 'codeman';
|
||||
}
|
||||
|
||||
const payload = data as RawStatuslinePayload | undefined;
|
||||
|
||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||
// present and changed (the statusline fires on every assistant message).
|
||||
const telemetry = parseStatusTelemetry(payload);
|
||||
if (telemetry) {
|
||||
const sig = telemetrySignature(telemetry);
|
||||
if (lastSig.get(sessionId) !== sig) {
|
||||
lastSig.set(sessionId, sig);
|
||||
// Bound the map across long multi-session runs: prune dead sessions.
|
||||
if (lastSig.size > 256) {
|
||||
for (const id of [...lastSig.keys()]) {
|
||||
if (!ctx.sessions.has(id)) lastSig.delete(id);
|
||||
}
|
||||
}
|
||||
const payload = { sessionId, ...telemetry };
|
||||
setLatestPlanUsage(payload); // replayed in the SSE init snapshot for fresh loads
|
||||
ctx.broadcast(SessionStatusTelemetry, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// In-terminal statusline footer → CURRENT SESSION status (model / tokens /
|
||||
// context %), NOT the plan limits. Available from the first render, even
|
||||
// before rate_limits appears.
|
||||
return formatSessionStatusText(parseSessionStatus(payload));
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../schemas.js';
|
||||
import { subagentWatcher } from '../../subagent-watcher.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
findSessionOrFail,
|
||||
@@ -530,7 +531,10 @@ export function registerSystemRoutes(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const merged = { ...existing, ...settings };
|
||||
// statusLineTelemetry is an ACTION field (reconcile the plan-usage exporter),
|
||||
// not a stored setting — strip it before persisting so settings.json stays clean.
|
||||
const { statusLineTelemetry, ...settingsToStore } = settings;
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
// Handle subagent tracking toggle dynamically
|
||||
@@ -546,6 +550,22 @@ export function registerSystemRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// Plan-usage chip: its DISPLAY is per-device (client-side, see settings-ui.js).
|
||||
// Telemetry COLLECTION is server-side and enable-sticky — when a client turns
|
||||
// the chip ON it sends statusLineTelemetry:true and we (re)inject our exporter
|
||||
// into every ACTIVE Claude session's working dir so the live % starts flowing
|
||||
// immediately (no new session needed). We deliberately never auto-REMOVE here:
|
||||
// the exporter is benign/print-through and a per-repo settings.local.json is
|
||||
// shared by sibling sessions, so one device's "off" must not yank the exporter
|
||||
// another device's chip depends on. Each dir handled once.
|
||||
if (statusLineTelemetry === true) {
|
||||
const dirs = new Set<string>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir);
|
||||
}
|
||||
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
|
||||
}
|
||||
|
||||
// Handle tunnel toggle dynamically
|
||||
if ('tunnelEnabled' in settings) {
|
||||
const tunnelEnabled = settings.tunnelEnabled as boolean;
|
||||
|
||||
@@ -158,6 +158,8 @@ export const CreateSessionSchema = z.object({
|
||||
effort: effortLevelSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
/** Inject the plan-usage statusLine exporter into the case (App Settings → Display → "Plan Usage Limits"). Claude-only. */
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
@@ -186,6 +188,51 @@ export const ResizeSchema = z.object({
|
||||
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for POST /api/status-telemetry
|
||||
* Claude Code statusline payload forwarded by the Codeman-managed statusLine
|
||||
* exporter (see hooks-config.generateStatusLineCommand). Validates only the
|
||||
* subset Codeman displays; unknown keys (session_id, transcript_path, cwd, …)
|
||||
* are stripped by z.object. Auth-exempt like /api/hook-event.
|
||||
*/
|
||||
// NOTE: every modeled field is `.nullish()` (not `.optional()`) on purpose.
|
||||
// Claude's statusline blob is officially shipped but undocumented in exact
|
||||
// shape, and `z.optional()` REJECTS an explicit `null` (accepts only
|
||||
// `undefined`) — a single stray `null` (e.g. `cost:{total_cost_usd:null}`)
|
||||
// would 400 the ENTIRE POST before the deliberately-tolerant parser
|
||||
// (usage-telemetry.ts, which only acts on `typeof === 'number'/'string'`) ever
|
||||
// runs, silently killing the chip's data feed. `.nullish()` keeps the schema
|
||||
// gate as forgiving as the parser it guards.
|
||||
const RateLimitWindowSchema = z
|
||||
.object({
|
||||
used_percentage: z.number().nullish(),
|
||||
resets_at: z.number().nullish(),
|
||||
})
|
||||
.nullish();
|
||||
|
||||
export const StatusTelemetrySchema = z.object({
|
||||
sessionId: z.string().min(1).max(100),
|
||||
data: z
|
||||
.object({
|
||||
rate_limits: z
|
||||
.object({
|
||||
five_hour: RateLimitWindowSchema,
|
||||
seven_day: RateLimitWindowSchema,
|
||||
})
|
||||
.nullish(),
|
||||
context_window: z
|
||||
.object({
|
||||
used_percentage: z.number().nullish(),
|
||||
total_input_tokens: z.number().nullish(),
|
||||
total_output_tokens: z.number().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(),
|
||||
model: z.object({ display_name: z.string().max(100).nullish() }).nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
// ========== Case Routes ==========
|
||||
|
||||
/**
|
||||
@@ -321,6 +368,13 @@ export const SettingsUpdateSchema = z
|
||||
showFileBrowser: z.boolean().optional(),
|
||||
showSubagents: z.boolean().optional(),
|
||||
showMultiMonitorButton: z.boolean().optional(),
|
||||
showPlanUsageLimits: z.boolean().optional(),
|
||||
// Action field (NOT persisted as a setting): when true, (re)injects the
|
||||
// plan-usage statusLine exporter into active Claude sessions so live usage %
|
||||
// starts flowing. Sent on ENABLE only — the chip's DISPLAY is per-device
|
||||
// (client-side), but telemetry COLLECTION is server-side, so the per-device
|
||||
// toggle signals it out-of-band here rather than via showPlanUsageLimits.
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
// Input
|
||||
gestureControlEnabled: z.boolean().optional(),
|
||||
// Claude CLI settings
|
||||
|
||||
+58
-5
@@ -44,7 +44,7 @@ import { dataPath } from '../config/instance.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
|
||||
import type { ClaudeMode, SessionState } from '../types.js';
|
||||
import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState } from '../types.js';
|
||||
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
|
||||
import type { TerminalMultiplexer } from '../mux-interface.js';
|
||||
import { createMultiplexer } from '../mux-factory.js';
|
||||
@@ -60,7 +60,11 @@ import {
|
||||
type SubagentToolResult,
|
||||
} from '../subagent-watcher.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import {
|
||||
buildDetectedAttachmentHistoryItem,
|
||||
buildExternalAttachmentHistoryItem,
|
||||
} from '../session-attachment-history.js';
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
import { TeamWatcher } from '../team-watcher.js';
|
||||
import { TunnelManager } from '../tunnel-manager.js';
|
||||
@@ -124,6 +128,7 @@ import {
|
||||
import type { EventLoopMonitorHandle } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { getLatestPlanUsage } from './plan-usage-latest.js';
|
||||
import type { ScheduledRun } from './ports/index.js';
|
||||
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
|
||||
import { installRouteErrorHandler } from './route-error-handler.js';
|
||||
@@ -135,6 +140,7 @@ import {
|
||||
registerFileRoutes,
|
||||
registerScheduledRoutes,
|
||||
registerHookEventRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
registerSessionRoutes,
|
||||
@@ -439,8 +445,20 @@ export class WebServer extends EventEmitter {
|
||||
// Store handlers for cleanup on shutdown
|
||||
this.imageWatcherHandlers = {
|
||||
detected: (event: ImageDetectedEvent) => this.broadcast(SseEvent.ImageDetected, event),
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) =>
|
||||
this.broadcast(SseEvent.AttachmentDetected, { ...event, source: event.source || 'detected' }),
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) => {
|
||||
const attachmentEvent = {
|
||||
...event,
|
||||
source: event.source || 'detected',
|
||||
thumbnailUrl:
|
||||
event.thumbnailUrl || buildFileThumbnailRoute(event.sessionId, event.relativePath || event.fileName),
|
||||
};
|
||||
const session = this.sessions.get(event.sessionId);
|
||||
if (session) {
|
||||
session.upsertAttachmentHistory(buildDetectedAttachmentHistoryItem(attachmentEvent));
|
||||
this.persistSessionState(session);
|
||||
}
|
||||
this.broadcast(SseEvent.AttachmentDetected, attachmentEvent);
|
||||
},
|
||||
error: (error: Error, sessionId?: string) => {
|
||||
console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message);
|
||||
},
|
||||
@@ -811,6 +829,7 @@ export class WebServer extends EventEmitter {
|
||||
registerFileRoutes(this.app, ctx);
|
||||
registerScheduledRoutes(this.app, ctx);
|
||||
registerHookEventRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
registerSessionRoutes(this.app, ctx);
|
||||
@@ -901,7 +920,14 @@ export class WebServer extends EventEmitter {
|
||||
// field kept off SessionState to avoid leaking via API broadcasts.
|
||||
const base = session.toState();
|
||||
const envOverrides = session.getEnvOverridesForPersist();
|
||||
const state = (envOverrides ? { ...base, __envOverrides: envOverrides } : base) as SessionState;
|
||||
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
|
||||
// separate from the sanitized public attachmentHistory in toState().
|
||||
const attachmentHistory = session.getAttachmentHistoryForPersist();
|
||||
const state = {
|
||||
...base,
|
||||
...(envOverrides ? { __envOverrides: envOverrides } : {}),
|
||||
...(attachmentHistory ? { __attachmentHistory: attachmentHistory } : {}),
|
||||
} as SessionState;
|
||||
const controller = this.respawnControllers.get(session.id);
|
||||
if (controller) {
|
||||
const config = controller.getConfig();
|
||||
@@ -1121,6 +1147,11 @@ export class WebServer extends EventEmitter {
|
||||
if (settings.showMultiMonitorButton === true) {
|
||||
html = html.replace(' btn-multimonitor--hidden', '');
|
||||
}
|
||||
// Plan-usage chip: ships hidden (`header-plan-usage--hidden`) and is revealed
|
||||
// PER-DEVICE by the client (settings-ui.js applyHeaderVisibilitySettings). It
|
||||
// used to be server-revealed from a synced setting, but that leaked the desktop
|
||||
// choice onto mobile — display is now per-device only (like the response viewer).
|
||||
// Telemetry collection stays server-side via the statusLineTelemetry action.
|
||||
// Detached single-session ("solo") window: inject the target session id so
|
||||
// the client can enter solo mode even if a (network-first) service worker
|
||||
// later serves a cached shell. The client primarily detects solo mode from
|
||||
@@ -1284,6 +1315,21 @@ export class WebServer extends EventEmitter {
|
||||
sessionWorkingDir: session.workingDir,
|
||||
forceWorkspaceConfinement: true,
|
||||
});
|
||||
const record = attachmentRegistry.get(sessionId, event.attachmentId);
|
||||
if (record) {
|
||||
session.upsertAttachmentHistory(
|
||||
buildExternalAttachmentHistoryItem({
|
||||
sessionId,
|
||||
externalPath: record.filePath,
|
||||
fileName: record.fileName,
|
||||
extension: record.extension,
|
||||
size: record.size,
|
||||
mtimeMs: record.mtimeMs,
|
||||
timestamp: event.timestamp,
|
||||
})
|
||||
);
|
||||
this.persistSessionState(session);
|
||||
}
|
||||
this.broadcast(SseEvent.AttachmentDetected, event);
|
||||
}
|
||||
|
||||
@@ -1625,6 +1671,7 @@ export class WebServer extends EventEmitter {
|
||||
subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents
|
||||
timestamp: now,
|
||||
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
|
||||
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
|
||||
};
|
||||
|
||||
this.cachedLightState = { data: result, timestamp: now };
|
||||
@@ -2001,6 +2048,11 @@ export class WebServer extends EventEmitter {
|
||||
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
|
||||
// by the Session constructor (env var would hard-lock /effort switching).
|
||||
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
|
||||
// Prefer the private (externalPath-bearing) history; fall back to the
|
||||
// sanitized public copy for sessions persisted before that split.
|
||||
const savedAttachmentHistory =
|
||||
(savedState as { __attachmentHistory?: SessionAttachmentHistoryItem[] })?.__attachmentHistory ??
|
||||
savedState?.attachmentHistory;
|
||||
const session = new Session({
|
||||
id: muxSession.sessionId, // Preserve the original session ID
|
||||
workingDir: muxSession.workingDir,
|
||||
@@ -2013,6 +2065,7 @@ export class WebServer extends EventEmitter {
|
||||
allowedTools: recoveryClaudeMode.allowedTools,
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
|
||||
@@ -87,6 +87,8 @@ export const SessionMessage = 'session:message' as const;
|
||||
export const SessionInteractive = 'session:interactive' as const;
|
||||
/** Prompt sent to session for execution. */
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
/** Claude plan-usage telemetry (5-hour + weekly limits) parsed from the statusline. */
|
||||
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -376,6 +378,7 @@ export const SseEvent = {
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runWithConversionLimit, getActiveConversionCount } from '../src/document-conversion-limiter.js';
|
||||
|
||||
describe('document-conversion-limiter', () => {
|
||||
it('never runs more converters than the cap (default 3) concurrently', async () => {
|
||||
let running = 0;
|
||||
let maxObserved = 0;
|
||||
|
||||
const task = () => async () => {
|
||||
running++;
|
||||
maxObserved = Math.max(maxObserved, running);
|
||||
// The module's own accounting must also stay within the cap.
|
||||
expect(getActiveConversionCount()).toBeLessThanOrEqual(3);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
running--;
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: 12 }, () => runWithConversionLimit(task())));
|
||||
|
||||
expect(maxObserved).toBeLessThanOrEqual(3);
|
||||
expect(maxObserved).toBeGreaterThan(1); // proves it genuinely parallelizes, not serializes
|
||||
expect(getActiveConversionCount()).toBe(0); // every slot released
|
||||
});
|
||||
|
||||
it('processes every queued task even when far more are submitted than the cap', async () => {
|
||||
let completed = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: 25 }, () =>
|
||||
runWithConversionLimit(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
completed++;
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(completed).toBe(25);
|
||||
expect(getActiveConversionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('releases the slot when a task throws', async () => {
|
||||
await expect(
|
||||
runWithConversionLimit(async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
expect(getActiveConversionCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import { pruneDocumentPreviewCache } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRm = vi.mocked(fs.rm);
|
||||
|
||||
describe('pruneDocumentPreviewCache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// mtime encoded in the filename (f0 oldest ... f101 newest)
|
||||
mockedStat.mockImplementation(async (p) => {
|
||||
const m = /f(\d+)\.pdf$/.exec(String(p));
|
||||
return { mtimeMs: m ? Number(m[1]) : 0, isFile: () => true } as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('evicts the oldest *.pdf files once the cache exceeds the cap (default 100)', async () => {
|
||||
const names = Array.from({ length: 102 }, (_, i) => `f${i}.pdf`);
|
||||
mockedReaddir.mockResolvedValue(names as never);
|
||||
|
||||
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
||||
|
||||
// 102 - 100 = 2 oldest removed
|
||||
expect(mockedRm).toHaveBeenCalledTimes(2);
|
||||
const removed = mockedRm.mock.calls.map((c) => String(c[0]));
|
||||
expect(removed.some((p) => p.endsWith('f0.pdf'))).toBe(true);
|
||||
expect(removed.some((p) => p.endsWith('f1.pdf'))).toBe(true);
|
||||
expect(removed.some((p) => p.endsWith('f101.pdf'))).toBe(false); // newest kept
|
||||
});
|
||||
|
||||
it('ignores non-pdf entries (e.g. transient work-* dirs) when counting', async () => {
|
||||
const names = [...Array.from({ length: 50 }, (_, i) => `f${i}.pdf`), 'work-abc', 'work-def'];
|
||||
mockedReaddir.mockResolvedValue(names as never);
|
||||
|
||||
await pruneDocumentPreviewCache('/tmp/codeman-document-preview-cache');
|
||||
|
||||
expect(mockedRm).not.toHaveBeenCalled(); // 50 pdfs <= cap
|
||||
});
|
||||
|
||||
it('never throws when the cache dir cannot be read', async () => {
|
||||
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
||||
await expect(pruneDocumentPreviewCache('/tmp/missing')).resolves.toBeUndefined();
|
||||
expect(mockedRm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { clearDocumentPreviewCache, getOfficePreviewPdfPath } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
copyFile: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-document-preview-cache/work-test'),
|
||||
readdir: vi.fn(async () => ['deck.pdf']),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedExecFile = vi.mocked(execFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRename = vi.mocked(fs.rename);
|
||||
|
||||
describe('document-preview-cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDocumentPreviewCache();
|
||||
|
||||
let cachedPdfExists = false;
|
||||
mockedStat.mockImplementation(async (path) => {
|
||||
const pathText = String(path);
|
||||
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||
if (!cachedPdfExists) throw new Error('ENOENT');
|
||||
return { size: 4096, isFile: () => true } as never;
|
||||
}
|
||||
|
||||
return { size: 1845494, mtimeMs: 12345, isFile: () => true } as never;
|
||||
});
|
||||
mockedRename.mockImplementation(async () => {
|
||||
cachedPdfExists = true;
|
||||
});
|
||||
mockedExecFile.mockImplementation((cmd, _args, _options, callback) => {
|
||||
if (cmd === 'powershell.exe') {
|
||||
cachedPdfExists = true;
|
||||
}
|
||||
|
||||
setTimeout(() => callback(null, { stdout: '', stderr: '' }), 1);
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates concurrent Office preview conversions and reuses the cached PDF', async () => {
|
||||
const [firstPath, secondPath] = await Promise.all([
|
||||
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||
getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx'),
|
||||
]);
|
||||
const thirdPath = await getOfficePreviewPdfPath('/tmp/deck.pptx', 'pptx');
|
||||
|
||||
expect(firstPath).toBeTruthy();
|
||||
expect(secondPath).toBe(firstPath);
|
||||
expect(thirdPath).toBe(firstPath);
|
||||
expect(mockedExecFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'soffice',
|
||||
expect.arrayContaining([
|
||||
'--headless',
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
expect.stringMatching(/^-env:UserInstallation=file:/),
|
||||
]),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers Microsoft Word for DOCX files on Windows-mounted paths', async () => {
|
||||
const result = await getOfficePreviewPdfPath(
|
||||
'/mnt/c/Users/aakhter/Documents/codeman-inline-viewer-test.docx',
|
||||
'docx'
|
||||
);
|
||||
|
||||
expect(result).toContain('/mnt/c/Users/aakhter/AppData/Local/Temp/codeman-document-preview-cache/');
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-NonInteractive', '-EncodedCommand']),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
const powerShellCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'powershell.exe');
|
||||
const encodedCommand = powerShellCall?.[1]?.at(-1);
|
||||
const decodedCommand = Buffer.from(String(encodedCommand), 'base64').toString('utf16le');
|
||||
expect(decodedCommand).toContain('$source = ');
|
||||
expect(decodedCommand).toContain('C:\\Users\\aakhter\\AppData\\Local\\Temp\\codeman-document-preview-cache\\');
|
||||
expect(decodedCommand).toContain('.docx');
|
||||
expect(decodedCommand).toContain('$output = ');
|
||||
expect(decodedCommand).toContain('.pdf');
|
||||
expect(mockedExecFile).not.toHaveBeenCalledWith(
|
||||
'soffice',
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { generateFirstPageThumbnail } from '../src/document-thumbnailer.js';
|
||||
import { clearDocumentPreviewCache } from '../src/document-preview-cache.js';
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||
readFile: vi.fn(async () => Buffer.from('png')),
|
||||
readdir: vi.fn(async () => ['converted.pdf']),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-thumb-test'),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedExecFile = vi.mocked(execFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedMkdir = vi.mocked(fs.mkdir);
|
||||
const mockedMkdtemp = vi.mocked(fs.mkdtemp);
|
||||
const mockedRename = vi.mocked(fs.rename);
|
||||
const mockedRm = vi.mocked(fs.rm);
|
||||
|
||||
describe('document-thumbnailer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearDocumentPreviewCache();
|
||||
mockedStat.mockImplementation(async (path) => {
|
||||
const pathText = String(path);
|
||||
if (pathText.includes('codeman-document-preview-cache') && pathText.endsWith('.pdf')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
|
||||
return { size: 500 * 1024 * 1024, mtimeMs: 12345, isFile: () => true } as never;
|
||||
});
|
||||
mockedReadFile.mockResolvedValue(Buffer.from('large thumbnail') as never);
|
||||
mockedReaddir.mockResolvedValue(['converted.pdf'] as never);
|
||||
mockedMkdir.mockResolvedValue(undefined as never);
|
||||
mockedMkdtemp.mockResolvedValue('/tmp/codeman-thumb-test' as never);
|
||||
mockedRename.mockResolvedValue(undefined as never);
|
||||
mockedRm.mockResolvedValue(undefined as never);
|
||||
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {} as never;
|
||||
});
|
||||
});
|
||||
|
||||
it('renders first-page thumbnails for large documents without an app-level size cap', async () => {
|
||||
const result = await generateFirstPageThumbnail('/tmp/large-deck.pdf', 'pdf');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: Buffer.from('large thumbnail'),
|
||||
contentType: 'image/png',
|
||||
});
|
||||
expect(mockedExecFile).toHaveBeenCalledWith(
|
||||
'pdftoppm',
|
||||
expect.arrayContaining(['-png', '-singlefile', '-f', '1', '-l', '1']),
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('renders Office thumbnails from the cached converted PDF after conversion cleanup', async () => {
|
||||
mockedMkdtemp.mockImplementation(async (prefix) =>
|
||||
String(prefix).includes('codeman-document-preview-cache')
|
||||
? '/tmp/codeman-document-preview-cache/work-test'
|
||||
: '/tmp/codeman-thumb-pdf-test'
|
||||
);
|
||||
mockedExecFile.mockImplementation((_cmd, _args, _options, callback) => {
|
||||
callback(null, { stdout: '', stderr: '' });
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const result = await generateFirstPageThumbnail('/tmp/deck.pptx', 'pptx');
|
||||
|
||||
expect(result).toEqual({
|
||||
content: Buffer.from('large thumbnail'),
|
||||
contentType: 'image/png',
|
||||
});
|
||||
const pdftoppmCall = mockedExecFile.mock.calls.find(([cmd]) => cmd === 'pdftoppm');
|
||||
expect(pdftoppmCall?.[1]).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('codeman-document-preview-cache')])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -145,9 +145,9 @@ describe('ImageWatcher', () => {
|
||||
// ========== Image Detection ==========
|
||||
|
||||
describe('image detection', () => {
|
||||
it('should emit image:detected (popup) for .png files', () => {
|
||||
it('should emit attachment:detected for .png files', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('image:detected', handler);
|
||||
watcher.on('attachment:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
const chokidarWatcher = mockWatchers.get('/home/user/project')!;
|
||||
@@ -161,11 +161,14 @@ describe('ImageWatcher', () => {
|
||||
expect(event.fileName).toBe('screenshot.png');
|
||||
expect(event.filePath).toBe('/home/user/project/screenshot.png');
|
||||
expect(event.relativePath).toBe('screenshot.png');
|
||||
expect(event.extension).toBe('png');
|
||||
expect(event.attachmentType).toBe('image');
|
||||
expect(event.size).toBe(2048);
|
||||
});
|
||||
|
||||
it('should not emit attachment:detected for .png (stays on the popup path)', () => {
|
||||
it('should not emit legacy image:detected for .png attachment cards', () => {
|
||||
const handler = vi.fn();
|
||||
watcher.on('attachment:detected', handler);
|
||||
watcher.on('image:detected', handler);
|
||||
|
||||
watcher.watchSession('session-1', '/home/user/project');
|
||||
mockWatchers.get('/home/user/project')!.emit('add', '/home/user/project/screenshot.png');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Port: none (pure static analysis — runs in CI, no browser/server).
|
||||
//
|
||||
// Regression guard for "a header button leaks onto the cramped mobile header".
|
||||
// This exact class of bug shipped twice (the plan-usage chip, then the COD-39
|
||||
// attachments-history button): a new control was added to the header, looked
|
||||
// fine on desktop, and nobody noticed it cluttering the phone header. The mobile
|
||||
// Playwright suite that *would* catch it (test/mobile/**) is EXCLUDED from CI, so
|
||||
// it never gated. This test is intentionally a pure parser of index.html +
|
||||
// mobile.css so it runs in the normal CI sweep with zero browser dependencies.
|
||||
//
|
||||
// Policy: every header button that is VISIBLE BY DEFAULT on desktop must have an
|
||||
// explicit decision for phones — either it's hidden via an @media (max-width:
|
||||
// 430px) display:none rule in mobile.css, or it's added to MOBILE_VISIBLE_ALLOWLIST
|
||||
// below with a reason. A new default-visible header button with neither fails this
|
||||
// test, forcing the author to decide its mobile behavior.
|
||||
//
|
||||
// The real-browser counterpart (actual computed visibility on an emulated phone)
|
||||
// lives in test/mobile/header-buttons.test.ts.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { join } from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC = join(HERE, '../src/web/public');
|
||||
|
||||
// Canonical phone width used to decide whether a media query applies on a phone.
|
||||
// Matches the device the browser-based test emulates (iPhone 14 Pro = 393px CSS).
|
||||
const PHONE_WIDTH = 393;
|
||||
|
||||
// Header buttons intentionally kept VISIBLE in the phone header. Empty today: the
|
||||
// mobile header is deliberately minimal and essential controls (settings, case)
|
||||
// live in the toolbar. Add a class here ONLY with a justifying comment.
|
||||
const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
|
||||
|
||||
// Buttons we expect to STAY hidden on phones — an explicit lock so a future edit
|
||||
// that removes a hide rule fails loudly (not silently). The attachments button is
|
||||
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
|
||||
// it's excluded from the default-visible enumeration rather than mobile-hidden.
|
||||
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log'];
|
||||
|
||||
function attrOf(openTag: string, name: string): string {
|
||||
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
/** Does a media query's width range include a phone-width viewport? */
|
||||
function appliesToPhone(params: string): boolean {
|
||||
const max = params.match(/max-width:\s*(\d+)px/);
|
||||
const min = params.match(/min-width:\s*(\d+)px/);
|
||||
if (max && PHONE_WIDTH > Number(max[1])) return false; // phone is wider than the cap
|
||||
if (min && PHONE_WIDTH < Number(min[1])) return false; // phone is narrower than the floor
|
||||
return Boolean(max || min); // must actually be a width-bounded query
|
||||
}
|
||||
|
||||
function loadHeaderButtons(): { classes: string[]; id: string; distinguishing: string[] }[] {
|
||||
const html = readFileSync(join(PUBLIC, 'index.html'), 'utf-8');
|
||||
// The header controls live in <div class="header-right" id="headerRight"> … </header>.
|
||||
const region = html.match(/<div class="header-right"[^>]*>([\s\S]*?)<\/header>/);
|
||||
expect(region, '#headerRight region not found in index.html — update the selector in this test').toBeTruthy();
|
||||
const headerHtml = region![1];
|
||||
|
||||
return [...headerHtml.matchAll(/<button\b([^>]*)>/g)]
|
||||
.map((m) => {
|
||||
const open = m[1];
|
||||
const classes = attrOf(open, 'class').split(/\s+/).filter(Boolean);
|
||||
return {
|
||||
classes,
|
||||
id: attrOf(open, 'id'),
|
||||
style: attrOf(open, 'style'),
|
||||
distinguishing: classes.filter(
|
||||
(c) => c.startsWith('btn-') && c !== 'btn-icon-header' && c !== 'btn-sm' && !c.endsWith('--hidden')
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((b) => b.classes.includes('btn-icon-header'))
|
||||
.filter((b) => !b.classes.includes('btn-sm')) // font A-/A+ controls — separate sub-group
|
||||
.filter((b) => !/display:\s*none/i.test(b.style)) // JS-gated (solo-redock, retired bell)
|
||||
.filter((b) => !b.classes.some((c) => c.endsWith('--hidden'))); // opt-in, hidden by default
|
||||
}
|
||||
|
||||
function loadPhoneHiddenClasses(): Set<string> {
|
||||
const css = readFileSync(join(PUBLIC, 'mobile.css'), 'utf-8');
|
||||
const hidden = new Set<string>();
|
||||
postcss.parse(css).walkAtRules('media', (atRule) => {
|
||||
if (!appliesToPhone(atRule.params)) return;
|
||||
atRule.walkRules((rule) => {
|
||||
let hides = false;
|
||||
rule.walkDecls('display', (decl) => {
|
||||
if (decl.value.replace(/!important/i, '').trim() === 'none') hides = true;
|
||||
});
|
||||
if (!hides) return;
|
||||
for (const token of rule.selector.match(/\.btn-[a-z0-9-]+/gi) || []) {
|
||||
hidden.add(token.slice(1).toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
return hidden;
|
||||
}
|
||||
|
||||
describe('Mobile header button policy (static guard)', () => {
|
||||
const buttons = loadHeaderButtons();
|
||||
const phoneHidden = loadPhoneHiddenClasses();
|
||||
|
||||
it('finds the default-visible header buttons (sanity)', () => {
|
||||
// If this drops to 0 the parser/markup drifted — fix the parser, don't delete the test.
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every default-visible header button has a mobile-visibility decision', () => {
|
||||
for (const btn of buttons) {
|
||||
expect(
|
||||
btn.distinguishing.length,
|
||||
`Header button (id=${btn.id || '?'}, class="${btn.classes.join(' ')}") has no distinguishing ` +
|
||||
`btn-* class to target on mobile. Give it one so its phone visibility can be controlled.`
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const hidden = btn.distinguishing.some((c) => phoneHidden.has(c.toLowerCase()));
|
||||
const allowed = btn.distinguishing.some((c) => MOBILE_VISIBLE_ALLOWLIST.has(c));
|
||||
|
||||
expect(
|
||||
hidden || allowed,
|
||||
`Header button .${btn.distinguishing.join('.')} (id=${btn.id || '?'}) is VISIBLE BY DEFAULT but has ` +
|
||||
`no mobile-visibility decision.\n` +
|
||||
` → To hide it on phones: add it to the @media (max-width: 430px) "display: none" block in ` +
|
||||
`src/web/public/mobile.css (next to .btn-settings / .btn-lifecycle-log).\n` +
|
||||
` → To keep it visible on phones: add '${btn.distinguishing[0]}' to MOBILE_VISIBLE_ALLOWLIST in ` +
|
||||
`this test, with a reason.\n` +
|
||||
`This guard exists because the plan-usage chip and the attachments button both leaked onto the ` +
|
||||
`mobile header unnoticed.`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('locks the known phone-hidden header buttons', () => {
|
||||
for (const cls of KNOWN_PHONE_HIDDEN) {
|
||||
expect(
|
||||
phoneHidden.has(cls),
|
||||
`${cls} must stay hidden on phones — restore its rule in the @media (max-width: 430px) ` +
|
||||
`display:none block in src/web/public/mobile.css.`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Port 3208 - Header button visibility (real-browser E2E).
|
||||
//
|
||||
// Companion to the CI static guard (test/mobile-header-buttons-policy.test.ts).
|
||||
// Renders the real app and asserts ACTUAL computed visibility, catching CSS/layout
|
||||
// regressions the static parser can't see. Two behaviours are covered:
|
||||
// 1. The minimal phone header hides the settings gear + lifecycle log.
|
||||
// 2. The attachments button (COD-39) is OPT-IN — default-hidden everywhere, shown
|
||||
// only when App Settings → Display → "Attachments Button" is enabled. It first
|
||||
// shipped always-visible (mobile, then desktop), which is the regression here.
|
||||
import { describe, it, beforeAll, afterAll } from 'vitest';
|
||||
import type { WebServer } from '../../src/web/server.js';
|
||||
import { createTestServer, stopTestServer } from './helpers/server.js';
|
||||
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
|
||||
import { assertHidden, assertVisible } from './helpers/assertions.js';
|
||||
import { REPRESENTATIVE_DEVICES } from './devices.js';
|
||||
import { PORTS, WAIT } from './helpers/constants.js';
|
||||
|
||||
const PORT = PORTS.HEADER_BUTTONS;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('Header button visibility (E2E)', () => {
|
||||
let server: WebServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createTestServer(PORT);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeAllBrowsers();
|
||||
await stopTestServer(server);
|
||||
});
|
||||
|
||||
it('hides the settings gear + lifecycle log on a standard phone (iPhone 14 Pro)', async () => {
|
||||
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL);
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertHidden(page, '.btn-icon-header.btn-settings');
|
||||
await assertHidden(page, '.btn-icon-header.btn-lifecycle-log');
|
||||
});
|
||||
|
||||
it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => {
|
||||
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertHidden(page, '#attachmentsHistoryBtn');
|
||||
});
|
||||
|
||||
it('shows the attachments button once the setting is enabled', async () => {
|
||||
const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL);
|
||||
// Desktop-class devices use the non-mobile settings blob.
|
||||
await page.evaluate(() => {
|
||||
const cur = JSON.parse(localStorage.getItem('codeman-app-settings') || '{}');
|
||||
cur.showAttachmentsButton = true;
|
||||
localStorage.setItem('codeman-app-settings', JSON.stringify(cur));
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(WAIT.PAGE_SETTLE);
|
||||
await assertVisible(page, '#attachmentsHistoryBtn');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const PORTS = {
|
||||
DEVICE_MATRIX: 3205,
|
||||
VISUAL_REGRESSION: 3206,
|
||||
ACCESSIBILITY: 3207,
|
||||
HEADER_BUTTONS: 3208,
|
||||
} as const;
|
||||
|
||||
// CSS Selectors
|
||||
@@ -64,8 +65,8 @@ export const BREAKPOINTS = {
|
||||
|
||||
// Keyboard constants (match app.js KeyboardHandler)
|
||||
export const KEYBOARD = {
|
||||
SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show
|
||||
HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide
|
||||
SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show
|
||||
HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide
|
||||
TYPICAL_IOS_HEIGHT: 336,
|
||||
FOCUSIN_DELAY: 400,
|
||||
ANIMATION_DELAY: 150,
|
||||
@@ -87,7 +88,7 @@ export const SWIPE = {
|
||||
export const SUBAGENT = {
|
||||
MOBILE_CARD_HEIGHT: 110,
|
||||
MOBILE_CARD_GAP: 4,
|
||||
MOBILE_CARD_STRIDE: 114, // height + gap
|
||||
MOBILE_CARD_STRIDE: 114, // height + gap
|
||||
TOOLBAR_OFFSET: 40,
|
||||
DEFAULT_HEADER_HEIGHT: 36,
|
||||
} as const;
|
||||
|
||||
@@ -132,13 +132,16 @@ describe('Tab Navigation', () => {
|
||||
expect(modalClass).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('header has no utility toggle and the tray stays collapsed on mobile', async () => {
|
||||
it('header has no utility toggle and the tray is reachable inline on mobile', async () => {
|
||||
// The three-dot header utility toggle was removed (owner decision,
|
||||
// 2026-06-10): nothing interactive may occupy the top-left corner, and
|
||||
// the headerRight tray stays collapsed (hidden) on small viewports.
|
||||
// 2026-06-10) and the collapsible position:fixed headerRight tray it
|
||||
// controlled was dropped (PR #122): with the toggle gone, leaving the
|
||||
// tray collapsed made every header-right utility — including the opt-in
|
||||
// response-viewer eye button — permanently unreachable on phones. The
|
||||
// utilities now flow INLINE and must stay reachable on small viewports;
|
||||
// nothing interactive may occupy the top-left corner.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
|
||||
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
|
||||
});
|
||||
|
||||
const toggleCount = await page.locator('#mobileHeaderUtilityToggle').count();
|
||||
@@ -148,7 +151,7 @@ describe('Tab Navigation', () => {
|
||||
const tray = document.getElementById('headerRight');
|
||||
return tray ? getComputedStyle(tray).display !== 'none' : false;
|
||||
});
|
||||
expect(trayVisible).toBe(false);
|
||||
expect(trayVisible).toBe(true);
|
||||
});
|
||||
|
||||
it('tabs remain visible on large phone and tablet headers', async () => {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @fileoverview Route coverage for the document preview/thumbnail endpoints
|
||||
* added in COD-38 (PR #120): the by-attachmentId routes
|
||||
* (/attachments/:id/preview|thumbnail) and the workspace-path routes
|
||||
* (/file-preview|/file-thumbnail). Converters are mocked, so no real
|
||||
* pdftoppm/LibreOffice is needed. Uses app.inject() — no real ports.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readFile: vi.fn(async () => Buffer.from('%PDF-1.4 fake pdf bytes')),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true, mtimeMs: 1 })),
|
||||
readdir: vi.fn(async () => []),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
mkdtemp: vi.fn(async () => '/tmp/codeman-preview-test'),
|
||||
rename: vi.fn(async () => undefined),
|
||||
rm: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, realpathSync: vi.fn((p: string) => p) };
|
||||
});
|
||||
|
||||
// Mock the converters so the routes don't shell out to real binaries.
|
||||
vi.mock('../../src/document-thumbnailer.js', () => ({
|
||||
generateFirstPageThumbnail: vi.fn(async () => ({ content: Buffer.from('\x89PNG fake'), contentType: 'image/png' })),
|
||||
}));
|
||||
vi.mock('../../src/document-preview-cache.js', () => ({
|
||||
getOfficePreviewPdfPath: vi.fn(async () => '/tmp/codeman-document-preview-cache/out.pdf'),
|
||||
getPreviewPdfDownloadName: vi.fn((name: string) => `${name.replace(/\.[^.]+$/, '')}.pdf`),
|
||||
}));
|
||||
|
||||
import { generateFirstPageThumbnail } from '../../src/document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath } from '../../src/document-preview-cache.js';
|
||||
import { attachmentRegistry, type AttachmentRecord } from '../../src/attachment-registry.js';
|
||||
|
||||
const SID = 'test-session-1';
|
||||
const WORKDIR = '/tmp/test-workdir';
|
||||
|
||||
function makeRecord(over: Partial<AttachmentRecord>): AttachmentRecord {
|
||||
return {
|
||||
attachmentId: 'att_x',
|
||||
sessionId: SID,
|
||||
filePath: `${WORKDIR}/file`,
|
||||
fileName: 'file',
|
||||
extension: 'pdf',
|
||||
attachmentType: 'document',
|
||||
size: 100,
|
||||
mtimeMs: 1,
|
||||
timestamp: 1,
|
||||
source: 'detected',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('file-routes preview/thumbnail (COD-38)', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes, { sessionId: SID });
|
||||
vi.clearAllMocks();
|
||||
attachmentRegistry.clearSession(SID);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
attachmentRegistry.clearSession(SID);
|
||||
});
|
||||
|
||||
// ---- workspace-path routes ----
|
||||
|
||||
it('file-preview converts a workspace DOCX to an inline PDF', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=deck.docx` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
expect(res.headers['content-disposition']).toContain('inline');
|
||||
expect(getOfficePreviewPdfPath).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-preview redirects a non-Office workspace file (PDF) to the raw route', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview?path=report.pdf` });
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/file-raw?path=report.pdf');
|
||||
expect(getOfficePreviewPdfPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-preview 400s for a missing path parameter', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-preview` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('file-thumbnail returns a PNG for a supported workspace file', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=deck.pdf` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(generateFirstPageThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('file-thumbnail 400s for an unsupported extension', async () => {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${SID}/file-thumbnail?path=notes.exe` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(generateFirstPageThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- by-attachmentId routes ----
|
||||
|
||||
it('by-id preview converts a registered DOCX attachment', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({
|
||||
attachmentId: 'att_docx',
|
||||
filePath: `${WORKDIR}/deck.docx`,
|
||||
fileName: 'deck.docx',
|
||||
extension: 'docx',
|
||||
})
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_docx/preview`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/pdf');
|
||||
});
|
||||
|
||||
it('by-id preview redirects a non-Office attachment (PNG) to its raw route', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({ attachmentId: 'att_png', filePath: `${WORKDIR}/shot.png`, fileName: 'shot.png', extension: 'png' })
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_png/preview`,
|
||||
});
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(300);
|
||||
expect(res.statusCode).toBeLessThan(400);
|
||||
expect(res.headers.location).toContain('/attachments/att_png/raw');
|
||||
});
|
||||
|
||||
it('by-id thumbnail returns a PNG for a registered attachment', async () => {
|
||||
attachmentRegistry.register(
|
||||
makeRecord({ attachmentId: 'att_pdf', filePath: `${WORKDIR}/deck.pdf`, fileName: 'deck.pdf', extension: 'pdf' })
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SID}/attachments/att_pdf/thumbnail`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Route tests for POST /api/status-telemetry — the Claude statusline exporter
|
||||
* endpoint that feeds the header "Plan Usage Limits" chip.
|
||||
*
|
||||
* Covers: broadcast on real telemetry + footer print-through, unknown-session
|
||||
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
|
||||
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
|
||||
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
|
||||
* and 400 on a malformed body.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
|
||||
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
|
||||
|
||||
const SID = 'test-session-1'; // default id created by createMockRouteContext
|
||||
|
||||
const REAL = {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
||||
},
|
||||
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
|
||||
cost: { total_cost_usd: 0.0415 },
|
||||
model: { display_name: 'Opus 4.8 (1M context)' },
|
||||
};
|
||||
|
||||
describe('POST /api/status-telemetry', () => {
|
||||
let h: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
|
||||
});
|
||||
|
||||
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
|
||||
|
||||
it('broadcasts plan-usage telemetry and returns the session-status footer', async () => {
|
||||
const res = await post({ sessionId: SID, data: REAL });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/plain');
|
||||
expect(res.body).toBe('Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%');
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
const [event, payload] = h.ctx.broadcast.mock.calls[0];
|
||||
expect(event).toBe(SessionStatusTelemetry);
|
||||
expect(payload).toMatchObject({
|
||||
sessionId: SID,
|
||||
fiveHour: { usedPercentage: 15 },
|
||||
sevenDay: { usedPercentage: 34 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not broadcast for an unknown session; returns the brand footer', async () => {
|
||||
const res = await post({ sessionId: 'does-not-exist', data: REAL });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe('codeman');
|
||||
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dedups identical telemetry — rebroadcasts only once', async () => {
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rebroadcasts when a displayed window percentage changes', async () => {
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
const moved = {
|
||||
...REAL,
|
||||
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
|
||||
};
|
||||
await post({ sessionId: SID, data: moved });
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does NOT rebroadcast on context-only drift (the chip never shows context %)', async () => {
|
||||
await post({ sessionId: SID, data: REAL });
|
||||
await post({ sessionId: SID, data: { ...REAL, context_window: { ...REAL.context_window, used_percentage: 91 } } });
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tolerates null in Claude's undocumented fields (no 400) and ignores them", async () => {
|
||||
const res = await post({
|
||||
sessionId: SID,
|
||||
data: {
|
||||
rate_limits: { five_hour: { used_percentage: 20, resets_at: 1781409000 }, seven_day: null },
|
||||
cost: { total_cost_usd: null },
|
||||
model: { display_name: null },
|
||||
context_window: { used_percentage: null, total_input_tokens: null, total_output_tokens: null },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
||||
const [, payload] = h.ctx.broadcast.mock.calls[0];
|
||||
expect(payload).toMatchObject({ fiveHour: { usedPercentage: 20 } });
|
||||
expect(payload.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a malformed body (missing sessionId) with 400', async () => {
|
||||
const res = await post({ data: REAL });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,11 @@
|
||||
* Port: N/A (no server start)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFileSync, mkdtempSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hostname as osHostname } from 'node:os';
|
||||
import { hostname as osHostname, tmpdir } from 'node:os';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -40,6 +40,21 @@ async function render(host?: string): Promise<string> {
|
||||
}
|
||||
|
||||
describe('WebServer index.html <title> templating (#82)', () => {
|
||||
// renderIndexHtml reads the ambient settings.json (for the gesture bundle and
|
||||
// the header-toggle marker-class strips, e.g. showPlanUsageLimits /
|
||||
// showMultiMonitorButton). Point it at an empty data dir so this test is
|
||||
// deterministic regardless of the developer's real settings — otherwise an
|
||||
// enabled toggle would strip a marker class and break the byte-identical
|
||||
// assertion below. getDataDir() reads CODEMAN_DATA_DIR fresh per call.
|
||||
const _prevDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
beforeAll(() => {
|
||||
process.env.CODEMAN_DATA_DIR = mkdtempSync(join(tmpdir(), 'codeman-title-test-'));
|
||||
});
|
||||
afterAll(() => {
|
||||
if (_prevDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = _prevDataDir;
|
||||
});
|
||||
|
||||
it('substitutes the bare <title>Codeman</title> with codeman:<host>', async () => {
|
||||
const html = await render('laptop');
|
||||
expect(html).toContain('<title>codeman:laptop</title>');
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
|
||||
import {
|
||||
ATTACHMENT_HISTORY_LIMIT,
|
||||
buildDetectedAttachmentHistoryItem,
|
||||
buildExternalAttachmentHistoryItem,
|
||||
upsertAttachmentHistory,
|
||||
} from '../src/session-attachment-history.js';
|
||||
|
||||
describe('session attachment history', () => {
|
||||
it('dedupes explicit external attachments by source path and moves latest to top', () => {
|
||||
const first = buildExternalAttachmentHistoryItem({
|
||||
sessionId: 's1',
|
||||
externalPath: '/mnt/c/docs/brief.docx',
|
||||
fileName: 'brief.docx',
|
||||
extension: 'docx',
|
||||
size: 100,
|
||||
mtimeMs: 1,
|
||||
timestamp: 10,
|
||||
});
|
||||
const second = { ...first, size: 200, mtimeMs: 2, timestamp: 20 };
|
||||
|
||||
const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
size: 200,
|
||||
mtimeMs: 2,
|
||||
timestamp: 20,
|
||||
externalPath: '/mnt/c/docs/brief.docx',
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes detected workspace attachments by relative path', () => {
|
||||
const first = buildDetectedAttachmentHistoryItem({
|
||||
sessionId: 's1',
|
||||
filePath: 'report.pdf',
|
||||
relativePath: 'out/report.pdf',
|
||||
fileName: 'report.pdf',
|
||||
extension: 'pdf',
|
||||
attachmentType: 'pdf',
|
||||
size: 100,
|
||||
timestamp: 10,
|
||||
});
|
||||
const second = { ...first, size: 150, timestamp: 20 };
|
||||
|
||||
const result = upsertAttachmentHistory(upsertAttachmentHistory([], first), second);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ relativePath: 'out/report.pdf', size: 150, timestamp: 20 });
|
||||
});
|
||||
|
||||
it('caps history to newest 100 items', () => {
|
||||
let history: SessionAttachmentHistoryItem[] = [];
|
||||
for (let i = 0; i < ATTACHMENT_HISTORY_LIMIT + 5; i++) {
|
||||
history = upsertAttachmentHistory(
|
||||
history,
|
||||
buildDetectedAttachmentHistoryItem({
|
||||
sessionId: 's1',
|
||||
filePath: `${i}.png`,
|
||||
relativePath: `out/${i}.png`,
|
||||
fileName: `${i}.png`,
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: i,
|
||||
timestamp: i,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
expect(history).toHaveLength(ATTACHMENT_HISTORY_LIMIT);
|
||||
expect(history[0].fileName).toBe('104.png');
|
||||
expect(history.at(-1)?.fileName).toBe('5.png');
|
||||
});
|
||||
|
||||
it('includes attachment history in session state', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
session.upsertAttachmentHistory({
|
||||
id: 'detected:file.png',
|
||||
sessionId: session.id,
|
||||
fileName: 'file.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 12,
|
||||
mtimeMs: 0,
|
||||
timestamp: 100,
|
||||
source: 'detected',
|
||||
relativePath: 'file.png',
|
||||
});
|
||||
|
||||
expect(session.toState().attachmentHistory).toHaveLength(1);
|
||||
expect(session.toState().attachmentHistory?.[0].fileName).toBe('file.png');
|
||||
});
|
||||
|
||||
it('sanitizes external attachment paths from public session state', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
session.upsertAttachmentHistory(
|
||||
buildExternalAttachmentHistoryItem({
|
||||
sessionId: session.id,
|
||||
externalPath: '/mnt/c/private/board-update.pdf',
|
||||
fileName: 'board-update.pdf',
|
||||
extension: 'pdf',
|
||||
size: 100,
|
||||
timestamp: 100,
|
||||
})
|
||||
);
|
||||
|
||||
const publicState = session.toState();
|
||||
const persistedHistory = session.getAttachmentHistoryForPersist();
|
||||
|
||||
expect(JSON.stringify(publicState.attachmentHistory)).not.toContain('/mnt/c/private/board-update.pdf');
|
||||
expect(publicState.attachmentHistory?.[0].id).not.toContain('/mnt/c/private/board-update.pdf');
|
||||
expect(persistedHistory?.[0].externalPath).toBe('/mnt/c/private/board-update.pdf');
|
||||
});
|
||||
|
||||
it('round-trips persisted history (incl. externalPath) through constructor restore', () => {
|
||||
const a = new Session({ workingDir: '/tmp' });
|
||||
a.upsertAttachmentHistory(
|
||||
buildExternalAttachmentHistoryItem({
|
||||
sessionId: a.id,
|
||||
externalPath: '/mnt/c/docs/deck.docx',
|
||||
fileName: 'deck.docx',
|
||||
extension: 'docx',
|
||||
size: 10,
|
||||
timestamp: 5,
|
||||
})
|
||||
);
|
||||
const persisted = a.getAttachmentHistoryForPersist();
|
||||
|
||||
const b = new Session({ workingDir: '/tmp', attachmentHistory: persisted });
|
||||
// Private copy keeps externalPath (needed to re-register/serve the file)...
|
||||
expect(b.getAttachmentHistoryForPersist()?.[0].externalPath).toBe('/mnt/c/docs/deck.docx');
|
||||
// ...but the public copy stays sanitized after restore.
|
||||
expect(JSON.stringify(b.toState().attachmentHistory)).not.toContain('/mnt/c/docs/deck.docx');
|
||||
});
|
||||
|
||||
it('ignores malformed saved history items during restore instead of throwing', () => {
|
||||
const valid: SessionAttachmentHistoryItem = {
|
||||
id: 'detected:ok.png',
|
||||
sessionId: 's',
|
||||
fileName: 'ok.png',
|
||||
extension: 'png',
|
||||
attachmentType: 'image',
|
||||
size: 1,
|
||||
mtimeMs: 0,
|
||||
timestamp: 1,
|
||||
source: 'detected',
|
||||
relativePath: 'ok.png',
|
||||
};
|
||||
// null, a non-object, and an item missing required fields must be skipped —
|
||||
// historyKey() would otherwise throw inside the constructor and abort recovery.
|
||||
const malformed = [null, 'nope', { partial: true }, valid] as unknown as SessionAttachmentHistoryItem[];
|
||||
|
||||
let session!: Session;
|
||||
expect(() => {
|
||||
session = new Session({ workingDir: '/tmp', attachmentHistory: malformed });
|
||||
}).not.toThrow();
|
||||
|
||||
const persisted = session.getAttachmentHistoryForPersist();
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted?.[0].fileName).toBe('ok.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseStatusTelemetry,
|
||||
parseSessionStatus,
|
||||
formatSessionStatusText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../src/usage-telemetry.js';
|
||||
|
||||
// Mirrors the real captured statusline payload (CC 2.1.177, Claude Max) — see
|
||||
// docs/usage-limits-display-plan.md. resets_at is epoch SECONDS.
|
||||
const REAL: RawStatuslinePayload = {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
||||
},
|
||||
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
|
||||
cost: { total_cost_usd: 0.0415495 },
|
||||
model: { display_name: 'Opus 4.8 (1M context)' },
|
||||
};
|
||||
|
||||
describe('parseStatusTelemetry', () => {
|
||||
it('normalizes the real payload, converting resets_at seconds → ms', () => {
|
||||
const t = parseStatusTelemetry(REAL);
|
||||
expect(t).not.toBeNull();
|
||||
expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 1000 });
|
||||
expect(t!.sevenDay).toEqual({ usedPercentage: 34, resetAt: 1781827200 * 1000 });
|
||||
expect(t!.contextUsedPercentage).toBe(56);
|
||||
expect(t!.costUsd).toBeCloseTo(0.0415495);
|
||||
expect(t!.modelDisplayName).toBe('Opus 4.8 (1M context)');
|
||||
});
|
||||
|
||||
it('returns null when there is no rate_limits (pre-first-response / non-subscriber)', () => {
|
||||
expect(parseStatusTelemetry({})).toBeNull();
|
||||
expect(parseStatusTelemetry(undefined)).toBeNull();
|
||||
expect(parseStatusTelemetry({ context_window: { used_percentage: 5 } })).toBeNull();
|
||||
expect(parseStatusTelemetry({ rate_limits: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a single window when only one is present', () => {
|
||||
const t = parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 50, resets_at: 1781409000 } } });
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(50);
|
||||
expect(t!.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a window with a missing or non-numeric field', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 20 }, // no resets_at → dropped
|
||||
seven_day: { used_percentage: 40, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour).toBeUndefined();
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(40);
|
||||
});
|
||||
|
||||
it('clamps percentages to 0–100', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 150, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: -5, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(100);
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a zero/negative reset timestamp', () => {
|
||||
expect(parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 10, resets_at: 0 } } })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps a NaN percentage as 0 and drops a window with a non-finite reset', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: NaN, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 40, resets_at: Infinity },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour).toEqual({ usedPercentage: 0, resetAt: 1781409000 * 1000 });
|
||||
expect(t!.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rounds a fractional resets_at to whole milliseconds', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: { five_hour: { used_percentage: 10, resets_at: 1781409000.7 } },
|
||||
});
|
||||
expect(t!.fiveHour?.resetAt).toBe(Math.round(1781409000.7 * 1000));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSessionStatus', () => {
|
||||
it('extracts model, token totals, and context % for the footer', () => {
|
||||
const s = parseSessionStatus(REAL);
|
||||
expect(s).toEqual({
|
||||
modelDisplayName: 'Opus 4.8 (1M context)',
|
||||
inputTokens: 562411,
|
||||
outputTokens: 1188,
|
||||
contextUsedPercentage: 56,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when none of the footer fields are present', () => {
|
||||
expect(parseSessionStatus({})).toBeNull();
|
||||
expect(parseSessionStatus(undefined)).toBeNull();
|
||||
// rate_limits alone is not session status
|
||||
expect(parseSessionStatus({ rate_limits: { five_hour: { used_percentage: 5, resets_at: 1 } } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSessionStatusText', () => {
|
||||
it('formats the footer with comma-grouped tokens', () => {
|
||||
expect(formatSessionStatusText(parseSessionStatus(REAL))).toBe(
|
||||
'Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%'
|
||||
);
|
||||
});
|
||||
|
||||
it('omits groups that are missing', () => {
|
||||
expect(formatSessionStatusText({ contextUsedPercentage: 12 })).toBe('ctx:12%');
|
||||
expect(formatSessionStatusText({ modelDisplayName: 'Opus 4.8 (1M context)' })).toBe('Opus 4.8 (1M context)');
|
||||
});
|
||||
|
||||
it('falls back to a brand string when there is no data', () => {
|
||||
expect(formatSessionStatusText(null)).toBe('codeman');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetrySignature', () => {
|
||||
it('is stable for equal telemetry and changes when a percentage moves', () => {
|
||||
const a = parseStatusTelemetry(REAL)!;
|
||||
const b = parseStatusTelemetry(REAL)!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
|
||||
const moved = parseStatusTelemetry({
|
||||
...REAL,
|
||||
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
|
||||
})!;
|
||||
expect(telemetrySignature(moved)).not.toBe(telemetrySignature(a));
|
||||
});
|
||||
|
||||
it('ignores contextUsedPercentage (not displayed) so it does not churn each message', () => {
|
||||
const base = { rate_limits: { five_hour: { used_percentage: 15, resets_at: 1781409000 } } };
|
||||
const a = parseStatusTelemetry({ ...base, context_window: { used_percentage: 56 } })!;
|
||||
const b = parseStatusTelemetry({ ...base, context_window: { used_percentage: 91 } })!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
});
|
||||
|
||||
it('keys on the ROUNDED window percentage (matches the chip) — sub-integer drift is ignored', () => {
|
||||
const sig = (p: number) =>
|
||||
telemetrySignature(
|
||||
parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: p, resets_at: 1781409000 } } })!
|
||||
);
|
||||
expect(sig(15.1)).toBe(sig(15.4)); // both render as 15%
|
||||
expect(sig(15.1)).not.toBe(sig(15.6)); // 15% vs 16%
|
||||
});
|
||||
|
||||
it('excludes cost/model (not shown in the chip) from the signature', () => {
|
||||
const base = { rate_limits: { five_hour: { used_percentage: 15, resets_at: 1781409000 } } };
|
||||
const a = parseStatusTelemetry({ ...base, cost: { total_cost_usd: 0.01 }, model: { display_name: 'A' } })!;
|
||||
const b = parseStatusTelemetry({ ...base, cost: { total_cost_usd: 9.99 }, model: { display_name: 'B' } })!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user