Compare commits

...
Author SHA1 Message Date
Codeman maintainer 9df310c30a chore: version packages 2026-06-23 23:13:55 +02:00
Codeman maintainer 50b8f1d9a0 feat(mobile): large + multi-image uploads from the camera-roll picker
The mobile copy/paste overlay's "🖼 Image" button (and drag-drop / paste)
now handles real-world photo batches:

- Up to 20 images per batch, uploaded with bounded concurrency (3) and a
  live "Uploading N/M…" progress toast; a final summary reports successes,
  any failures, and whether the 20-cap trimmed the selection (no silent
  truncation).
- Per-file upload limit raised 10MB → 50MB (MAX_PASTE_IMAGE_BYTES in
  buffer-limits.ts, env-overridable) so full-resolution phone photos and
  large screenshots aren't rejected.
- Very large images are downscaled to <=4096px longest edge before upload:
  fixes iOS Safari's ~16.7M-px <canvas> limit (which made huge photos fail
  to re-encode and fall back to an original that tripped the magic-byte
  check), and keeps batch uploads fast and small.
- Fix a latent concurrency bug the batch path exposed: the first parallel
  uploads to a session raced on `mkdir(.claude-images)` and the EEXIST
  losers 500'd. mkdir now treats an existing real directory as success
  (re-verifying it isn't a planted symlink), so concurrent uploads succeed.

Verified end-to-end in a real browser (Playwright): downscale, >10MB
server acceptance, 20-cap, 20/20 concurrent uploads landing on disk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:12:41 +02:00
Codeman maintainer 1255e28f6f fix(input): durable exactly-once input delivery so a dropped link can't lose a prompt
A "sent" prompt could vanish with no trace on a flaky connection (e.g. a train):
with local echo on, Enter cleared the overlay then sent over the WebSocket
fire-and-forget. On a half-open socket (readyState===OPEN, dead TCP) ws.send()
doesn't throw, so the frame was silently discarded, nothing was enqueued, and
navigator.onLine stayed true — the prompt was lost and never resent.

Replace the best-effort offline queue with a durable, acknowledged delivery layer:

- Client (app.js): every input frame is recorded with a stable clientId +
  monotonic per-session seq and persisted to localStorage BEFORE delivery, and
  only dropped on a server ACK. Delivered over WS (acked via {t:'ia',seq}) or,
  when the socket is down, POST in seq order (HTTP 2xx = ACK). A 2s sweep
  force-reconnects a WS whose oldest frame is unacked past 4s (half-open sockets
  never recover on their own); on reconnect/reload all pending frames re-deliver.
  Survives reconnects AND page reloads. Connection indicator shows pending count.
- Server: Session.shouldApplyInput(clientId, seq) applies each frame exactly once
  (bounded MRU map); ws-routes + POST /input dedup a redelivered seq but still ACK
  it (200 / {t:'ia'}), so an at-least-once resend can never type the prompt twice.
  Untagged input (curl/legacy) applies unconditionally — no behavior change.
- terminal-ui.js sendInput() (voice / keyboard-accessory / paste) now routes
  through the same durable layer.

Tests: test/reliable-input-dedup.test.ts (exactly-once semantics on the real
Session) + POST /input dedup route tests. Design: docs/reliable-input-delivery.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:58:40 +02:00
Codeman maintainer 9d12fc7f94 feat(gesture): hand-drag subagent & ultracode windows in the gesture beta
Pinch any floating subagent or ultracode run/transcript window with the
camera hand-tracking overlay and move it anywhere. Adds a 'window' grab
kind to entry.ts, slotted into the pinch priority chain
(cg-float panel → agent window → session tab → toolbar button). It moves
the window via its own style.left/top (matching app.js's mouse drag,
incl. bottom:'auto') and calls window.app.updateConnectionLines() so the
glowing connector line to the session tab tracks live — app.js redraws
from fresh rects, so no reach into its internals.

Hardening: el.isConnected guard (ultracode windows tear down mid-grab on
SSE reconnect / auto-close), all window.app calls optional-chained +
try/caught so the standalone playground still works, bring-to-front via
app.js's own z-counters, rAF-coalesced redraws cleared on drop so the
final placement always redraws.

Rebuilt the committed gesture-codeman.js bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:01:04 +02:00
Codeman maintainer 5d406c9705 chore: version packages 2026-06-19 15:31:57 +02:00
Codeman maintainer a8782b364f fix(security): harden remaining inline onclick handlers against XSS double-context
Extends PR #132 (ultracode handlers) to the rest of the frontend. The same
JS-string-in-HTML-attribute pattern — '${escapeHtml(value)}' — remained in 32
more inline handlers across app.js, panels-ui.js, session-ui.js,
subagent-windows.js, and notification-manager.js. The browser HTML-decodes the
attribute value before parsing the handler source, so escapeHtml's &#39; reverts
to ' and a quote-bearing id/path/name breaks out of the JS string literal into
executable code.

Switch all to escapeHtml(JSON.stringify(value)): JSON.stringify JS-encodes and
quote-wraps first, then escapeHtml handles the HTML-attribute layer, so the
value round-trips as one inert string argument.

Also fixes two non-escapeHtml variants of the same class:
- panels-ui.js: mux-session `sid` was pre-escaped with escapeHtml() then dropped
  into a single-quoted JS string (selectSession / killMuxSession). Now
  JSON.stringify'd at the source.
- orchestrator-panel.js: phase.id was interpolated raw (no escaping at all) into
  orchestratorSkipPhase / orchestratorRetryPhase. Now escapeHtml(JSON.stringify()).

The most realistic vector here is file paths (panels-ui openLogViewerWindow) —
filenames can legally contain a single quote.

Numeric interpolations (${i+1}, ${index}, ${item.version}) and the
developer-literal ${onclick} in orchestrator-panel are not user data and are
left as-is. Verified: 0 vulnerable patterns remain, all 22 frontend files parse
(check:frontend-syntax + node --check), and a runtime round-trip confirms the
injection that fired under the old pattern is now an inert string argument.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:26:42 +02:00
Ark0N d8da1bd3ff Merge pull request #132 from aakhter/cod-127-xss-ultracode-handlers
Harden ultracode inline onclick handlers against XSS
2026-06-19 15:10:28 +02:00
Aamer Akhter 06871eb7e3 Harden ultracode inline onclick handlers against XSS
The ultracode run/agent cards and minimized-tab badges built inline onclick
handlers by interpolating escapeHtml(value) inside single-quoted JavaScript
strings within an HTML attribute:

    onclick="app.openUltracodeAgentWindow('${escapeHtml(agentId)}', ...)"

escapeHtml maps ' -> &#39;, but the browser HTML-decodes the attribute value
before the handler source is parsed, so &#39; becomes a literal ' again and a
quote in a run/agent/session id breaks out of the string literal into
executable JS. escapeHtml alone is insufficient for the JS-string-within-HTML-
attribute double context.

Switch each handler to escapeHtml(JSON.stringify(value)): JSON.stringify
JS-encodes and quote-wraps the value, then escapeHtml handles the HTML
attribute layer, so the value round-trips as an inert string argument. This
matches the encoding already used by other handlers in these files.

Affected:
- ultracode-panel.js: selectWorkflowRun, openUltracodeAgentWindow
- ultracode-windows.js: restore/dismiss for minimized run and agent tabs
2026-06-19 08:55:24 -04:00
Codeman maintainer 5d59c1764d feat(ultracode): in-page agent transcript windows + minimize-to-tab (1.1.14)
Clicking an agent card opens its live transcript as an in-page connected
floating window instead of a detached browser popup. The "−" button on both
run and agent windows now minimizes into the originating session tab as a
restorable ULTRA badge (🧬 runs, 📄 transcripts). Removes the old
collapse-to-header behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:26:33 +02:00
Codeman maintainer cfcd9d288b fix(mobile): keep /compact in extended accessory bar, only drop it from simple
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:29:49 +02:00
Codeman maintainer 9c22114b5a fix(mobile): remove /compact button from keyboard accessory bar (reintroduced in 1.1.10)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:13:23 +02:00
Codeman maintainer 98b2124d7e feat(ultracode): enrich live run tracking — real per-agent tokens/tools/state, readable title, blue connector line, click-to-open window
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:00:04 +02:00
30 changed files with 2097 additions and 273 deletions
+62
View File
@@ -1,5 +1,67 @@
# aicodeman
## 1.1.16
### Patch Changes
- Mobile image uploads, reliable input delivery, and gesture window dragging.
**Mobile image uploads (camera-roll picker / drag-drop / paste).** The "🖼 Image" button now handles real photo batches: up to 20 images per batch uploaded with bounded concurrency and a live "Uploading N/M…" progress toast (with a summary of successes, failures, and whether the 20-cap trimmed the selection). The per-file limit is raised from 10MB to 50MB (`MAX_PASTE_IMAGE_BYTES`, env-overridable via `CODEMAN_MAX_PASTE_IMAGE_BYTES`) so full-resolution phone photos and large screenshots are accepted. Very large images are downscaled to ≤4096px on the longest edge before upload, fixing iOS Safari's ~16.7M-px `<canvas>` limit that previously made huge photos fail to re-encode. Also fixes a latent concurrency bug the batch path exposed where the first parallel uploads to a session raced on creating `.claude-images/` and failed with EEXIST.
**Reliable, exactly-once input delivery.** A "sent" prompt could be silently lost on a flaky connection (e.g. a train): a half-open WebSocket accepts `ws.send()` without error while discarding the frame, and nothing was queued or resent. Input is now recorded durably (localStorage) with a stable clientId + monotonic per-session sequence before delivery, and only dropped once the server ACKs it — delivered over the WebSocket (acked via `{t:'ia',seq}`) or, when the socket is down, over POST in order. A 2s sweep force-reconnects a half-open socket; pending input survives reconnects and page reloads. The server applies each `(clientId, seq)` at most once (`Session.shouldApplyInput`), so an at-least-once resend can never type the prompt twice. Untagged input (curl/legacy) is unchanged. See `docs/reliable-input-delivery.md`.
**Gesture beta: drag agent windows.** With the camera hand-tracking overlay, you can now pinch and move the floating subagent and ultracode run/transcript windows. They keep their glowing connector line to the session tab while moving and can travel across a multi-monitor seam.
## 1.1.15
### Patch Changes
- Security: harden all frontend inline `onclick`/`ondblclick` handlers against a stored-XSS double-context bug.
Many inline handlers interpolated values as `'${escapeHtml(value)}'` — a JavaScript string literal sitting inside an HTML attribute. The browser HTML-decodes the attribute value _before_ parsing the handler source, so `escapeHtml`'s `&#39;` reverts to a literal `'` and a quote-bearing id/name/path/URL breaks out of the JS string into executable code. `escapeHtml` alone is insufficient for this JS-string-within-HTML-attribute context.
All affected handlers now use `escapeHtml(JSON.stringify(value))`: `JSON.stringify` JS-encodes and quote-wraps the value, then `escapeHtml` handles the HTML-attribute layer, so the value round-trips as a single inert string argument.
- ultracode run/agent cards and minimized-tab badges (`ultracode-panel.js`, `ultracode-windows.js`) — PR #132.
- Session tabs (click/rename/gear/detach/close), notifications, subagent windows + dropdowns, the agents/tools/log-viewer/image-popup panels, mux-session monitor rows, and case-management buttons (`app.js`, `notification-manager.js`, `subagent-windows.js`, `panels-ui.js`, `session-ui.js`).
- Two non-`escapeHtml` variants of the same class: a pre-escaped mux-session id in `panels-ui.js` (`selectSession`/`killMuxSession`) and a fully raw, unescaped `phase.id` in `orchestrator-panel.js` (`orchestratorSkipPhase`/`orchestratorRetryPhase`).
The most realistic exploitation vector was file paths in the project-insights log-viewer link, since filenames can legally contain a single quote. Purely numeric interpolations and developer-literal handler strings were left unchanged.
## 1.1.14
### Patch Changes
- Ultracode (Workflow-tool) floating windows — agent transcripts in-page, and minimize-to-tab.
- **Agent transcripts open in-page, connected, instead of a detached browser popup.** Clicking an agent card (in a run window or the dock panel) now opens the agent's live transcript as its own draggable floating window, tied by a connector line to its parent run window (falling back to the run's session tab if that window has since closed) — the same line idiom the run windows use. Re-clicking a card focuses the existing window; closing it removes the window and its line. (Previously this spawned a separate `window.open` browser popup.)
- **The window "−" button now minimizes into the originating session tab**, mirroring the subagent-window idiom. The window genie-animates into its tab and is tracked there; the tab shows an `ULTRA` badge whose hover/click dropdown lists each minimized item (🧬 run windows, 📄 agent transcripts). Click an item to restore its floating window, or dismiss it with ×. A run minimized while still active keeps tracking in the background and its badge auto-clears shortly after the run finishes. Both run windows and agent-transcript windows minimize into the same merged badge.
- Removed the old collapse-to-header behavior that the "−" button previously triggered (now superseded by minimize-to-tab).
## 1.1.13
### Patch Changes
- Keep the `/compact` button in the extended (full) mobile keyboard accessory bar; only the simple bar drops it. (1.1.12 had removed it from both.)
## 1.1.12
### Patch Changes
- Remove the `/compact` button from the mobile keyboard accessory bar. It had been reintroduced in 1.1.10; this removes the button from both the simple and full accessory-bar layouts (the underlying command handler is left in place as inert plumbing).
## 1.1.11
### Patch Changes
- Ultracode (Workflow-tool) run visualization — much better live tracking.
While a run is in flight, the watcher previously showed empty agent slots ("agent N", 0 tokens, raw `wf_…` id as the title) because the detailed completion JSON only lands when the run finishes. The live path now enriches in-flight runs directly from the on-disk transcript tree:
- **Real per-agent stats mid-run** — tokens and tool-call counts are parsed from each `agent-<id>.jsonl` transcript (tool counts match the final accounting exactly; token totals land within ~1% of the completion value), with model and a prompt preview. All mtime-cached (transcripts, journal, and script meta) so idle polls do no extra reads.
- **Readable window/run title** — workflow name, summary, and phases are derived from the persisted `workflows/scripts/<name>-<runId>.js` instead of showing the raw run id.
- **Agent status colors** — done agents show green, working agents show yellow (this also fixes the run/agent status badges, which referenced undefined `--success`/`--warning` CSS variables and were rendering with no color).
- **Connector line** — the floating-window → session-tab line now uses the session-tab accent blue (was purple).
- **Click a run to open its floating window** — clicking a workflow in the dock panel opens (or focuses) its floating window with the connector line, in addition to the auto-popped windows.
- Agents are ordered by journal launch order; concurrent run-detail fetches are de-duplicated.
## 1.1.10
### Patch Changes
+3 -3
View File
@@ -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.1.10 (must match `package.json`)
**Version**: 1.1.16 (must match `package.json`)
## Project Overview
@@ -218,11 +218,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
### SSE Event Registry
~120 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
~127 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
### API Routes
~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.
~147 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`).
+72
View File
@@ -0,0 +1,72 @@
# Reliable input delivery (exactly-once, durable)
## The bug this fixes
With local echo on, pressing Enter cleared the overlay and then sent the prompt
over the WebSocket **fire-and-forget** (`ws.send({t:'i',d})`). On a flaky link
(e.g. a moving train) the socket is frequently *half-open*: `readyState === OPEN`
so `ws.send()` does **not** throw, but the underlying TCP is dead, so the frame is
silently discarded. Nothing was enqueued (the send "succeeded"), the on-screen
prompt was already wiped, and `navigator.onLine` stays `true` — so a long typed
prompt vanished with no trace and no resend.
## The guarantee
Every byte of user input is **recorded durably before delivery** and **only
dropped once the server ACKs it** — so a half-open socket, a reconnect, or a page
reload can never lose input. Redelivery is **exactly-once**: the server applies
each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
## How it works
### Client (`app.js`)
- A stable **`clientId`** (`localStorage['codeman:clientId']`) identifies this
browser to the server's dedup across reconnects and reloads.
- Each input frame gets a **monotonic per-session `seq`**. Frame records
(`{seq,data,useMux,ts,tries,sentAt}`) live in `_pendingDeliveries`
(`Map<sessionId, record[]>`), persisted (debounced, + flushed on `pagehide`/
`visibilitychange`) to `localStorage['codeman:pendingInput']`. The seq counters
persist too, so seqs stay monotonic across reloads (never reset — a reset would
let the server treat fresh input as an already-applied duplicate).
- **Delivery** (`_drainSession`):
- **WS path** — when the socket is `OPEN` for the session, send each not-yet-sent
record (`sentAt === 0`) in seq order over the single ordered stream. Records
stay pending until the server's `{t:'ia',seq}` ACK removes them.
- **POST path** — when no WS, POST records in order, awaiting each (the HTTP 2xx
*is* the ACK). A 404/410 (session gone) drops the record rather than retry
forever.
- **Half-open recovery** (`_redeliverSweep`, every 2s): if the active WS session's
oldest record is unacked past `_reliableAckTimeoutMs` (4s), the socket is assumed
dead — `ws.close()` forces a fast reconnect; `onopen` (`_onWsReady`) resets
`sentAt = 0` and re-sends everything pending. Also re-drains background sessions
over POST, and fires on SSE-reconnect / `online`.
- The connection indicator shows pending count/bytes (`_pendingBytes`).
### Server
- **`Session.shouldApplyInput(clientId, seq)`** — returns `true` exactly once per
`(clientId, seq)`: the first time a seq strictly greater than that client's
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
via `shouldApplyInput` (skips a duplicate, still ACKs with `{t:'ia',seq}` so the
client drops it). Untagged frames apply unconditionally (no behavior change).
- **POST route** (`/api/sessions/:id/input`) — optional `seq`/`clientId` in
`SessionInputWithLimitSchema`; a deduped duplicate returns 200 without writing
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
apply.
## Known limitation
Dedup state is in-memory on the server. A **server restart** between a write and
the client's redelivery of that same seq could re-apply it (a rare duplicate).
This is a deliberate trade-off: favor *never losing input* over a rare duplicate
across the narrow restart window.
## Tests
- `test/reliable-input-dedup.test.ts` — `Session.shouldApplyInput` exactly-once
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
`(clientId, seq)` once on redelivery; untagged input always applies.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.1.10",
"version": "1.1.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.1.10",
"version": "1.1.16",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.1.10",
"version": "1.1.16",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+138 -5
View File
@@ -17,6 +17,13 @@
// • Panel "re-grab" — pinch an existing floating panel and move it anywhere;
// release over the tab strip to re-dock it (panel goes away, the tab stays).
// This is the capability the old OS-window detach lost.
// • Agent-window "grab-to-move" — pinch any floating *subagent* or *ultracode*
// run/transcript window (the dashboard's own `.subagent-window` /
// `.ultracode-window` floats) and move it anywhere. These windows stay owned
// by app.js — we only nudge their `style.left/top` and ask app.js to redraw
// the glowing connector line back to their session tab (its redraw reads live
// rects, so the line tracks without us touching app.js internals). This is the
// multi-monitor verb that lets these windows cross the physical monitor seam.
// • Button "tap" — pinch over a toolbar button (Run / Run Shell) and release
// in place → fires the button's real click handler. Drift too far first and
// it's treated as a stray move, not a tap.
@@ -36,12 +43,29 @@ import type { HandState } from '../gesture/types.ts';
declare global {
interface Window {
__codemanGesture?: GestureBridge;
/** The Codeman dashboard singleton (app.js, `window.app`). The gesture layer
* reaches into it to redraw the floating-window connector lines and bump a
* grabbed window's z-order while moving the subagent / ultracode windows.
* Loosely typed — only the few members we touch. */
app?: {
updateConnectionLines?: () => void;
saveSubagentWindowStates?: () => void;
subagentWindowZIndex?: number;
ultracodeWindowZIndex?: number;
};
}
}
const TAB_SELECTOR = '.session-tab';
/** An in-page floating session panel this layer spawned — re-grabbable to move. */
const PANEL_SELECTOR = '.cg-float';
/** The dashboard's own floating agent windows (subagent runs + ultracode run and
* transcript windows). All three carry one of these classes, position via
* `style.left/top`, and redraw their connector line from
* `window.app.updateConnectionLines()` — so the hand can pick one up and move it
* without app.js knowing. (`.ultracode-agent-window` also carries
* `.ultracode-window`, so this matches it too.) */
const WINDOW_SELECTOR = '.subagent-window, .ultracode-window';
/** The session-tab strip; dropping a moved panel over it re-docks the session. */
const DOCK_SELECTOR = '.session-tabs';
/** Toolbar buttons a pinch can "tap": Run (#runBtn → app.run()) and Run Shell
@@ -93,6 +117,17 @@ type Grab =
dy: number;
/** Cursor currently over the tab strip → releasing re-docks. */
overDock: boolean;
}
| {
/** A dashboard-owned floating agent window (subagent / ultracode) being
* moved. We never remove or re-parent it — just reposition + redraw its
* connector. The element ref can go stale mid-grab (SSE reconnect tears
* ultracode windows down), so every move guards on `el.isConnected`. */
kind: 'window';
el: HTMLElement;
/** Cursor→window-top-left offset at grab, so it doesn't snap. */
dx: number;
dy: number;
};
/** Live state for one hand pinching a toolbar button (Run / Run Shell). */
@@ -122,6 +157,8 @@ class GestureBridge {
private taps = new Map<string, Tap>();
/** Live floating panels, keyed by session id (idempotent per id). */
private floats = new Map<string, FloatingPanel>();
/** rAF coalescing for connector-line redraws while dragging an agent window. */
private connectorRedrawScheduled = false;
constructor() {
injectStyles();
@@ -187,7 +224,7 @@ class GestureBridge {
await this.gc.start();
this.running = true;
this.button.classList.add('on');
this.status.textContent = 'on — pinch a tab or button';
this.status.textContent = 'on — pinch a tab, window, or button';
} catch (err) {
// Surface the *real* cause: MediaPipe/Emscripten can throw a non-Error
// (number/string), so `(err as Error).message` was logging "undefined".
@@ -242,6 +279,22 @@ class GestureBridge {
}
}
// A dashboard-owned floating agent window (subagent / ultracode run or
// transcript) → pick it up and move it. Priority below cg-float panels
// (which sit far above), above tabs/buttons. We grab anywhere on the window
// (not just its titlebar) since the hand is choosing the whole window.
const win = this.hitClosest(x, y, WINDOW_SELECTOR);
if (win) {
const rect = win.getBoundingClientRect();
// Match app.js's own drag: drop any bottom-anchor so left/top take effect.
win.style.bottom = 'auto';
win.classList.add('cg-win-grabbed');
this.bringWindowToFront(win);
this.grabs.set(hand, { kind: 'window', el: win, dx: x - rect.left, dy: y - rect.top });
this.status.textContent = 'moving window';
return;
}
// A session tab → grab-and-pull-out into a floating panel (ghost follows).
const tab = this.hitClosest(x, y, TAB_SELECTOR);
const id = tab?.dataset.id;
@@ -292,12 +345,16 @@ class GestureBridge {
}
return;
}
if (grab?.kind === 'window') {
this.moveWindow(grab.el, x - grab.dx, y - grab.dy);
return;
}
// A button pinch that drifts too far is a stray move, not a tap — cancel it.
const tap = this.taps.get(hand);
if (tap && Math.hypot(x - tap.ox, y - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove('cg-tap-armed');
this.taps.delete(hand);
this.status.textContent = 'on — pinch a tab or button';
this.status.textContent = 'on — pinch a tab, window, or button';
}
}
@@ -319,6 +376,23 @@ class GestureBridge {
else this.flash('placed');
return;
}
if (grab?.kind === 'window') {
this.grabs.delete(hand);
grab.el.classList.remove('cg-win-grabbed');
// Clear the coalescer so the final placement always redraws, even if a
// mid-drag rAF was throttled (tab briefly backgrounded) and left it latched.
this.connectorRedrawScheduled = false;
this.redrawWindowConnectors();
// Persist subagent-window positions like app.js's own drag end does
// (a no-op for ultracode windows, which aren't position-persisted).
try {
window.app?.saveSubagentWindowStates?.();
} catch {
/* best-effort */
}
this.flash('placed window');
return;
}
// Release over the same button → fire its real click handler.
const tap = this.taps.get(hand);
if (tap) {
@@ -373,6 +447,59 @@ class GestureBridge {
float.el.style.top = `${t}px`;
}
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
* redraw its connector line. The window self-positions via `style.left/top` and
* app.js's connector redraw reads live rects, so this tracks without touching
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
* which equals the *spanned* viewport in a multi-monitor window — so the window
* can still travel across the physical monitor seam, just not off-screen. */
private moveWindow(el: HTMLElement, left: number, top: number): void {
if (!el.isConnected) return;
const w = el.offsetWidth || 380;
const h = el.offsetHeight || 320;
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w - 4));
const t = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h - 4));
el.style.left = `${l}px`;
el.style.top = `${t}px`;
this.redrawWindowConnectors();
}
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
private redrawWindowConnectors(): void {
if (this.connectorRedrawScheduled) return;
this.connectorRedrawScheduled = true;
requestAnimationFrame(() => {
this.connectorRedrawScheduled = false;
try {
window.app?.updateConnectionLines?.();
} catch {
/* app.js may not expose it (standalone playground) */
}
});
}
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
private bringWindowToFront(el: HTMLElement): void {
const app = window.app;
if (!app) return;
try {
if (el.classList.contains('ultracode-window')) {
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1000) + 1;
el.style.zIndex = String(app.ultracodeWindowZIndex);
} else {
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1000) + 1;
el.style.zIndex = String(app.subagentWindowZIndex);
}
} catch {
/* cosmetic only */
}
}
private positionGhost(ghost: HTMLElement, x: number, y: number): void {
ghost.style.left = `${x}px`;
ghost.style.top = `${y}px`;
@@ -385,17 +512,19 @@ class GestureBridge {
if (grab.kind === 'tab') {
grab.ghost.remove();
grab.tab.classList.remove('cg-grabbed');
} else {
} else if (grab.kind === 'panel') {
grab.panel.el.style.pointerEvents = '';
grab.panel.el.classList.remove('cg-float-grabbed', 'cg-redock');
} else {
grab.el.classList.remove('cg-win-grabbed');
}
}
this.grabs.clear();
for (const tap of this.taps.values()) tap.el.classList.remove('cg-tap-armed');
this.taps.clear();
document
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`)
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed'));
.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`)
.forEach((t) => t.classList.remove('cg-grabbed', 'cg-tap-armed', 'cg-win-grabbed'));
}
private onStatus(fps: number, hands: HandState[]): void {
@@ -491,6 +620,10 @@ function injectStyles(): void {
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
outline: 2px solid #4ade80 !important; outline-offset: -2px;
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
}
.cg-float {
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
z-index: ${Z}; display: flex; flex-direction: column; overflow: hidden;
+15
View File
@@ -96,3 +96,18 @@ export const TRIM_RESPAWN_BUFFER_TO = 512 * 1024; // 512KB
* which is enough to extract metadata from the first few JSONL lines.
*/
export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
// ============================================================================
// Paste-Image Upload Limits
// ============================================================================
/**
* Maximum size (bytes) of a single image uploaded via POST
* /api/sessions/:id/paste-image. The mobile picker / drag-drop / paste paths
* send one file per request (the client uploads up to MAX_PASTE_IMAGES of them
* per batch), so this caps each individual file, not the batch. Generous enough
* for full-resolution phone photos and large screenshots; the client downscales
* very large images before upload, so legitimate uploads land well under this.
* Override: CODEMAN_MAX_PASTE_IMAGE_BYTES (bytes)
*/
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
+36
View File
@@ -2213,6 +2213,42 @@ export class Session extends EventEmitter {
}
}
/**
* Per-client highest-applied input sequence, for exactly-once input delivery.
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
* long-lived session can't grow it without limit (insertion order = MRU, so
* eviction drops the least-recently-active client).
*/
private _appliedInputSeq = new Map<string, number>();
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
/**
* Decide whether an input frame should be applied to the PTY or skipped as a
* duplicate redelivery. Returns true exactly once per (clientId, seq): the
* first time a seq strictly greater than the client's last-applied is seen.
* A redelivery of an already-applied seq (the client never got our ACK and
* resent) returns false. Callers should ACK regardless — a duplicate is, from
* the client's view, "delivered" — and only `write()` the PTY when this is
* true. Relies on the client delivering one client's frames in seq order over
* a single ordered stream, so `seq <= last` ⇒ already applied.
*
* Without this, the client's at-least-once redelivery (needed because a
* half-open socket silently drops frames with no error) would type a prompt
* twice whenever an ACK is lost after the write landed.
*/
shouldApplyInput(clientId: string, seq: number): boolean {
const last = this._appliedInputSeq.get(clientId);
if (last !== undefined && seq <= last) return false;
// Re-insert to move this client to the MRU end for fair eviction.
if (last !== undefined) this._appliedInputSeq.delete(clientId);
this._appliedInputSeq.set(clientId, seq);
if (this._appliedInputSeq.size > Session.MAX_INPUT_DEDUP_CLIENTS) {
const oldest = this._appliedInputSeq.keys().next().value;
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
}
return true;
}
/**
* Sends input via the terminal multiplexer's direct input mechanism.
*
+306 -80
View File
@@ -468,13 +468,28 @@ class CodemanApp {
this.maxReconnectAttempts = 10;
this.isOnline = navigator.onLine;
// Offline input queue
this._inputQueue = new Map(); // Map<sessionId, string>
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
// Reliable, durable input delivery (replaces the old best-effort queue).
// Every input byte is recorded with a stable clientId + a monotonic
// per-session seq, persisted to localStorage, and only dropped once the
// server ACKs that exact seq — so a half-open socket silently dropping a
// frame, a reconnect, or a page reload can never lose a typed prompt.
// Exactly-once: the server applies each (clientId, seq) at most once.
this._connectionStatus = 'connected';
// Sequential input send chain — ensures keystroke ordering across async fetches
this._inputSendChain = Promise.resolve();
this._clientId = '';
this._seqCounters = new Map(); // sessionId -> last issued seq
this._pendingDeliveries = new Map(); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}]
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
this._persistReliableTimer = null;
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
this._loadReliableState();
this._reliableSweepTimer = setInterval(() => this._redeliverSweep(), 2000);
// Flush the durable queue synchronously when the page is hidden/closed —
// debounced persistence may have a pending write we mustn't lose on reload.
window.addEventListener('pagehide', () => this._persistReliableNow());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this._persistReliableNow();
});
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
// (not at buffer.cursorY, which reflects Ink's internal cursor position)
@@ -1931,8 +1946,10 @@ class CodemanApp {
setConnectionStatus(status) {
this._connectionStatus = status;
this._updateConnectionIndicator();
if (status === 'connected' && this._inputQueue.size > 0) {
this._drainInputQueues();
if (status === 'connected') {
// Reconnected (SSE) — push any durably-queued input out immediately
// instead of waiting for the next 2s sweep.
this._redeliverSweep();
}
}
@@ -1965,6 +1982,9 @@ class CodemanApp {
// went over HTTP, which never claims (see ws-routes sizingToken).
this.sendResize(sessionId)?.catch?.(() => {});
this._startMobileResizeRetry(sessionId);
// Flush any durably-queued input over the fresh socket (covers frames a
// prior half-open socket silently dropped, and input typed while offline).
this._onWsReady(sessionId);
}
};
@@ -1979,6 +1999,10 @@ class CodemanApp {
this._onSessionClearTerminal({ id: sessionId });
} else if (msg.t === 'r') {
this._onSessionNeedsRefresh({ id: sessionId });
} else if (msg.t === 'ia') {
// Input ACK — the server applied (or deduped) this seq; drop it from
// the durable queue so it can never be re-delivered/lost.
this._onWsInputAck(msg.seq);
}
} catch {
// Ignore malformed messages
@@ -2062,79 +2086,270 @@ class CodemanApp {
}
/**
* Send input to server without blocking the keystroke flush cycle.
* Uses a sequential promise chain to preserve character ordering
* across concurrent async fetches.
* Public input entry point — name/signature kept for all call sites.
* Records the input durably, then delivers it reliably (exactly-once). Never
* blocks the keystroke flush; never silently drops on a half-open socket.
* @param {string} sessionId
* @param {string} input
* @param {{useMux?: boolean}} [opts] - useMux only affects the POST fallback.
*/
_sendInputAsync(sessionId, input) {
// Queue immediately if offline
if (!this.isOnline || this._connectionStatus === 'disconnected') {
this._enqueueInput(sessionId, input);
_sendInputAsync(sessionId, input, opts) {
if (!sessionId || !input) return;
this._reliableSend(sessionId, input, opts?.useMux === true);
}
/** Record one input frame and kick delivery. The record lives until ACKed. */
_reliableSend(sessionId, data, useMux) {
const seq = this._nextSeq(sessionId);
const rec = { seq, data, useMux: !!useMux, ts: Date.now(), tries: 0, sentAt: 0 };
let list = this._pendingDeliveries.get(sessionId);
if (!list) {
list = [];
this._pendingDeliveries.set(sessionId, list);
}
list.push(rec);
this._persistReliableState();
this._updateConnectionIndicator();
this._drainSession(sessionId);
}
_nextSeq(sessionId) {
const next = (this._seqCounters.get(sessionId) || 0) + 1;
this._seqCounters.set(sessionId, next);
return next;
}
/** Deliver all unacked records for a session, in seq order. */
_drainSession(sessionId) {
const list = this._pendingDeliveries.get(sessionId);
if (!list || list.length === 0) return;
// Fast path: WebSocket open for this session — fire each not-yet-sent record
// over the single ordered stream. They stay pending until the server ACKs
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
for (const rec of list) {
if (rec.sentAt !== 0) continue;
try {
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
rec.sentAt = Date.now();
rec.tries++;
} catch {
break; // socket died mid-send — reconnect/POST drainer retries
}
}
return;
}
// Fast path: WebSocket — fire-and-forget, inherently ordered (single TCP stream).
if (this._wsReady && this._wsSessionId === sessionId) {
// Slow path: no WS — POST records in order, awaiting each (the HTTP 2xx is
// the ACK). Serialized per session so seq order survives async fetches.
if (this._postDraining.has(sessionId)) return;
this._postDraining.add(sessionId);
(async () => {
try {
this._ws.send(JSON.stringify({ t: 'i', d: input }));
this.clearPendingHooks(sessionId);
return;
} catch {
// WS send failed — fall through to HTTP POST
}
}
// Slow path: HTTP POST — chain on dispatch only, don't wait for response.
// The server handles writeViaMux as fire-and-forget anyway.
this._inputSendChain = this._inputSendChain.then(() => {
const fetchPromise = fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
keepalive: input.length < 65536,
});
// Handle response asynchronously — don't block next keystroke on response
fetchPromise.then(resp => {
if (!resp.ok) {
this._enqueueInput(sessionId, input);
} else {
this.clearPendingHooks(sessionId);
for (;;) {
const cur = this._pendingDeliveries.get(sessionId);
if (!cur || cur.length === 0) break;
// If the WebSocket came back mid-drain, yield to it (the acked stream)
// so we don't redundantly re-POST what onopen is already re-sending.
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
break;
}
const rec = cur[0];
rec.tries++;
rec.sentAt = Date.now();
let resp = null;
try {
const body = { input: rec.data, seq: rec.seq, clientId: this._clientId };
if (rec.useMux) body.useMux = true;
resp = await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
keepalive: rec.data.length < 65536,
});
} catch {
resp = null;
}
if (resp && resp.ok) {
this._ackDelivery(sessionId, rec.seq);
} else if (resp && (resp.status === 404 || resp.status === 410)) {
// Session no longer exists — the input can never land. Drop it
// rather than retry forever (not a "lost" prompt: the target is gone).
this._ackDelivery(sessionId, rec.seq);
} else {
break; // offline / 5xx — leave queued; sweep + reconnect retry later
}
}
}).catch(() => {
this._enqueueInput(sessionId, input);
});
// Return immediately after fetch is dispatched (don't await response)
});
} finally {
this._postDraining.delete(sessionId);
}
})();
}
_enqueueInput(sessionId, input) {
const existing = this._inputQueue.get(sessionId) || '';
let combined = existing + input;
// Enforce 64KB cap — keep most recent keystrokes
if (combined.length > this._inputQueueMaxBytes) {
combined = combined.slice(combined.length - this._inputQueueMaxBytes);
}
this._inputQueue.set(sessionId, combined);
this._updateConnectionIndicator();
}
async _drainInputQueues() {
if (this._inputQueue.size === 0) return;
// Snapshot and clear
const queued = new Map(this._inputQueue);
this._inputQueue.clear();
this._updateConnectionIndicator();
for (const [sessionId, input] of queued) {
const resp = await this._apiPost(`/api/sessions/${sessionId}/input`, { input });
if (!resp?.ok) {
this._enqueueInput(sessionId, input);
/** Drop an ACKed record (by exact seq) and persist. */
_ackDelivery(sessionId, seq) {
const list = this._pendingDeliveries.get(sessionId);
if (list) {
const idx = list.findIndex((r) => r.seq === seq);
if (idx !== -1) {
list.splice(idx, 1);
if (list.length === 0) this._pendingDeliveries.delete(sessionId);
// When nothing is left pending anywhere, flush durable state immediately
// (not debounced) so a reload in the next 250ms can't redeliver an
// already-delivered frame — otherwise localStorage briefly still shows it.
if (this._pendingDeliveries.size === 0) this._persistReliableNow();
else this._persistReliableState();
this._updateConnectionIndicator();
}
}
this._updateConnectionIndicator();
this.clearPendingHooks?.(sessionId);
}
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
_onWsInputAck(seq) {
if (this._wsSessionId && Number.isInteger(seq)) this._ackDelivery(this._wsSessionId, seq);
}
/** Called from ws.onopen — flush everything pending over the fresh socket. */
_onWsReady(sessionId) {
const list = this._pendingDeliveries.get(sessionId);
if (list) for (const r of list) r.sentAt = 0; // fresh socket ⇒ re-send all
this._drainSession(sessionId);
}
/**
* Periodic retry. For the active WS session, an oldest frame unacked past the
* timeout means the socket is (half-)dead — close it to force a fast reconnect
* (onclose → reconnect → onopen → _onWsReady re-sends). Other sessions just
* (re)drain over POST.
*/
_redeliverSweep() {
if (this._pendingDeliveries.size === 0) return;
for (const sessionId of [...this._pendingDeliveries.keys()]) {
const list = this._pendingDeliveries.get(sessionId);
if (!list || list.length === 0) continue;
const isActiveWs =
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
if (isActiveWs) {
const oldest = list[0];
if (oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs) {
try {
this._ws.close(); // half-open: never recovers on its own — force reconnect
} catch {
/* ignore */
}
continue;
}
}
this._drainSession(sessionId);
}
}
/** Total bytes/count still awaiting ACK across all sessions (for the indicator). */
_pendingBytes() {
let bytes = 0;
let count = 0;
for (const list of this._pendingDeliveries.values()) {
for (const r of list) {
bytes += r.data.length;
count++;
}
}
return { bytes, count };
}
// ---- durable persistence (localStorage; quota- and disabled-storage-safe) --
_loadReliableState() {
// Stable client identity for server-side dedup across reconnects/reloads.
try {
this._clientId = localStorage.getItem('codeman:clientId') || '';
} catch {
this._clientId = '';
}
if (!this._clientId) {
this._clientId = 'c-' + Math.random().toString(36).slice(2) + '-' + Date.now().toString(36);
try {
localStorage.setItem('codeman:clientId', this._clientId);
} catch {
/* storage disabled — dedup degrades to per-load, still no loss */
}
}
try {
const raw = localStorage.getItem('codeman:pendingInput');
if (!raw) return;
const saved = JSON.parse(raw);
if (saved && saved.seqs) {
for (const [s, n] of Object.entries(saved.seqs)) {
if (Number.isFinite(n)) this._seqCounters.set(s, n);
}
}
if (saved && saved.pending) {
for (const [s, recs] of Object.entries(saved.pending)) {
if (Array.isArray(recs) && recs.length) {
// Reset sentAt so they re-deliver promptly on this fresh load.
this._pendingDeliveries.set(
s,
recs
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
.map((r) => ({
seq: r.seq,
data: r.data,
useMux: !!r.useMux,
ts: r.ts || Date.now(),
tries: 0,
sentAt: 0,
}))
);
}
}
}
} catch {
/* corrupt/parse error — start clean rather than throw */
}
}
_persistReliableState() {
// Debounced — typing without local echo calls this per keystroke.
if (this._persistReliableTimer) return;
this._persistReliableTimer = setTimeout(() => {
this._persistReliableTimer = null;
this._persistReliableNow();
}, 250);
}
_persistReliableNow() {
if (this._persistReliableTimer) {
clearTimeout(this._persistReliableTimer);
this._persistReliableTimer = null;
}
try {
const seqs = {};
for (const [s, n] of this._seqCounters) seqs[s] = n;
const pending = {};
let bytes = 0;
for (const [s, list] of this._pendingDeliveries) {
if (!list.length) continue;
pending[s] = list.map((r) => ({
seq: r.seq,
data: r.data,
useMux: r.useMux,
ts: r.ts,
tries: r.tries,
}));
for (const r of list) bytes += r.data.length;
}
// Bound the persisted backlog. On extreme overflow keep the seq counters
// (so future input stays monotonic and dedup-safe) but skip the payloads —
// the in-memory queue still delivers; only cross-reload durability is lost.
const payload =
bytes > this._reliableMaxBytes ? { seqs } : { seqs, pending };
localStorage.setItem('codeman:pendingInput', JSON.stringify(payload));
} catch {
/* QuotaExceeded or disabled storage — in-memory delivery is unaffected */
}
}
_updateConnectionIndicator() {
@@ -2143,11 +2358,9 @@ class CodemanApp {
const text = this.$('connectionText');
if (!indicator || !dot || !text) return;
let totalBytes = 0;
for (const v of this._inputQueue.values()) totalBytes += v.length;
const { bytes: totalBytes, count } = this._pendingBytes();
const status = this._connectionStatus;
const hasQueue = totalBytes > 0;
const hasQueue = count > 0;
// Connected with empty queue — hide
if ((status === 'connected' || status === 'connecting') && !hasQueue) {
@@ -2179,6 +2392,8 @@ class CodemanApp {
this.isOnline = true;
this.reconnectAttempts = 0;
this.connectSSE();
// Network came back — drain durably-queued input right away.
this._redeliverSweep();
});
window.addEventListener('offline', () => {
this.isOnline = false;
@@ -2773,12 +2988,16 @@ class CodemanApp {
const minimizedCount = minimizedAgents?.size || 0;
const subagentBadge = minimizedCount > 0 ? this.renderSubagentTabBadge(id, minimizedAgents) : '';
// Ultracode runs + agent transcripts minimized to this tab (ultracode-windows.js
// renders one merged ULTRA badge; returns '' when nothing is minimized).
const ultracodeBadge = this.renderUltracodeTabBadge ? this.renderUltracodeTabBadge(id) : '';
// Show folder name if session has a custom name AND tall tabs setting is enabled
const folderName = session.workingDir ? session.workingDir.split('/').pop() || '' : '';
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, '${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${loadState ? ' tab-loading' : ''}" data-id="${id}" data-color="${color}" ${loadState ? `data-load-phase="${escapeHtml(loadState.phase)}"` : ''} onclick="app.handleSessionTabClick(event, ${escapeHtml(JSON.stringify(id))})" oncontextmenu="event.preventDefault(); app.startInlineRename(${escapeHtml(JSON.stringify(id))})" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-busy="${loadState ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
${loadState ? '<span class="tab-load-spinner" aria-hidden="true"></span>' : ''}
<span class="tab-status ${status}" aria-hidden="true"></span>
@@ -2792,9 +3011,10 @@ class CodemanApp {
</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
${ultracodeBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions(${escapeHtml(JSON.stringify(id))})" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession(${escapeHtml(JSON.stringify(id))})" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession(${escapeHtml(JSON.stringify(id))})" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`);
_tabIdx++;
}
@@ -3681,7 +3901,13 @@ class CodemanApp {
this._flushedOffsets?.delete(sessionId);
this._flushedTexts?.delete(sessionId);
this._inputQueue.delete(sessionId);
// Drop any durably-queued input for a session that's actually gone (deleted/
// exited). Not a lost prompt — the target no longer exists. Only reached on
// real session removal, never on a tab switch.
this._pendingDeliveries?.delete(sessionId);
this._seqCounters?.delete(sessionId);
this._postDraining?.delete(sessionId);
this._persistReliableState();
this.ralphStates.delete(sessionId);
this.ralphClosedSessions.delete(sessionId);
this.projectInsights.delete(sessionId);
+87 -4
View File
@@ -4449,6 +4449,7 @@ var GestureController = class {
// packages/gesture-control/src/codeman/entry.ts
var TAB_SELECTOR = ".session-tab";
var PANEL_SELECTOR = ".cg-float";
var WINDOW_SELECTOR = ".subagent-window, .ultracode-window";
var DOCK_SELECTOR = ".session-tabs";
var CLICK_SELECTOR = "#runBtn, .btn-shell";
var Z2 = 2147483e3;
@@ -4476,6 +4477,8 @@ var GestureBridge = class {
__publicField(this, "taps", /* @__PURE__ */ new Map());
/** Live floating panels, keyed by session id (idempotent per id). */
__publicField(this, "floats", /* @__PURE__ */ new Map());
/** rAF coalescing for connector-line redraws while dragging an agent window. */
__publicField(this, "connectorRedrawScheduled", false);
injectStyles();
this.surface = el("div", "cg-surface");
this.canvas = el("canvas", "cg-canvas");
@@ -4530,7 +4533,7 @@ var GestureBridge = class {
await this.gc.start();
this.running = true;
this.button.classList.add("on");
this.status.textContent = "on \u2014 pinch a tab or button";
this.status.textContent = "on \u2014 pinch a tab, window, or button";
} catch (err) {
const msg = describeError(err);
this.status.textContent = `failed: ${msg}`;
@@ -4575,6 +4578,16 @@ var GestureBridge = class {
return;
}
}
const win = this.hitClosest(x2, y2, WINDOW_SELECTOR);
if (win) {
const rect = win.getBoundingClientRect();
win.style.bottom = "auto";
win.classList.add("cg-win-grabbed");
this.bringWindowToFront(win);
this.grabs.set(hand, { kind: "window", el: win, dx: x2 - rect.left, dy: y2 - rect.top });
this.status.textContent = "moving window";
return;
}
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
const id = tab?.dataset.id;
if (tab && id) {
@@ -4620,11 +4633,15 @@ var GestureBridge = class {
}
return;
}
if (grab?.kind === "window") {
this.moveWindow(grab.el, x2 - grab.dx, y2 - grab.dy);
return;
}
const tap = this.taps.get(hand);
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove("cg-tap-armed");
this.taps.delete(hand);
this.status.textContent = "on \u2014 pinch a tab or button";
this.status.textContent = "on \u2014 pinch a tab, window, or button";
}
}
onDrop(hand, x2, y2) {
@@ -4645,6 +4662,18 @@ var GestureBridge = class {
else this.flash("placed");
return;
}
if (grab?.kind === "window") {
this.grabs.delete(hand);
grab.el.classList.remove("cg-win-grabbed");
this.connectorRedrawScheduled = false;
this.redrawWindowConnectors();
try {
window.app?.saveSubagentWindowStates?.();
} catch {
}
this.flash("placed window");
return;
}
const tap = this.taps.get(hand);
if (tap) {
this.taps.delete(hand);
@@ -4694,6 +4723,54 @@ var GestureBridge = class {
float.el.style.left = `${l}px`;
float.el.style.top = `${t2}px`;
}
/** Move a dashboard-owned agent window by its top-left, clamped on-screen, then
* redraw its connector line. The window self-positions via `style.left/top` and
* app.js's connector redraw reads live rects, so this tracks without touching
* app.js internals. Guards on `isConnected`: ultracode windows can be torn down
* (SSE reconnect / auto-close) while still held. Clamps to `innerWidth/Height`,
* which equals the *spanned* viewport in a multi-monitor window — so the window
* can still travel across the physical monitor seam, just not off-screen. */
moveWindow(el2, left, top) {
if (!el2.isConnected) return;
const w2 = el2.offsetWidth || 380;
const h2 = el2.offsetHeight || 320;
const l = Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w2 - 4));
const t2 = Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h2 - 4));
el2.style.left = `${l}px`;
el2.style.top = `${t2}px`;
this.redrawWindowConnectors();
}
/** Ask app.js to redraw all connector lines (subagent + ultracode), coalesced to
* one per frame so per-frame drags don't thrash. `updateConnectionLines()` is
* itself debounced in app.js, but we rAF-gate too in case an older dashboard
* build isn't, and to no-op cleanly when app.js isn't present (standalone). */
redrawWindowConnectors() {
if (this.connectorRedrawScheduled) return;
this.connectorRedrawScheduled = true;
requestAnimationFrame(() => {
this.connectorRedrawScheduled = false;
try {
window.app?.updateConnectionLines?.();
} catch {
}
});
}
/** Pop a grabbed window above its siblings using app.js's own z-counter, so a
* picked-up window comes to the front like a real focus. Cosmetic + best-effort. */
bringWindowToFront(el2) {
const app = window.app;
if (!app) return;
try {
if (el2.classList.contains("ultracode-window")) {
app.ultracodeWindowZIndex = (app.ultracodeWindowZIndex ?? 1e3) + 1;
el2.style.zIndex = String(app.ultracodeWindowZIndex);
} else {
app.subagentWindowZIndex = (app.subagentWindowZIndex ?? 1e3) + 1;
el2.style.zIndex = String(app.subagentWindowZIndex);
}
} catch {
}
}
positionGhost(ghost, x2, y2) {
ghost.style.left = `${x2}px`;
ghost.style.top = `${y2}px`;
@@ -4703,15 +4780,17 @@ var GestureBridge = class {
if (grab.kind === "tab") {
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
} else {
} else if (grab.kind === "panel") {
grab.panel.el.style.pointerEvents = "";
grab.panel.el.classList.remove("cg-float-grabbed", "cg-redock");
} else {
grab.el.classList.remove("cg-win-grabbed");
}
}
this.grabs.clear();
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
this.taps.clear();
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed"));
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed, .cg-win-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed", "cg-win-grabbed"));
}
onStatus(fps, hands) {
const { width, height } = this.canvas;
@@ -4797,6 +4876,10 @@ function injectStyles() {
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
.subagent-window.cg-win-grabbed, .ultracode-window.cg-win-grabbed {
outline: 2px solid #4ade80 !important; outline-offset: -2px;
box-shadow: 0 12px 48px rgba(74,222,128,.5) !important;
}
.cg-float {
position: fixed; left: 0; top: 0; width: ${FLOAT_W}px; height: ${FLOAT_H}px;
z-index: ${Z2}; display: flex; flex-direction: column; overflow: hidden;
+80 -24
View File
@@ -104,34 +104,78 @@ Object.assign(CodemanApp.prototype, {
document.execCommand('paste');
},
async _uploadAndInsertImages(files) {
// Max images accepted in one batch (paste / drop / mobile picker). Each is
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
// rate limit while covering "select a bunch of photos at once".
_maxBatchImages: 20,
// How many uploads to run concurrently. Small enough that decoding several
// large images through <canvas> at once won't OOM a phone, large enough that
// 20 photos don't crawl through serially.
_uploadConcurrency: 3,
async _uploadAndInsertImages(fileList) {
const sessionId = this.activeSessionId;
if (!sessionId) return;
this.showToast('Uploading ' + files.length + ' image' + (files.length > 1 ? 's' : '') + '...', 'info');
let files = Array.from(fileList || []);
if (files.length === 0) return;
const paths = [];
for (const file of files) {
try {
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
// MIME claim "image/jpeg", which passes the server's extension allowlist
// but fails its magic-byte check ("bytes do not match declared type").
// Decoding through the browser and re-encoding guarantees the bytes
// match the extension we send.
const normalized = await this._normalizeImageForUpload(file);
const path = await this._uploadPasteImage(sessionId, normalized);
paths.push(path);
} catch (err) {
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
// Cap the batch and tell the user what got dropped (no silent truncation).
let capped = false;
if (files.length > this._maxBatchImages) {
files = files.slice(0, this._maxBatchImages);
capped = true;
}
const total = files.length;
let done = 0;
let failed = 0;
const results = new Array(total); // preserve selection order for insertion
const progress = () =>
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} image${total > 1 ? 's' : ''}…`, 'info');
progress();
// Bounded-concurrency worker pool over the file list.
let next = 0;
const worker = async () => {
for (;;) {
const i = next++;
if (i >= total) return;
try {
// Re-encode to a standard JPEG/PNG (and downscale very large images)
// before upload. Galleries on some phones (notably Android/MIUI) hand
// back a WebP/HEIF whose filename and MIME claim "image/jpeg", which
// passes the server's extension allowlist but fails its magic-byte
// check. Decoding through the browser and re-encoding guarantees the
// bytes match the extension we send — and shrinks huge photos so they
// fit the upload limit and iOS's <canvas> area cap.
const normalized = await this._normalizeImageForUpload(files[i]);
results[i] = await this._uploadPasteImage(sessionId, normalized);
} catch (err) {
failed++;
console.warn('Image upload failed:', err);
results[i] = null;
} finally {
done++;
if (done < total) progress();
}
}
};
await Promise.all(Array.from({ length: Math.min(this._uploadConcurrency, total) }, () => worker()));
const paths = results.filter(Boolean);
if (paths.length > 0) {
// Insert all paths in one shot, space-separated, in selection order.
await this.sendInput(paths.join(' '));
}
if (paths.length > 0) {
const pathStr = paths.join(' ');
await this.sendInput(pathStr);
this.showToast(paths.length + ' image' + (paths.length > 1 ? 's' : '') + ' ready', 'success');
}
// Final status: successes, plus any failures / cap so nothing is silent.
const parts = [];
if (paths.length > 0) parts.push(`${paths.length} image${paths.length > 1 ? 's' : ''} ready`);
if (failed > 0) parts.push(`${failed} failed`);
if (capped) parts.push(`max ${this._maxBatchImages} per batch`);
const tone = paths.length > 0 ? (failed > 0 || capped ? 'info' : 'success') : 'error';
this.showToast(parts.join(' · ') || 'No images uploaded', tone);
},
async _uploadPasteImage(sessionId, file) {
@@ -176,12 +220,24 @@ Object.assign(CodemanApp.prototype, {
const height = img.naturalHeight;
if (!width || !height) return file;
// Downscale very large images. Two reasons: (1) iOS Safari refuses to
// render a <canvas> larger than ~16.7M px (it returns a blank/null
// blob), so a 48MP photo would otherwise fail to re-encode and fall back
// to the original — which then trips the server's magic-byte check for
// HEIF mislabeled as JPEG. (2) It keeps multi-photo uploads fast and well
// under the size limit. Cap the longest edge so area stays safely below
// the canvas limit while still uploading a large, high-quality image.
const MAX_EDGE = 4096;
const scale = Math.min(1, MAX_EDGE / Math.max(width, height));
const w = Math.max(1, Math.round(width * scale));
const h = Math.max(1, Math.round(height * scale));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
ctx.drawImage(img, 0, 0);
ctx.drawImage(img, 0, 0, w, h);
const mime = toPng ? 'image/png' : 'image/jpeg';
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
-1
View File
@@ -58,7 +58,6 @@ const KeyboardAccessoryBar = {
</svg>
</button>
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
+1 -1
View File
@@ -273,7 +273,7 @@ class NotificationManager {
const readClass = n.read ? '' : ' unread';
const countLabel = n.count > 1 ? `<span class="notif-item-count">&times;${n.count}</span>` : '';
const sessionChip = n.sessionName ? `<span class="notif-item-session">${escapeHtml(n.sessionName)}</span>` : '';
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification('${escapeHtml(n.id)}')">
return `<div class="notif-item ${urgencyClass}${readClass}" data-notif-id="${n.id}" data-session-id="${n.sessionId || ''}" onclick="app.notificationManager.clickNotification(${escapeHtml(JSON.stringify(n.id))})">
<div class="notif-item-header">
<span class="notif-item-title">${escapeHtml(n.title)}${countLabel}</span>
<span class="notif-item-time">${this.relativeTime(n.timestamp)}</span>
+2 -2
View File
@@ -392,10 +392,10 @@ Object.assign(CodemanApp.prototype, {
let actions = '';
if (orchState === 'executing' || orchState === 'failed') {
if (phase.status === 'pending') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase('${phase.id}')" title="Skip">skip</button>`;
actions += `<button class="orch-phase-btn" onclick="app.orchestratorSkipPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Skip">skip</button>`;
}
if (phase.status === 'failed') {
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase('${phase.id}')" title="Retry">retry</button>`;
actions += `<button class="orch-phase-btn" onclick="app.orchestratorRetryPhase(${escapeHtml(JSON.stringify(phase.id))})" title="Retry">retry</button>`;
}
}
+17 -17
View File
@@ -753,8 +753,8 @@ Object.assign(CodemanApp.prototype, {
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
html.push(`
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
onclick="app.selectSubagent('${escapeHtml(agent.agentId)}')"
ondblclick="app.openSubagentWindow('${escapeHtml(agent.agentId)}')"
onclick="app.selectSubagent(${escapeHtml(JSON.stringify(agent.agentId))})"
ondblclick="app.openSubagentWindow(${escapeHtml(JSON.stringify(agent.agentId))})"
title="Double-click to open tracking window">
<div class="subagent-header">
${agentIcon}
@@ -762,8 +762,8 @@ Object.assign(CodemanApp.prototype, {
${teammateBadge}
${modelBadge}
<span class="subagent-status ${statusClass}">${agent.status}</span>
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}('${escapeHtml(agent.agentId)}')" title="${hasWindow ? 'Close window' : 'Open in window'}">
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">&#x2715;</button>` : ''}
<button class="subagent-window-btn" onclick="event.stopPropagation(); app.${hasWindow ? 'closeSubagentWindow' : 'openSubagentWindow'}(${escapeHtml(JSON.stringify(agent.agentId))})" title="${hasWindow ? 'Close window' : 'Open in window'}">
${hasWindow ? '✕' : '⧉'}
</button>
</div>
@@ -810,7 +810,7 @@ Object.assign(CodemanApp.prototype, {
<span class="icon">${this.getToolIcon(a.tool)}</span>
<span class="name">${escapeHtml(a.tool)}</span>
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams(${escapeHtml(JSON.stringify(a.toolUseId))})">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`;
} else if (a.type === 'tool_result') {
@@ -859,7 +859,7 @@ Object.assign(CodemanApp.prototype, {
<span class="subagent-id" title="${escapeHtml(agent.description || agent.agentId)}">${escapeHtml(detailTitle.length > 60 ? detailTitle.substring(0, 60) + '...' : detailTitle)}</span>
${modelBadge}
<span class="subagent-status ${agent.status}">${agent.status}</span>
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript('${escapeHtml(agent.agentId)}')">
<button class="subagent-transcript-btn" onclick="app.viewSubagentTranscript(${escapeHtml(JSON.stringify(agent.agentId))})">
View Full Transcript
</button>
</div>
@@ -1195,7 +1195,7 @@ Object.assign(CodemanApp.prototype, {
parentDiv.dataset.parentSession = parentSessionId;
parentDiv.innerHTML = `
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentName)}</span>
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentName)}</span>
`;
header.insertAdjacentElement('afterend', parentDiv);
}
@@ -1687,7 +1687,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status running">terminal</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${escapeHtml(windowId)}')" title="Minimize to tab">─</button>
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(windowId))})" title="Minimize to tab">─</button>
</div>
</div>
<div class="subagent-window-body teammate-terminal-body" id="subagent-window-body-${windowId}">
@@ -2200,7 +2200,7 @@ Object.assign(CodemanApp.prototype, {
const fileName = path.split('/').pop();
html.push(`
<span class="project-insight-filepath"
onclick="app.openLogViewerWindow('${escapeHtml(path)}', '${escapeHtml(tool.sessionId)}')"
onclick="app.openLogViewerWindow(${escapeHtml(JSON.stringify(path))}, ${escapeHtml(JSON.stringify(tool.sessionId))})"
title="${escapeHtml(path)}">${escapeHtml(fileName)}</span>
`);
}
@@ -3099,7 +3099,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status streaming">streaming</span>
</div>
<div class="log-viewer-window-actions">
<button onclick="app.closeLogViewerWindow('${escapeHtml(windowId)}')" title="Close">×</button>
<button onclick="app.closeLogViewerWindow(${escapeHtml(JSON.stringify(windowId))})" title="Close">×</button>
</div>
</div>
<div class="log-viewer-window-body" id="log-viewer-body-${windowId}">
@@ -3275,14 +3275,14 @@ Object.assign(CodemanApp.prototype, {
<span class="size-badge">${sizeKB} KB</span>
</div>
<div class="image-popup-actions">
<button onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup('${escapeHtml(imageId)}')" title="Close">×</button>
<button onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" title="Open in new tab">↗</button>
<button onclick="app.closeImagePopup(${escapeHtml(JSON.stringify(imageId))})" title="Close">×</button>
</div>
</div>
<div class="image-popup-body">
<img src="${imageUrl}" alt="${escapeHtml(fileName)}"
onerror="this.parentElement.innerHTML='<div class=\\'image-error\\'>Failed to load image</div>'"
onclick="app.openImageInNewTab('${escapeHtml(imageUrl)}')" />
onclick="app.openImageInNewTab(${escapeHtml(JSON.stringify(imageUrl))})" />
</div>
`;
@@ -3505,9 +3505,9 @@ Object.assign(CodemanApp.prototype, {
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
}
const sid = escapeHtml(muxSession.sessionId);
const sid = escapeHtml(JSON.stringify(muxSession.sessionId));
html += `
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
<div class="process-item process-item-clickable" onclick="app.selectSession(${sid})" title="Switch to session">
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
<div class="process-info">
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
@@ -3520,7 +3520,7 @@ Object.assign(CodemanApp.prototype, {
</div>
</div>
<div class="process-actions">
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession(${sid})" title="Kill session">Kill</button>
</div>
</div>
`;
@@ -3563,7 +3563,7 @@ Object.assign(CodemanApp.prototype, {
</div>
</div>
<div class="process-actions">
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent('${escapeHtml(agent.agentId)}')" title="Kill agent">Kill</button>` : ''}
${agent.status !== 'completed' ? `<button class="btn-toolbar btn-sm btn-danger" onclick="app.killSubagent(${escapeHtml(JSON.stringify(agent.agentId))})" title="Kill agent">Kill</button>` : ''}
</div>
</div>
`;
+5 -5
View File
@@ -1385,11 +1385,11 @@ Object.assign(CodemanApp.prototype, {
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button>
</div>
</div>
@@ -1484,14 +1484,14 @@ Object.assign(CodemanApp.prototype, {
const isSelected = c.name === currentCase;
html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
onclick="app.selectMobileCase('${escapeHtml(c.name)}')">
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
<span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
+93 -16
View File
@@ -5573,6 +5573,45 @@ kbd {
letter-spacing: 0.03em;
}
/* Ultracode run minimized-to-tab badge — same chip shape as the subagent badge but a
distinct purple (matches the ultracode window accent), and a SEPARATE class so the
incremental tab-update path (which keys on .tab-subagent-badge) never touches it.
Reuses the shared .subagent-dropdown for its restore/dismiss list. */
.session-tab .tab-ultracode-badge {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 16px;
padding: 0 5px;
border-radius: 8px;
font-size: 0.6rem;
cursor: pointer;
background: #a855f7;
color: white;
margin-left: 4px;
transition: transform 0.1s, background 0.15s;
}
@media (hover: hover) {
.session-tab .tab-ultracode-badge:hover {
background: #9333ea;
transform: scale(1.05);
}
}
.session-tab .tab-ultracode-badge .subagent-label {
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.03em;
}
/* Per-item type glyph in the ULTRA dropdown (🧬 run vs 📄 transcript). */
.subagent-dropdown-item .ultracode-dd-icon {
font-size: 0.7rem;
line-height: 1;
flex-shrink: 0;
margin-right: 1px;
}
/* Subagent Dropdown - compact, hover-triggered */
.subagent-dropdown {
display: none;
@@ -8473,14 +8512,14 @@ kbd {
flex-shrink: 0;
}
.ultracode-status.completed {
background: var(--success);
background: var(--green);
}
.ultracode-status.active {
background: var(--warning);
background: var(--yellow);
color: black;
}
.ultracode-status.failed {
background: var(--error, #b3261e);
background: var(--red, #b3261e);
color: white;
}
@@ -8531,6 +8570,16 @@ kbd {
border-radius: 6px;
padding: 0.4rem 0.5rem;
margin-bottom: 0.35rem;
border-left: 3px solid transparent;
}
/* At-a-glance agent state: yellow while working, green when done. */
.ultracode-agent-card.uw-state-working {
border-left-color: var(--yellow);
background: color-mix(in srgb, var(--yellow) 9%, var(--bg-input));
}
.ultracode-agent-card.uw-state-done {
border-left-color: var(--green);
background: color-mix(in srgb, var(--green) 9%, var(--bg-input));
}
.ultracode-agent-card--clickable {
cursor: pointer;
@@ -8539,6 +8588,14 @@ kbd {
.ultracode-agent-card--clickable:hover {
background: var(--bg-card);
}
/* Keep the state tint on hover (equal specificity to the hover rule, so it must
come after it) — a tad stronger so the hover still reads as interactive. */
.ultracode-agent-card--clickable.uw-state-working:hover {
background: color-mix(in srgb, var(--yellow) 16%, var(--bg-card));
}
.ultracode-agent-card--clickable.uw-state-done:hover {
background: color-mix(in srgb, var(--green) 16%, var(--bg-card));
}
.ultracode-agent-top {
display: flex;
align-items: center;
@@ -8564,10 +8621,10 @@ kbd {
color: white;
}
.ultracode-agent-state.completed {
background: var(--success);
background: var(--green);
}
.ultracode-agent-state.active {
background: var(--warning);
background: var(--yellow);
color: black;
}
.ultracode-agent-state.idle {
@@ -8629,13 +8686,6 @@ kbd {
transform: scale(0.92);
opacity: 0;
}
.ultracode-window.collapsed {
height: auto !important;
resize: none;
}
.ultracode-window.collapsed .ultracode-window-body {
display: none;
}
.ultracode-window-header {
display: flex;
align-items: center;
@@ -8707,14 +8757,15 @@ kbd {
margin-bottom: 0.4rem;
}
/* Ultracode connector line — distinct purple to set it apart from blue subagent lines. */
/* Ultracode connector line — the session-tab accent blue (dashed to stay distinct
from the solid subagent lines, per the same-blue-as-the-tab request). */
.connection-line.ultracode-connection {
stroke: #a855f7;
stroke: var(--accent);
stroke-width: 3;
stroke-dasharray: 6 3;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 5px rgba(168, 85, 247, 0.85))
drop-shadow(0 0 10px rgba(168, 85, 247, 0.5));
drop-shadow(0 0 5px rgba(var(--accent-rgb), 0.85))
drop-shadow(0 0 10px rgba(var(--accent-rgb), 0.5));
animation: ultracode-conn-pulse 1.4s ease-in-out infinite;
}
.connection-line.ultracode-connection:hover {
@@ -8725,6 +8776,32 @@ kbd {
50% { opacity: 1; }
}
/* Agent-transcript window: spawned from an agent card (in a run window or the dock
panel), tied to its parent run window by its own connector line. Reuses the
.ultracode-window chrome; wider with a monospace transcript body. */
.ultracode-agent-window {
width: 470px;
height: 420px;
border-color: #38bdf8;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(56, 189, 248, 0.18);
}
.ultracode-agent-window .ultracode-window-body .uw-transcript {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: 0.68rem;
line-height: 1.45;
color: var(--text);
}
/* Distinguish the parent→agent line from the tab→run line: solid cyan, no dashes.
Must follow .ultracode-connection (equal specificity → source order wins). */
.connection-line.ultracode-agent-connection {
stroke: #38bdf8;
stroke-dasharray: none;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8)) drop-shadow(0 0 5px rgba(56, 189, 248, 0.85));
}
/* 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
+8 -4
View File
@@ -33,10 +33,10 @@ Object.assign(CodemanApp.prototype, {
const truncatedName = displayName.length > 25 ? displayName.substring(0, 25) + '…' : displayName;
const statusClass = agent?.status || 'idle';
agentItems.push(`
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Click to restore">
<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore">
<span class="subagent-dropdown-status ${statusClass}"></span>
<span class="subagent-dropdown-name">${escapeHtml(truncatedName)}</span>
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent('${escapeHtml(agentId)}', '${escapeHtml(sessionId)}')" title="Dismiss">&times;</span>
<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.permanentlyCloseMinimizedSubagent(${escapeHtml(JSON.stringify(agentId))}, ${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">&times;</span>
</div>
`);
}
@@ -461,6 +461,10 @@ Object.assign(CodemanApp.prototype, {
if (typeof this._appendUltracodeConnectionLines === 'function') {
this._appendUltracodeConnectionLines(svg, rects);
}
// Agent-transcript windows → their run window / tab (ultracode-windows.js).
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
this._appendUltracodeAgentConnectionLines(svg, rects);
}
},
// ═══════════════════════════════════════════════════════════════
@@ -695,7 +699,7 @@ Object.assign(CodemanApp.prototype, {
parentSessionId && parentSessionName
? `<div class="subagent-window-parent" data-parent-session="${parentSessionId}">
<span class="parent-label">from</span>
<span class="parent-name" onclick="app.selectSession('${escapeHtml(parentSessionId)}')">${escapeHtml(parentSessionName)}</span>
<span class="parent-name" onclick="app.selectSession(${escapeHtml(JSON.stringify(parentSessionId))})">${escapeHtml(parentSessionName)}</span>
</div>`
: '';
@@ -716,7 +720,7 @@ Object.assign(CodemanApp.prototype, {
<span class="status ${agent.status}">${agent.status}</span>
</div>
<div class="subagent-window-actions">
<button onclick="app.closeSubagentWindow('${escapeHtml(agentId)}')" title="Minimize to tab">─</button>
<button onclick="app.closeSubagentWindow(${escapeHtml(JSON.stringify(agentId))})" title="Minimize to tab">─</button>
</div>
</div>
${parentHeader}
+5 -6
View File
@@ -2195,12 +2195,11 @@ Object.assign(CodemanApp.prototype, {
* @returns {Promise<void>}
*/
async sendInput(input) {
if (!this.activeSessionId) return;
await fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input, useMux: true }),
});
if (!this.activeSessionId || !input) return;
// Route through the durable, exactly-once delivery layer (useMux for the
// POST fallback) so voice / keyboard-accessory / paste input also survives a
// dropped link instead of being lost in a single best-effort fetch.
this._sendInputAsync(this.activeSessionId, input, { useMux: true });
},
// ═══════════════════════════════════════════════════════════════
+28 -25
View File
@@ -96,6 +96,10 @@ Object.assign(CodemanApp.prototype, {
this.activeWorkflowPhaseIndex = null; // reset phase filter on run change
this._fetchWorkflowRunDetail(runId);
this.renderUltracodeAgentsPanel();
// Clicking a run also pops its floating window (with connector line to the
// session tab), like the auto-popped one — an explicit open, so it ignores the
// floating-windows auto-pop toggle (ultracode-windows.js).
if (typeof this.openUltracodeWindowForRun === 'function') this.openUltracodeWindowForRun(runId);
},
selectWorkflowPhase(phaseIndex) {
this._ensureWorkflowState();
@@ -105,6 +109,11 @@ Object.assign(CodemanApp.prototype, {
},
async _fetchWorkflowRunDetail(runId) {
// De-dupe concurrent fetches for the same run — selecting a run can trigger both
// a panel refresh and a floating-window open, which would otherwise double-fetch.
if (!this._wfDetailInFlight) this._wfDetailInFlight = new Set();
if (this._wfDetailInFlight.has(runId)) return;
this._wfDetailInFlight.add(runId);
try {
const res = await fetch(`/api/workflows/${encodeURIComponent(runId)}`);
const env = await res.json();
@@ -117,15 +126,20 @@ Object.assign(CodemanApp.prototype, {
}
} catch {
/* transient — next update retries */
} finally {
this._wfDetailInFlight.delete(runId);
}
},
// Phase 4: open an agent's live transcript by agentId. The workflow agent's
// Phase 4: fetch an agent's live transcript by agentId. The workflow agent's
// agentId is byte-identical to the agent-<id>.jsonl stem already tracked by
// subagent-watcher, so we reuse the existing transcript route — no watcher edits.
// Graceful when the agent isn't tracked yet / aged out / tracking disabled.
async openWorkflowAgentTranscript(agentId) {
if (!agentId) return;
// Returns { formatted: string[], entryCount } or null when nothing is available
// (queued / aged out of tracking / tracking disabled). Rendering into a connected
// in-page floating window lives in ultracode-windows.js (openUltracodeAgentWindow) —
// we no longer spawn a detached browser popup.
async _fetchWorkflowAgentTranscript(agentId) {
if (!agentId) return null;
let data = null;
try {
const res = await fetch(`/api/subagents/${encodeURIComponent(agentId)}/transcript?format=formatted`);
@@ -136,21 +150,8 @@ Object.assign(CodemanApp.prototype, {
const ok = data && data.success && data.data;
const formatted = ok ? data.data.formatted : null;
const entryCount = ok ? data.data.entryCount || 0 : 0;
if (!formatted || !entryCount) {
alert(
'No transcript available for this agent yet — it may be queued, aged out of tracking, or subagent tracking is disabled.'
);
return;
}
const win = window.open('', '_blank', 'width=860,height=640');
if (!win) return; // popup blocked
win.document.write(
`<html><head><title>Workflow agent ${escapeHtml(agentId)} transcript</title>` +
`<style>body{background:#1a1a2e;color:#eee;font-family:monospace;padding:20px}pre{white-space:pre-wrap;word-wrap:break-word}</style>` +
`</head><body><h2>Workflow agent ${escapeHtml(agentId)} (${entryCount} entries)</h2>` +
`<pre>${escapeHtml(formatted.join('\n'))}</pre></body></html>`
);
win.document.close();
if (!formatted || !entryCount) return null;
return { formatted, entryCount };
},
// ----- Render (debounced) -----
@@ -203,7 +204,7 @@ Object.assign(CodemanApp.prototype, {
phasesHtml = `<div class="ultracode-phase-list">${chips.join('')}</div>`;
}
return (
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun('${escapeHtml(r.runId)}')">` +
`<div class="ultracode-run-item${active ? ' selected' : ''}" onclick="app.selectWorkflowRun(${escapeHtml(JSON.stringify(r.runId))})">` +
`<div class="ultracode-run-head"><span class="ultracode-run-name">${name}</span>` +
`<span class="ultracode-status ${statusCls}">${escapeHtml(status || '—')}</span></div>` +
`<div class="ultracode-run-stats">${escapeHtml(stats)}</div>` +
@@ -251,13 +252,13 @@ Object.assign(CodemanApp.prototype, {
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
return header + group.map((a) => this._workflowAgentCardHtml(a, runId)).join('');
})
.join('');
detail.innerHTML = html;
},
_workflowAgentCardHtml(a) {
_workflowAgentCardHtml(a, runId) {
const state = String(a.state || 'start');
const stateCls = this._workflowAgentStateClass(state);
const stateLabel = state === 'start' ? 'queued' : state === 'progress' ? 'running' : state;
@@ -274,10 +275,12 @@ Object.assign(CodemanApp.prototype, {
// to the agent-<id>.jsonl stem already tracked by subagent-watcher). 'start' agents have
// no agentId yet, so they stay non-clickable.
const clickable = !!a.agentId;
// At-a-glance state tint on the whole card: green when done, yellow while working.
const cardStateCls = state === 'done' ? ' uw-state-done' : state === 'progress' ? ' uw-state-working' : '';
const cardAttrs = clickable
? ` class="ultracode-agent-card ultracode-agent-card--clickable" role="button" tabindex="0"` +
` title="View transcript" onclick="app.openWorkflowAgentTranscript('${escapeHtml(a.agentId)}')"`
: ` class="ultracode-agent-card"`;
? ` class="ultracode-agent-card ultracode-agent-card--clickable${cardStateCls}" role="button" tabindex="0"` +
` title="View transcript" onclick="app.openUltracodeAgentWindow(${escapeHtml(JSON.stringify(a.agentId))},${escapeHtml(JSON.stringify(runId || ''))})"`
: ` class="ultracode-agent-card${cardStateCls}"`;
return (
`<div${cardAttrs}>` +
`<div class="ultracode-agent-top">` +
+476 -12
View File
@@ -33,9 +33,12 @@
Object.assign(CodemanApp.prototype, {
/** Lazily seed the floating-window state maps (constructor also seeds them). */
_ensureUltracodeWindowState() {
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners, collapsed }
if (!this.ultracodeWindows) this.ultracodeWindows = new Map(); // runId -> { element, parentSessionId, dragListeners }
if (!this.ultracodeWindowsClosed) this.ultracodeWindowsClosed = new Set(); // runIds the user dismissed
if (!this.ultracodeWindowCloseTimers) this.ultracodeWindowCloseTimers = new Map(); // runId -> setTimeout id
if (!this.ultracodeAgentWindows) this.ultracodeAgentWindows = new Map(); // agentId -> { element, runId, dragListeners }
if (!this.minimizedUltracodeRuns) this.minimizedUltracodeRuns = new Map(); // sessionId -> Set<runId> minimized to a tab
if (!this.minimizedUltracodeAgents) this.minimizedUltracodeAgents = new Map(); // sessionId -> Map<agentId,{runId,label}>
if (this.ultracodeWindowZIndex === undefined) this.ultracodeWindowZIndex = 1000;
},
@@ -77,12 +80,29 @@ Object.assign(CodemanApp.prototype, {
_syncUltracodeFloatingWindow(run, opts) {
this._ensureUltracodeWindowState();
if (!run || !run.runId) return;
if (!this._ultracodeFloatingEnabled()) return;
const runId = run.runId;
const existing = this.ultracodeWindows.get(runId);
// Auto-pop is gated on the floating-windows toggle, but an ALREADY-open window
// (e.g. one opened by clicking the run in the dock) keeps refreshing regardless.
if (!existing && !this._ultracodeFloatingEnabled()) return;
if (this.ultracodeWindowsClosed.has(runId)) return; // respect explicit dismissal
const active = this._isWorkflowRunActive(run);
const existing = this.ultracodeWindows.get(runId);
// Minimized to a tab — keep it there (don't re-pop a window). Clear the tab badge a
// short while after the run finishes, mirroring the floating window's finish grace.
if (this._isUltracodeRunMinimized(runId)) {
if (!active && !this.ultracodeWindowCloseTimers.has(runId)) {
const timer = setTimeout(() => {
this.ultracodeWindowCloseTimers.delete(runId);
this._removeMinimizedUltracodeRun(runId);
this.renderSessionTabs();
this.updateConnectionLines();
}, 8000);
this.ultracodeWindowCloseTimers.set(runId, timer);
}
return;
}
if (active) {
// Run is alive — cancel any pending auto-close.
@@ -120,6 +140,31 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Explicitly open (or focus) the floating window for a run — the click-through
* from the dock panel's run list. Unlike auto-pop this ignores the floating-windows
* toggle and clears any prior dismissal (it's a direct user action), then draws the
* connector line from the run's session tab.
*/
openUltracodeWindowForRun(runId) {
this._ensureUltracodeWindowState();
if (!runId) return;
const run = this.workflowRuns && this.workflowRuns.get(runId);
if (!run) return;
this.ultracodeWindowsClosed.delete(runId); // an explicit open overrides a past dismissal
this._removeMinimizedUltracodeRun(runId); // …and a past minimize-to-tab
const existing = this.ultracodeWindows.get(runId);
if (existing) {
// Already open — bring to front and refresh.
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId);
this.updateConnectionLines();
} else {
this.createUltracodeWindow(run);
}
},
/** Build and mount a floating window for a run, positioned near its parent tab. */
createUltracodeWindow(run) {
this._ensureUltracodeWindowState();
@@ -140,7 +185,7 @@ Object.assign(CodemanApp.prototype, {
<span class="uw-status"></span>
</div>
<div class="ultracode-window-actions">
<button class="uw-min" type="button" title="Collapse">─</button>
<button class="uw-min" type="button" title="Minimize to tab">─</button>
<button class="uw-close" type="button" title="Close">&times;</button>
</div>
</div>
@@ -171,7 +216,7 @@ Object.assign(CodemanApp.prototype, {
win.querySelector('.uw-min').addEventListener('click', (e) => {
e.stopPropagation();
this.toggleUltracodeWindowCollapse(runId);
this.minimizeUltracodeWindowToTab(runId);
});
win.querySelector('.uw-close').addEventListener('click', (e) => {
e.stopPropagation();
@@ -184,19 +229,261 @@ Object.assign(CodemanApp.prototype, {
nameEl.addEventListener('click', () => this.selectSession(parentSessionId));
}
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners, collapsed: false });
this.ultracodeWindows.set(runId, { element: win, parentSessionId, dragListeners });
this.renderUltracodeWindowContent(runId);
this._fetchWorkflowRunDetail(runId); // pull agents[] for the body
this.updateConnectionLines();
},
/** Collapse/expand the window to header-only (line stays connected). */
toggleUltracodeWindowCollapse(runId) {
// ── Minimize a run window into its originating tab (same idiom as subagent windows) ──
/** Is this run currently minimized to a tab (so auto-pop should leave it alone)? */
_isUltracodeRunMinimized(runId) {
if (!this.minimizedUltracodeRuns) return false;
for (const set of this.minimizedUltracodeRuns.values()) {
if (set.has(runId)) return true;
}
return false;
},
/** Drop a run from the minimized-to-tab tracking (all sessions, or a specific one). */
_removeMinimizedUltracodeRun(runId, sessionId) {
if (!this.minimizedUltracodeRuns) return;
if (sessionId) {
const set = this.minimizedUltracodeRuns.get(sessionId);
if (set) {
set.delete(runId);
if (!set.size) this.minimizedUltracodeRuns.delete(sessionId);
}
return;
}
for (const [sid, set] of this.minimizedUltracodeRuns) {
if (set.delete(runId) && !set.size) this.minimizedUltracodeRuns.delete(sid);
}
},
/**
* Minimize the floating run window into its originating session tab: record it as
* minimized (so a badge renders on the tab), genie-animate the window toward that
* tab, then remove the floating element. Restorable from the tab badge dropdown.
*/
minimizeUltracodeWindowToTab(runId) {
this._ensureUltracodeWindowState();
const data = this.ultracodeWindows.get(runId);
if (!data) return;
data.collapsed = !data.collapsed;
data.element.classList.toggle('collapsed', data.collapsed);
let parentSessionId = data.parentSessionId;
if (!parentSessionId) {
const summary = this.workflowRuns && this.workflowRuns.get(runId);
parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
}
// No tab to fly into → fall back to a plain close so the window isn't orphaned.
if (!parentSessionId) {
this.closeUltracodeWindow(runId, true);
return;
}
// Cancel any pending finish auto-close — the badge owns the run's lifecycle now.
const pending = this.ultracodeWindowCloseTimers.get(runId);
if (pending) {
clearTimeout(pending);
this.ultracodeWindowCloseTimers.delete(runId);
}
if (!this.minimizedUltracodeRuns.has(parentSessionId)) this.minimizedUltracodeRuns.set(parentSessionId, new Set());
this.minimizedUltracodeRuns.get(parentSessionId).add(runId);
const element = data.element;
const dragListeners = data.dragListeners;
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
this._teardownUltracodeDrag(dragListeners);
if (element) element.remove();
this.ultracodeWindows.delete(runId);
// Full rebuild so the tab badge renders (the incremental path only knows subagent badges).
this._fullRenderSessionTabs();
this.updateConnectionLines();
});
},
/** Genie the window toward the center of its tab, then invoke `done` to tear it down. */
_animateUltracodeWindowToTab(element, sessionId, done) {
const tab = sessionId ? document.querySelector(`.session-tab[data-id="${sessionId}"]`) : null;
if (!tab || !element) {
done();
return;
}
const w = element.getBoundingClientRect();
const t = tab.getBoundingClientRect();
const dx = t.left + t.width / 2 - (w.left + w.width / 2);
const dy = t.top + t.height / 2 - (w.top + w.height / 2);
element.style.transformOrigin = 'center center';
element.style.transition = 'transform 0.26s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.26s ease';
element.style.pointerEvents = 'none';
requestAnimationFrame(() => {
element.style.transform = `translate(${dx}px, ${dy}px) scale(0.06)`;
element.style.opacity = '0';
});
let finished = false;
const finish = () => {
if (finished) return;
finished = true;
done();
};
element.addEventListener('transitionend', finish, { once: true });
setTimeout(finish, 320); // fallback in case transitionend doesn't fire
},
/** Tab badge (with restore/dismiss dropdown) for runs minimized to this session's tab. */
renderUltracodeTabBadge(sessionId) {
this._ensureUltracodeWindowState();
const runSet = this.minimizedUltracodeRuns.get(sessionId);
const agentMap = this.minimizedUltracodeAgents.get(sessionId);
const total = (runSet ? runSet.size : 0) + (agentMap ? agentMap.size : 0);
if (total === 0) return '';
const trunc = (s) => (s.length > 25 ? s.slice(0, 25) + '…' : s);
const items = [];
// Minimized run windows (🧬) first…
if (runSet) {
for (const runId of runSet) {
const run = this.workflowRuns && this.workflowRuns.get(runId);
const name = run ? run.workflowName || run.summary || runId : runId;
const statusCls = this._workflowStatusClass(run ? String(run.status || '') : '');
items.push(
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeRunFromTab(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore run">` +
`<span class="subagent-dropdown-status ${statusCls}"></span>` +
`<span class="ultracode-dd-icon">🧬</span>` +
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeRun(${escapeHtml(JSON.stringify(runId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">&times;</span>` +
`</div>`
);
}
}
// …then minimized agent transcripts (📄).
if (agentMap) {
for (const [agentId, entry] of agentMap) {
const name = (entry && entry.label) || agentId;
items.push(
`<div class="subagent-dropdown-item" onclick="event.stopPropagation(); app.restoreUltracodeAgentFromTab(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Click to restore transcript">` +
`<span class="subagent-dropdown-status"></span>` +
`<span class="ultracode-dd-icon">📄</span>` +
`<span class="subagent-dropdown-name">${escapeHtml(trunc(name))}</span>` +
`<span class="subagent-dropdown-close" onclick="event.stopPropagation(); app.dismissMinimizedUltracodeAgent(${escapeHtml(JSON.stringify(agentId))},${escapeHtml(JSON.stringify(sessionId))})" title="Dismiss">&times;</span>` +
`</div>`
);
}
}
const label = total === 1 ? 'ULTRA' : `ULTRA (${total})`;
return (
`<span class="tab-ultracode-badge" onmouseenter="app.showSubagentDropdown(this)" onmouseleave="app.scheduleHideSubagentDropdown(this)" onclick="event.stopPropagation(); app.pinSubagentDropdown(this);">` +
`<span class="subagent-label">${label}</span>` +
`<div class="subagent-dropdown" onmouseenter="app.cancelHideSubagentDropdown()" onmouseleave="app.scheduleHideSubagentDropdown(this.parentElement)">${items.join('')}</div>` +
`</span>`
);
},
/** Restore a minimized run from its tab badge: re-open the floating window. */
restoreUltracodeRunFromTab(runId, sessionId) {
this._ensureUltracodeWindowState();
this._removeMinimizedUltracodeRun(runId, sessionId);
this._fullRenderSessionTabs();
this.openUltracodeWindowForRun(runId);
},
/** Dismiss a minimized run from its tab badge (don't re-pop it). */
dismissMinimizedUltracodeRun(runId, sessionId) {
this._ensureUltracodeWindowState();
this._removeMinimizedUltracodeRun(runId, sessionId);
this.ultracodeWindowsClosed.add(runId);
this._fullRenderSessionTabs();
this.updateConnectionLines();
},
// ── Minimize an agent transcript window into its tab (same idiom as run windows) ──
/** Is this agent transcript currently minimized to a tab? */
_isUltracodeAgentMinimized(agentId) {
if (!this.minimizedUltracodeAgents) return false;
for (const map of this.minimizedUltracodeAgents.values()) {
if (map.has(agentId)) return true;
}
return false;
},
/** Look up a minimized agent's {runId,label} entry (across sessions). */
_getMinimizedUltracodeAgent(agentId) {
if (!this.minimizedUltracodeAgents) return null;
for (const map of this.minimizedUltracodeAgents.values()) {
if (map.has(agentId)) return map.get(agentId);
}
return null;
},
/** Drop an agent from minimized tracking (all sessions, or a specific one). */
_removeMinimizedUltracodeAgent(agentId, sessionId) {
if (!this.minimizedUltracodeAgents) return;
if (sessionId) {
const map = this.minimizedUltracodeAgents.get(sessionId);
if (map) {
map.delete(agentId);
if (!map.size) this.minimizedUltracodeAgents.delete(sessionId);
}
return;
}
for (const [sid, map] of this.minimizedUltracodeAgents) {
if (map.delete(agentId) && !map.size) this.minimizedUltracodeAgents.delete(sid);
}
},
/** Minimize an agent transcript window into the run's originating session tab. */
minimizeUltracodeAgentWindowToTab(agentId) {
this._ensureUltracodeWindowState();
const info = this.ultracodeAgentWindows.get(agentId);
if (!info) return;
const runId = info.runId;
const summary = runId && this.workflowRuns ? this.workflowRuns.get(runId) : null;
let parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
if (!parentSessionId && this.activeSessionId && this.sessions && this.sessions.has(this.activeSessionId)) {
parentSessionId = this.activeSessionId;
}
// No tab to fly into → plain close rather than orphan it.
if (!parentSessionId) {
this.closeUltracodeAgentWindow(agentId);
return;
}
const labelEl = info.element.querySelector('.uw-name');
const label = labelEl ? labelEl.textContent : agentId;
if (!this.minimizedUltracodeAgents.has(parentSessionId))
this.minimizedUltracodeAgents.set(parentSessionId, new Map());
this.minimizedUltracodeAgents.get(parentSessionId).set(agentId, { runId, label });
const element = info.element;
const dragListeners = info.dragListeners;
this._animateUltracodeWindowToTab(element, parentSessionId, () => {
this._teardownUltracodeDrag(dragListeners);
if (element) element.remove();
this.ultracodeAgentWindows.delete(agentId);
this._fullRenderSessionTabs();
this.updateConnectionLines();
});
},
/** Restore a minimized agent transcript from its tab badge: re-open its window. */
restoreUltracodeAgentFromTab(agentId, sessionId) {
this._ensureUltracodeWindowState();
const entry = this._getMinimizedUltracodeAgent(agentId);
const runId = entry ? entry.runId : null;
this._removeMinimizedUltracodeAgent(agentId, sessionId);
this._fullRenderSessionTabs();
this.openUltracodeAgentWindow(agentId, runId);
},
/** Dismiss a minimized agent transcript from its tab badge. */
dismissMinimizedUltracodeAgent(agentId, sessionId) {
this._ensureUltracodeWindowState();
this._removeMinimizedUltracodeAgent(agentId, sessionId);
this._fullRenderSessionTabs();
this.updateConnectionLines();
},
@@ -233,19 +520,149 @@ Object.assign(CodemanApp.prototype, {
}
},
// ── Agent-transcript windows ────────────────────────────────────────────────
// Clicking an agent card (in a run window OR the dock panel) opens the agent's
// live transcript as its OWN in-page floating window, line-tied to its parent run
// window (or the run's session tab when that window is closed). Replaces the old
// detached `window.open` browser popup so the transcript stays inside the same
// draggable, connector-line floating-window system as the run windows.
/** Open (or focus) the floating transcript window for a workflow agent. */
async openUltracodeAgentWindow(agentId, runId) {
this._ensureUltracodeWindowState();
if (!agentId) return;
this._removeMinimizedUltracodeAgent(agentId); // an explicit open overrides a past minimize
const existing = this.ultracodeAgentWindows.get(agentId);
if (existing && existing.element) {
// Already open — bring to front and refresh transcript.
existing.element.style.zIndex = ++this.ultracodeWindowZIndex;
this.updateConnectionLines();
} else if (!this.createUltracodeAgentWindow(agentId, runId)) {
return;
}
// Body shows a loading state until the fetch lands (re-fetch on focus too, so a
// still-running agent's transcript grows as you re-click).
const data = this._fetchWorkflowAgentTranscript ? await this._fetchWorkflowAgentTranscript(agentId) : null;
this.renderUltracodeAgentWindowContent(agentId, data);
},
/** Build and mount the floating agent-transcript window shell near its parent. */
createUltracodeAgentWindow(agentId, runId) {
this._ensureUltracodeWindowState();
if (this.ultracodeAgentWindows.has(agentId)) return this.ultracodeAgentWindows.get(agentId).element;
const label = this._ultracodeAgentLabel(agentId, runId) || agentId;
const win = document.createElement('div');
win.className = 'ultracode-window ultracode-agent-window spawning';
win.id = `ultracode-agent-window-${agentId}`;
win.style.zIndex = ++this.ultracodeWindowZIndex;
win.innerHTML = `
<div class="ultracode-window-header">
<div class="ultracode-window-title" title="${escapeHtml(label)} — transcript">
<span class="icon">📄</span>
<span class="uw-name">${escapeHtml(label)}</span>
</div>
<div class="ultracode-window-actions">
<button class="uw-min" type="button" title="Minimize to tab">─</button>
<button class="uw-close" type="button" title="Close">&times;</button>
</div>
</div>
<div class="ultracode-window-body">
<div class="subagent-empty">Loading transcript…</div>
</div>
`;
// Position: offset from the parent run window if it's open, else cascade.
const parentWin = runId ? this.ultracodeWindows.get(runId) : null;
if (parentWin && parentWin.element) {
const r = parentWin.element.getBoundingClientRect();
win.style.left = `${Math.max(8, Math.min(r.left + 40, window.innerWidth - 472))}px`;
win.style.top = `${Math.max(8, Math.min(r.top + 40, window.innerHeight - 160))}px`;
} else {
const n = this.ultracodeAgentWindows.size;
win.style.left = `${Math.min(140 + n * 28, Math.max(8, window.innerWidth - 472))}px`;
win.style.top = `${110 + n * 28}px`;
}
document.body.appendChild(win);
requestAnimationFrame(() => win.classList.remove('spawning'));
const header = win.querySelector('.ultracode-window-header');
const dragListeners = this.makeWindowDraggable(win, header);
win.querySelector('.uw-min').addEventListener('click', (e) => {
e.stopPropagation();
this.minimizeUltracodeAgentWindowToTab(agentId);
});
win.querySelector('.uw-close').addEventListener('click', (e) => {
e.stopPropagation();
this.closeUltracodeAgentWindow(agentId);
});
this.ultracodeAgentWindows.set(agentId, { element: win, runId, dragListeners });
this.updateConnectionLines();
return win;
},
/** Resolve a human label for an agent from the run's fetched detail.agents[]. */
_ultracodeAgentLabel(agentId, runId) {
const detail = this.workflowRunDetails && runId ? this.workflowRunDetails.get(runId) : null;
const agents = detail && Array.isArray(detail.agents) ? detail.agents : null;
if (agents) {
const found = agents.find((a) => a.agentId === agentId);
if (found && found.label) return found.label;
}
return null;
},
/** Fill an agent window's body with the fetched transcript (or a friendly empty state). */
renderUltracodeAgentWindowContent(agentId, data) {
const info = this.ultracodeAgentWindows.get(agentId);
if (!info || !info.element) return;
const body = info.element.querySelector('.ultracode-window-body');
if (!body) return;
if (!data || !data.formatted || !data.entryCount) {
body.innerHTML =
'<div class="subagent-empty">No transcript available yet — the agent may be queued, aged out of tracking, or subagent tracking is disabled.</div>';
return;
}
const text = escapeHtml(data.formatted.join('\n'));
body.innerHTML = `<div class="uw-summary">${data.entryCount} entries</div><pre class="uw-transcript">${text}</pre>`;
},
/** Close one floating agent-transcript window. */
closeUltracodeAgentWindow(agentId) {
this._ensureUltracodeWindowState();
const info = this.ultracodeAgentWindows.get(agentId);
if (!info) return;
this._teardownUltracodeDrag(info.dragListeners);
if (info.element) info.element.remove();
this.ultracodeAgentWindows.delete(agentId);
this.updateConnectionLines();
},
/** Tear down every floating window (called on SSE reconnect; keeps user dismissals). */
removeAllUltracodeWindows() {
this._ensureUltracodeWindowState();
const had = this.ultracodeWindows.size > 0;
const hadMinimized = this.minimizedUltracodeRuns.size > 0 || this.minimizedUltracodeAgents.size > 0;
const had = this.ultracodeWindows.size > 0 || this.ultracodeAgentWindows.size > 0;
for (const [, data] of this.ultracodeWindows) {
this._teardownUltracodeDrag(data.dragListeners);
if (data.element) data.element.remove();
}
this.ultracodeWindows.clear();
for (const [, info] of this.ultracodeAgentWindows) {
this._teardownUltracodeDrag(info.dragListeners);
if (info.element) info.element.remove();
}
this.ultracodeAgentWindows.clear();
for (const t of this.ultracodeWindowCloseTimers.values()) clearTimeout(t);
this.ultracodeWindowCloseTimers.clear();
this.minimizedUltracodeRuns.clear();
this.minimizedUltracodeAgents.clear();
// Redraw so the now-orphaned connector lines are cleared from the shared SVG.
if (had) this.updateConnectionLines();
// Drop any now-stale tab badges.
if (hadMinimized) this.renderSessionTabs();
},
/** When the feature is toggled on, pop windows for any currently-active runs. */
@@ -328,7 +745,7 @@ Object.assign(CodemanApp.prototype, {
const header =
`<div class="ultracode-phase-header"><span>${escapeHtml(title)}</span>` +
`<span class="ultracode-phase-sub">${this._fmtNum(tok)} tok · ${tools} tools</span></div>`;
return header + group.map((a) => this._workflowAgentCardHtml(a)).join('');
return header + group.map((a) => this._workflowAgentCardHtml(a, run.runId)).join('');
})
.join('');
return head + grid;
@@ -381,4 +798,51 @@ Object.assign(CodemanApp.prototype, {
svg.appendChild(line);
}
},
/**
* Append agent-window → parent connector lines into the shared SVG. Parent is the
* agent's run floating window when open, else the run's session tab. Called right
* after `_appendUltracodeConnectionLines` so it shares the same batched pass + the
* tab-rect cache.
*/
_appendUltracodeAgentConnectionLines(svg, rects) {
this._ensureUltracodeWindowState();
if (!svg || !this.ultracodeAgentWindows.size) return;
if (!rects) rects = new Map();
for (const [agentId, info] of this.ultracodeAgentWindows) {
if (!info.element) continue;
const winRect = info.element.getBoundingClientRect();
// Anchor: parent run window bottom-center if open, else the run's tab.
let px, py;
const runWin = info.runId ? this.ultracodeWindows.get(info.runId) : null;
if (runWin && runWin.element) {
const pr = runWin.element.getBoundingClientRect();
px = pr.left + pr.width / 2;
py = pr.bottom;
} else {
const summary = info.runId && this.workflowRuns ? this.workflowRuns.get(info.runId) : null;
const parentSessionId = summary ? this._resolveUltracodeParentSession(summary) : null;
if (!parentSessionId) continue;
const tabKey = 'tab:' + parentSessionId;
if (!rects.has(tabKey)) {
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set(tabKey, tab.getBoundingClientRect());
}
const tabRect = rects.get(tabKey);
if (!tabRect) continue;
px = tabRect.left + tabRect.width / 2;
py = tabRect.bottom;
}
const x2 = winRect.left + winRect.width / 2;
const y2 = winRect.top;
const midY = (py + y2) / 2;
const path = `M ${px} ${py} C ${px} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
line.setAttribute('d', path);
line.setAttribute('class', 'connection-line ultracode-connection ultracode-agent-connection');
line.setAttribute('data-agent-id', agentId);
svg.appendChild(line);
}
},
});
+32 -9
View File
@@ -60,6 +60,7 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
import { dataPath } from '../../config/instance.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
@@ -662,7 +663,7 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/input', async (req) => {
const { id } = req.params as { id: string };
const { input, useMux } = parseBody(SessionInputWithLimitSchema, req.body);
const { input, useMux, seq, clientId } = parseBody(SessionInputWithLimitSchema, req.body);
const session = findSessionOrFail(ctx, id);
const inputStr = String(input);
@@ -673,6 +674,13 @@ export function registerSessionRoutes(
);
}
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
// client's ACK, so a tagged duplicate redelivery must still return 200 but
// skip the write. Untagged requests (curl/legacy) always apply.
if (typeof clientId === 'string' && typeof seq === 'number' && !session.shouldApplyInput(clientId, seq)) {
return {};
}
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
if (useMux) {
@@ -1703,7 +1711,7 @@ export function registerSessionRoutes(
// ═══════════════════════════════════════════════════════════════
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
// The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
// CSRF defense: state-changing routes must come from same origin.
@@ -1740,7 +1748,7 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
// — even an authenticated attacker can otherwise loop 10MB POSTs.
// — even an authenticated attacker can otherwise loop large image POSTs.
if (!consumePasteToken(`${req.ip}:${id}`)) {
reply.code(429);
reply.header('Retry-After', '60');
@@ -1754,8 +1762,9 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
}
// Read the single file part. @fastify/multipart enforces the 10MB size cap
// and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
// Read the single file part. @fastify/multipart enforces the per-file size
// cap (MAX_PASTE_IMAGE_BYTES) and the 1-file/4-field count limits (server.ts),
// replacing a hand-rolled
// boundary scanner with several bugs: literal boundary matches anywhere in
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
// offsets), no part-count cap.
@@ -1779,7 +1788,8 @@ export function registerSessionRoutes(
imageBytes = await part.toBuffer();
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || `File too large (max ${maxMb}MB)`);
}
if (imageBytes.length === 0) {
reply.code(400);
@@ -1843,9 +1853,22 @@ export function registerSessionRoutes(
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
// the leaf. session.workingDir is guaranteed to exist (live session).
await fs.mkdir(imageDir);
// Non-recursive mkdir: does not follow symlinks for the leaf.
// session.workingDir is guaranteed to exist (live session).
try {
await fs.mkdir(imageDir);
} catch (mkErr: unknown) {
// Concurrent uploads (a batch of photos) race to create .claude-images —
// the losers get EEXIST. Treat an already-present REAL directory as
// success, but re-verify it isn't a symlink a racing actor planted
// (preserve the symlink-safety guarantee above).
if ((mkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw mkErr;
const raceStat = await fs.lstat(imageDir);
if (raceStat.isSymbolicLink() || !raceStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
}
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
+20 -5
View File
@@ -21,8 +21,11 @@
* {"t":"o","d":"..."} — terminal output
* {"t":"c"} — clear terminal
* {"t":"r"} — needs refresh (reload buffer)
* {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame)
* Client -> Server:
* {"t":"i","d":"..."} — input (keystroke or paste)
* {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are
* optional reliable-delivery tags: the server applies each
* (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}.
* {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged)
*/
@@ -123,10 +126,22 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
// (legacy/other tools) are applied as-is — no behavior change.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
}
if (seq !== null && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${seq}}`);
}
} else if (
msg.t === 'z' &&
Number.isInteger(msg.c) &&
+9
View File
@@ -469,6 +469,15 @@ export const SettingsUpdateSchema = z
export const SessionInputWithLimitSchema = z.object({
input: z.string().max(100000), // 100KB max input
useMux: z.boolean().optional(),
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
// client tags each input with a stable clientId + a monotonic per-session seq
// and redelivers anything it hasn't seen ACKed (e.g. a frame silently dropped
// by a half-open WebSocket on a flaky link). The server applies each (clientId,
// seq) at-most-once via Session.shouldApplyInput so a redelivery can't type the
// prompt twice. `.optional()` (not `.nullish()`) — the client omits them when
// unset rather than sending null. See docs/reliable-input-delivery.md.
seq: z.number().int().nonnegative().optional(),
clientId: z.string().max(128).optional(),
});
// ========== Session Mutation Routes ==========
+3 -2
View File
@@ -128,6 +128,7 @@ import {
} from '../utils/index.js';
import type { EventLoopMonitorHandle } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { SseEvent } from './sse-events.js';
import { getLatestPlanUsage } from './plan-usage-latest.js';
import type { ScheduledRun } from './ports/index.js';
@@ -679,8 +680,8 @@ export class WebServer extends EventEmitter {
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
await this.app.register(fastifyMultipart, {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB per file
files: 1, // paste-image only ever sends one file
fileSize: MAX_PASTE_IMAGE_BYTES, // per file (default 50MB) — large phone photos / screenshots
files: 1, // paste-image sends one file per request (clients batch up to 20 requests)
fields: 4, // small headroom for accompanying form fields
},
});
+380 -49
View File
@@ -53,16 +53,99 @@ const RUN_FILE_PREFIX = 'wf_';
const RUN_FILE_SUFFIX = '.json';
const LIVE_JOURNAL_FILE = 'journal.jsonl';
const LIVE_AGENT_PREFIX = 'agent-';
const LIVE_TRANSCRIPT_SUFFIX = '.jsonl';
const SCRIPTS_SUBDIR = 'scripts';
/** Hard caps on the largest per-agent strings so a 28-agent run stays compact. */
const PROMPT_PREVIEW_MAX = 200;
const RESULT_PREVIEW_MAX = 240;
/** Cap a live script read so a runaway/huge embedded script can't blow memory. */
const SCRIPT_READ_MAX_BYTES = 512 * 1024;
/** LRU cap on cached per-agent transcript stats across all live runs. */
const MAX_CACHED_AGENT_STATS = 2000;
function truncate(value: string | undefined, max: number): string | undefined {
if (typeof value !== 'string') return undefined;
return value.length > max ? `${value.slice(0, max)}…` : value;
}
/** First text from a transcript message's `content` (string or content-block array). */
function extractMessageText(content: unknown): string | undefined {
if (typeof content === 'string') return content.trim() || undefined;
if (Array.isArray(content)) {
for (const block of content) {
if (block && typeof block === 'object' && (block as { type?: string }).type === 'text') {
const t = (block as { text?: string }).text;
if (typeof t === 'string' && t.trim()) return t.trim();
}
}
}
return undefined;
}
/**
* Isolate the `meta = { … }` object literal from a workflow script so that later
* declarations (e.g. JSON-Schema objects with their own `description:`/`title:`
* fields, or prose containing `phases: [`) can't be mistaken for meta. Brace-matches
* naively — a `{`/`}` inside a string value can still confuse it, in which case we
* return undefined and the caller falls back to scanning the whole body. Best-effort.
*/
function extractMetaBlock(body: string): string | undefined {
const m = /(?:^|[^A-Za-z0-9_])(?:export\s+const|const|let|var)\s+meta\s*=\s*\{/.exec(body);
if (!m) return undefined;
const open = body.indexOf('{', m.index);
if (open < 0) return undefined;
let depth = 0;
for (let i = open; i < body.length; i++) {
if (body[i] === '{') depth++;
else if (body[i] === '}' && --depth === 0) return body.slice(open, i + 1);
}
return undefined; // unterminated (truncated script) — caller falls back to whole body
}
/**
* Best-effort extraction of a single/double-quoted `key: '...'` value from a workflow
* `meta` literal (the script is JS we must not eval). The key is anchored to a key
* position (`{`/`,`/whitespace before it) so e.g. `description` can't match the tail
* of `..._description`; pass the scoped meta block so later same-named schema fields
* can't win. Returns undefined on no match.
*/
function extractMetaString(body: string, key: string): string | undefined {
const re = new RegExp(`(?:^|[{,\\s])${key}\\s*:\\s*(['"])((?:\\\\.|(?!\\1).)*)\\1`);
const m = re.exec(body);
return m ? m[2].replace(/\\(['"\\`])/g, '$1') : undefined;
}
/** Best-effort `meta.phases: [{title, detail}, …]` extraction (pass the scoped meta block). */
function extractMetaPhases(body: string): WorkflowRunPhase[] {
const keyMatch = /(?:^|[^A-Za-z0-9_])phases\s*:\s*\[/.exec(body);
if (!keyMatch) return [];
const open = body.indexOf('[', keyMatch.index);
if (open < 0) return [];
// Brace-match to the array's closing ] so nested arrays don't end it early.
let depth = 0;
let end = -1;
for (let i = open; i < body.length; i++) {
if (body[i] === '[') depth++;
else if (body[i] === ']' && --depth === 0) {
end = i;
break;
}
}
if (end < 0) return [];
const block = body.slice(open, end + 1);
const phases: WorkflowRunPhase[] = [];
const re = /title\s*:\s*(['"])((?:\\.|(?!\1).)*)\1(?:\s*,\s*detail\s*:\s*(['"])((?:\\.|(?!\3).)*)\3)?/g;
let m: RegExpExecArray | null;
while ((m = re.exec(block)) !== null && phases.length < 20) {
phases.push({
title: m[2].replace(/\\(['"\\`])/g, '$1'),
detail: (m[4] || '').replace(/\\(['"\\`])/g, '$1'),
});
}
return phases;
}
/** Drop the heavy `agents[]` for list/snapshot use. */
export function summarizeRun(info: WorkflowRunInfo): WorkflowRunSummary {
const { agents: _agents, ...summary } = info;
@@ -85,6 +168,34 @@ interface DiscoveredLiveRun {
runId: string;
}
/** Per-agent stats parsed from one `agent-<id>.jsonl` transcript (mtime-cached). */
interface AgentTranscriptStats {
/** Tokens for the agent's LAST usage-bearing message (in+out+cache) ≈ the completion-JSON `tokens`. */
tokens: number;
/** Count of `tool_use` blocks across the transcript (matches the completion-JSON `toolCalls`). */
toolCalls: number;
/** Most-recent model id seen. */
model: string;
/** Name of the most-recent `tool_use` block. */
lastToolName?: string;
/** First user-message text, truncated — a hint of what the agent was asked. */
promptPreview?: string;
}
/** Workflow meta derived live from `workflows/scripts/<name>-<runId>.js`. */
interface LiveWorkflowMeta {
workflowName?: string;
summary?: string;
phases: WorkflowRunPhase[];
}
/** A live agent file on disk (transcript and/or meta), keyed by its `<id>` stem. */
interface LiveAgentFile {
agentId: string;
transcriptPath?: string;
transcriptMtime?: number;
}
/** A `workflowProgress[]` entry as it appears on disk (loosely typed for defensive parsing). */
interface RawProgressEntry {
type?: string;
@@ -123,6 +234,17 @@ export class WorkflowRunWatcher extends EventEmitter {
private liveDirMtimes = new Map<string, number>();
/** runId -> absolute live transcript-dir path (for mtime cleanup on removal). */
private runIdToLiveDir = new Map<string, string>();
/** transcript abs path -> { mtimeMs, stats }; re-parse a transcript only when its mtime moves. */
private agentStatCache = new LRUMap<string, { mtimeMs: number; stats: AgentTranscriptStats }>({
maxSize: MAX_CACHED_AGENT_STATS,
});
/** runId -> derived script meta (immutable per run; re-derived only until a name is found). */
private liveMetaCache = new Map<string, LiveWorkflowMeta>();
/** journal abs path -> { mtimeMs, parsed }; re-parse the journal only when it grows. */
private journalCache = new Map<
string,
{ mtimeMs: number; parsed: { startedOrder: string[]; doneIds: Set<string> } }
>();
/** watched-dir absolute path -> chokidar watcher (workflows/ + subagents/workflows/). */
private dirWatchers = new Map<string, ChokidarWatcher>();
@@ -160,6 +282,9 @@ export class WorkflowRunWatcher extends EventEmitter {
this.runIdToPath.clear();
this.liveDirMtimes.clear();
this.runIdToLiveDir.clear();
this.agentStatCache.clear();
this.liveMetaCache.clear();
this.journalCache.clear();
}
/** All cached runs (no recency filter), most-recently-active first. */
@@ -243,7 +368,18 @@ export class WorkflowRunWatcher extends EventEmitter {
if (path) this.fileMtimes.delete(path);
this.runIdToPath.delete(runId);
const liveDir = this.runIdToLiveDir.get(runId);
if (liveDir) this.liveDirMtimes.delete(liveDir);
if (liveDir) {
this.liveDirMtimes.delete(liveDir);
// Drop cached transcript stats + journal parse under this run's live dir.
const prefix = liveDir.endsWith('/') ? liveDir : liveDir + '/';
for (const key of Array.from(this.agentStatCache.keys())) {
if (key.startsWith(prefix)) this.agentStatCache.delete(key);
}
for (const key of Array.from(this.journalCache.keys())) {
if (key.startsWith(prefix)) this.journalCache.delete(key);
}
}
this.liveMetaCache.delete(runId);
this.runIdToLiveDir.delete(runId);
this.emit('run_removed', { runId });
}
@@ -346,14 +482,58 @@ export class WorkflowRunWatcher extends EventEmitter {
}
/**
* Re-synthesize an in-flight run from its transcript dir when its newest member
* mtime moved (skip otherwise so we don't re-emit run_updated on idle polls).
* Re-synthesize an in-flight run from its transcript dir. Cheap pass first: stat
* the dir members and skip entirely when the newest mtime is unchanged, so an idle
* poll never reads a single (large) transcript. Only on a real change do we parse —
* and even then per-transcript stats come from an mtime-keyed cache, so only the
* transcripts that actually grew are re-read.
*/
private async maybeParseLive(live: DiscoveredLiveRun): Promise<void> {
const info = await this.parseLiveDir(live);
let entries: string[];
try {
entries = await readdir(live.dirPath);
} catch {
return; // vanished between discover and read
}
// Cheap pass: newest member mtime + the agent-file set (no transcript reads yet).
let newestMtime = 0;
let journalPath: string | null = null;
let journalMtime = 0;
const agentFiles = new Map<string, LiveAgentFile>();
for (const name of entries) {
if (name !== LIVE_JOURNAL_FILE && !name.startsWith(LIVE_AGENT_PREFIX)) continue;
const full = join(live.dirPath, name);
let m = 0;
try {
m = (await stat(full)).mtimeMs;
} catch {
continue; // entry vanished — ignore
}
if (m > newestMtime) newestMtime = m;
if (name === LIVE_JOURNAL_FILE) {
journalPath = full;
journalMtime = m;
continue;
}
// agent-<stem>.jsonl (transcript) or agent-<stem>.meta.json (queued-slot marker)
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
if (!stem) continue;
const slot = agentFiles.get(stem) || { agentId: stem };
if (name.endsWith(LIVE_TRANSCRIPT_SUFFIX)) {
slot.transcriptPath = full;
slot.transcriptMtime = m;
}
agentFiles.set(stem, slot);
}
if (agentFiles.size === 0) return; // nothing to show yet
// Skip the expensive parse when nothing changed since the last synthesis.
if (this.liveDirMtimes.get(live.dirPath) === newestMtime) return;
this.liveDirMtimes.set(live.dirPath, newestMtime);
const info = await this.parseLiveDir(live, agentFiles, journalPath, journalMtime, newestMtime);
if (!info) return;
if (this.liveDirMtimes.get(live.dirPath) === info.lastActivityAt) return;
this.liveDirMtimes.set(live.dirPath, info.lastActivityAt);
const existed = this.runs.has(info.runId);
this.runs.set(info.runId, info);
@@ -362,56 +542,87 @@ export class WorkflowRunWatcher extends EventEmitter {
}
/**
* Build a minimal ACTIVE WorkflowRunInfo from `subagents/workflows/wf_<id>/`.
* The transcript tree carries no phases/tokens — those arrive with the
* completion wf_*.json — so we expose: the agent slots (keyed by agentId, so the
* card→transcript click still works), each marked done/running from journal
* `result` lines, and lastActivityAt from the newest agent/journal mtime.
* Build an ACTIVE WorkflowRunInfo from a live transcript dir, ENRICHED so the
* floating window / panel show real data mid-run rather than empty slots:
* - per-agent tokens + tool-calls + model + last tool, parsed from each
* `agent-<id>.jsonl` (mtime-cached — only changed transcripts are re-read);
* - per-agent state: 'done' once the journal logs a `result` (→ green badge),
* 'progress' once it logs `started` or a transcript exists (→ yellow), else 'start';
* - run name/summary/phases from the live `workflows/scripts/<name>-<runId>.js`
* (the completion wf_<id>.json, which carries these, only lands at the end);
* - run totals = sums of the per-agent stats.
*/
private async parseLiveDir(live: DiscoveredLiveRun): Promise<WorkflowRunInfo | null> {
let entries: string[];
try {
entries = await readdir(live.dirPath);
} catch {
return null; // vanished between discover and read
}
private async parseLiveDir(
live: DiscoveredLiveRun,
agentFiles: Map<string, LiveAgentFile>,
journalPath: string | null,
journalMtime: number,
newestMtime: number
): Promise<WorkflowRunInfo | null> {
const { startedOrder, doneIds } = journalPath
? await this.readJournal(journalPath, journalMtime)
: { startedOrder: [] as string[], doneIds: new Set<string>() };
const startIndex = new Map<string, number>();
startedOrder.forEach((id, i) => startIndex.set(id, i));
const agentIds = new Set<string>();
let newestMtime = 0;
for (const name of entries) {
if (name.startsWith(LIVE_AGENT_PREFIX)) {
const stem = name.slice(LIVE_AGENT_PREFIX.length).replace(/\.(meta\.json|jsonl)$/, '');
if (stem) agentIds.add(stem);
}
if (name === LIVE_JOURNAL_FILE || name.startsWith(LIVE_AGENT_PREFIX)) {
try {
const m = (await stat(join(live.dirPath, name))).mtimeMs;
if (m > newestMtime) newestMtime = m;
} catch {
// entry vanished — ignore
}
}
}
if (agentIds.size === 0) return null; // nothing to show yet
// Order agents by journal launch order; not-yet-started ones trail (sorted by id).
const ids = Array.from(agentFiles.keys()).sort((a, b) => {
const ia = startIndex.has(a) ? (startIndex.get(a) as number) : Number.MAX_SAFE_INTEGER;
const ib = startIndex.has(b) ? (startIndex.get(b) as number) : Number.MAX_SAFE_INTEGER;
if (ia !== ib) return ia - ib;
return a < b ? -1 : a > b ? 1 : 0;
});
const doneIds = await this.readJournalDoneAgents(join(live.dirPath, LIVE_JOURNAL_FILE));
const agents: WorkflowAgentInfo[] = Array.from(agentIds)
.sort()
.map((id, i) => ({
let totalTokens = 0;
let totalToolCalls = 0;
let defaultModel = '';
const agents: WorkflowAgentInfo[] = [];
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const file = agentFiles.get(id) as LiveAgentFile;
const stats = file.transcriptPath
? await this.agentTranscriptStats(file.transcriptPath, file.transcriptMtime || 0)
: null;
const state = doneIds.has(id) ? 'done' : startIndex.has(id) || file.transcriptPath ? 'progress' : 'start';
if (stats) {
totalTokens += stats.tokens;
totalToolCalls += stats.toolCalls;
if (stats.model && !defaultModel) defaultModel = stats.model;
}
agents.push({
index: i + 1,
label: `agent ${i + 1}`,
phaseIndex: 1,
phaseTitle: '',
model: '',
state: doneIds.has(id) ? 'done' : 'progress',
model: stats?.model || '',
state,
agentId: id,
}));
tokens: stats ? stats.tokens : undefined,
toolCalls: stats ? stats.toolCalls : undefined,
lastToolName: stats?.lastToolName,
promptPreview: stats?.promptPreview,
});
}
// Script meta is immutable for the life of a run, so derive it at most once.
// Re-derive only while we still lack a name (the script can land a poll or two
// after the first transcripts, which seed the run before the script exists).
let meta = this.liveMetaCache.get(live.runId);
if (!meta || !meta.workflowName) {
meta = await this.deriveLiveWorkflowMeta(live);
this.liveMetaCache.set(live.runId, meta);
}
return {
runId: live.runId,
workflowName: meta.workflowName,
summary: meta.summary,
status: 'running',
agentCount: agents.length,
phases: [],
totalTokens,
totalToolCalls,
defaultModel: defaultModel || undefined,
phases: meta.phases,
agents,
sessionUuid: live.sessionUuid,
projectHash: live.projectHash,
@@ -419,25 +630,145 @@ export class WorkflowRunWatcher extends EventEmitter {
};
}
/** Agent ids that already emitted a `result` event in the run journal. */
private async readJournalDoneAgents(journalPath: string): Promise<Set<string>> {
const done = new Set<string>();
/** Parse one agent transcript for token/tool stats; cached on the file's mtime. */
private async agentTranscriptStats(path: string, mtimeMs: number): Promise<AgentTranscriptStats | null> {
// peek (not get) on the hit-check so a cache HIT doesn't churn the LRU — every
// active agent is looked up each poll, and get() would delete+reinsert each time.
const cached = this.agentStatCache.peek(path);
if (cached && cached.mtimeMs === mtimeMs) return cached.stats;
let text: string;
try {
text = await readFile(path, 'utf-8');
} catch {
return null; // vanished mid-poll
}
let tokens = 0;
let toolCalls = 0;
let model = '';
let lastToolName: string | undefined;
let promptPreview: string | undefined;
for (const line of text.split('\n')) {
if (!line) continue;
let entry: { type?: string; message?: unknown };
try {
entry = JSON.parse(line) as { type?: string; message?: unknown };
} catch {
continue; // tolerate a partially-written trailing line
}
const msg = entry.message as
| {
model?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
content?: Array<{ type?: string; name?: string; text?: string }> | string;
}
| undefined;
if (!promptPreview && entry.type === 'user' && msg) {
promptPreview = truncate(extractMessageText(msg.content), PROMPT_PREVIEW_MAX);
}
if (!msg || typeof msg !== 'object') continue;
if (typeof msg.model === 'string' && msg.model) model = msg.model;
const u = msg.usage;
if (u) {
const total =
(u.input_tokens || 0) +
(u.output_tokens || 0) +
(u.cache_read_input_tokens || 0) +
(u.cache_creation_input_tokens || 0);
// Anthropic usage already reflects cumulative context, so the agent's running
// token total ≈ its LATEST usage-bearing message — last non-zero wins.
if (total > 0) tokens = total;
}
if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block && block.type === 'tool_use' && typeof block.name === 'string') {
toolCalls++;
lastToolName = block.name;
}
}
}
}
const stats: AgentTranscriptStats = { tokens, toolCalls, model, lastToolName, promptPreview };
this.agentStatCache.set(path, { mtimeMs, stats });
return stats;
}
/**
* Read the run journal: agent ids in `started` order plus the set that already
* logged a terminal `result`. (`started` lines appear in launch order.)
*/
private async readJournal(
journalPath: string,
mtimeMs: number
): Promise<{ startedOrder: string[]; doneIds: Set<string> }> {
const cached = this.journalCache.get(journalPath);
if (cached && cached.mtimeMs === mtimeMs) return cached.parsed;
const startedOrder: string[] = [];
const seenStarted = new Set<string>();
const doneIds = new Set<string>();
let text: string;
try {
text = await readFile(journalPath, 'utf-8');
} catch {
return done; // journal not written yet — all agents still in progress
return { startedOrder, doneIds }; // journal not written yet
}
for (const line of text.split('\n')) {
if (!line) continue;
try {
const ev = JSON.parse(line) as { type?: string; agentId?: string };
if (ev && ev.type === 'result' && typeof ev.agentId === 'string') done.add(ev.agentId);
if (!ev || typeof ev.agentId !== 'string') continue;
if (ev.type === 'started' && !seenStarted.has(ev.agentId)) {
seenStarted.add(ev.agentId);
startedOrder.push(ev.agentId);
} else if (ev.type === 'result') {
doneIds.add(ev.agentId);
}
} catch {
// tolerate a partially-written trailing line
}
}
return done;
const parsed = { startedOrder, doneIds };
this.journalCache.set(journalPath, { mtimeMs, parsed });
return parsed;
}
/**
* Derive name/summary/phases for a live run from its persisted script file
* `…/workflows/scripts/<name>-<runId>.js`. The filename yields a reliable name;
* `meta.description`/`meta.phases` are best-effort regex extractions (the script is
* JS we must not eval), so any parse miss simply leaves those optional fields absent.
*/
private async deriveLiveWorkflowMeta(live: DiscoveredLiveRun): Promise<LiveWorkflowMeta> {
const empty: LiveWorkflowMeta = { phases: [] };
const scriptsDir = join(this.projectsDir, live.projectHash, live.sessionUuid, WORKFLOWS_SUBDIR, SCRIPTS_SUBDIR);
let names: string[];
try {
names = await readdir(scriptsDir);
} catch {
return empty;
}
const suffix = `-${live.runId}.js`;
const fileName = names.find((n) => n.endsWith(suffix)) || names.find((n) => n.includes(live.runId));
if (!fileName) return empty;
const nameFromFile = fileName.endsWith(suffix) ? fileName.slice(0, -suffix.length) : fileName.replace(/\.js$/, '');
let body = '';
try {
body = (await readFile(join(scriptsDir, fileName), 'utf-8')).slice(0, SCRIPT_READ_MAX_BYTES);
} catch {
return { workflowName: nameFromFile || undefined, phases: [] };
}
// Scope name/summary/phases parsing to the `meta` literal so later script
// declarations (schemas, prose) can't be mistaken for it.
const metaBlock = extractMetaBlock(body) || body;
return {
workflowName: nameFromFile || extractMetaString(metaBlock, 'name') || undefined,
summary: extractMetaString(metaBlock, 'description') || undefined,
phases: extractMetaPhases(metaBlock),
};
}
/**
+10
View File
@@ -39,6 +39,16 @@ export class MockSession extends EventEmitter {
return true;
}
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
* exercising the reliable-delivery path behave like production. */
private _appliedInputSeq = new Map<string, number>();
shouldApplyInput(clientId: string, seq: number): boolean {
const last = this._appliedInputSeq.get(clientId);
if (last !== undefined && seq <= last) return false;
this._appliedInputSeq.set(clientId, seq);
return true;
}
/** Get the last written data */
get lastWrite(): string | undefined {
return this.writeBuffer[this.writeBuffer.length - 1];
+73
View File
@@ -0,0 +1,73 @@
/**
* @fileoverview Exactly-once input delivery — Session.shouldApplyInput dedup.
*
* Guards the server half of the reliable-input-delivery feature: the web client
* tags each input frame with a stable clientId + a monotonic per-session seq and
* redelivers anything it hasn't seen ACKed (a half-open socket silently drops
* frames on a flaky link). shouldApplyInput must apply each (clientId, seq)
* exactly once so a redelivery can never type the prompt twice — while still
* applying untagged input (curl/legacy) unconditionally at the call sites.
*
* See docs/reliable-input-delivery.md.
*/
import { describe, it, expect } from 'vitest';
import { Session } from '../src/session.js';
function makeSession(): Session {
// workingDir is the only required field; no PTY is spawned until start(),
// and TmuxManager no-ops under VITEST — so this is a cheap, side-effect-free
// instance for exercising the pure dedup bookkeeping.
return new Session({ workingDir: '/tmp' });
}
describe('Session.shouldApplyInput (exactly-once input dedup)', () => {
it('applies a fresh (clientId, seq) exactly once', () => {
const s = makeSession();
expect(s.shouldApplyInput('clientA', 1)).toBe(true);
// Same seq redelivered (lost ACK) — must NOT apply again.
expect(s.shouldApplyInput('clientA', 1)).toBe(false);
});
it('applies strictly increasing seqs and rejects stale ones', () => {
const s = makeSession();
expect(s.shouldApplyInput('c', 1)).toBe(true);
expect(s.shouldApplyInput('c', 2)).toBe(true);
expect(s.shouldApplyInput('c', 3)).toBe(true);
// Out-of-order / replayed lower seqs are duplicates.
expect(s.shouldApplyInput('c', 2)).toBe(false);
expect(s.shouldApplyInput('c', 1)).toBe(false);
// The next genuinely-new seq still applies.
expect(s.shouldApplyInput('c', 4)).toBe(true);
});
it('tracks each client independently', () => {
const s = makeSession();
expect(s.shouldApplyInput('a', 5)).toBe(true);
// A different client at seq 1 is not shadowed by client a's higher seq.
expect(s.shouldApplyInput('b', 1)).toBe(true);
expect(s.shouldApplyInput('b', 1)).toBe(false);
expect(s.shouldApplyInput('a', 6)).toBe(true);
});
it('tolerates a seq gap (skips never collapse a new seq to a duplicate)', () => {
const s = makeSession();
expect(s.shouldApplyInput('c', 1)).toBe(true);
// Client jumped seq (e.g. resumed after a reload that kept the counter).
expect(s.shouldApplyInput('c', 100)).toBe(true);
expect(s.shouldApplyInput('c', 100)).toBe(false);
expect(s.shouldApplyInput('c', 50)).toBe(false);
expect(s.shouldApplyInput('c', 101)).toBe(true);
});
it('keeps recent clients dedup-correct past the eviction bound', () => {
const s = makeSession();
// Far exceed MAX_INPUT_DEDUP_CLIENTS (256) with one-shot clients, then prove
// a freshly-active client is still deduped correctly (MRU eviction).
for (let i = 0; i < 400; i++) {
expect(s.shouldApplyInput(`oneshot-${i}`, 1)).toBe(true);
}
expect(s.shouldApplyInput('recent', 1)).toBe(true);
expect(s.shouldApplyInput('recent', 1)).toBe(false);
expect(s.shouldApplyInput('recent', 2)).toBe(true);
});
});
+37
View File
@@ -355,6 +355,43 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('applies a tagged (clientId, seq) input exactly once on redelivery', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
// First delivery of seq 1 — applied (200, written once).
const first = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
expect(first.statusCode).toBe(200);
// Redelivery of the SAME seq (client never saw the ACK) — still 200, but
// must NOT write again.
const dup = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
expect(dup.statusCode).toBe(200);
// A genuinely new seq — applied.
const next = await post({ input: '\r', seq: 2, clientId: 'cid-1' });
expect(next.statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['prompt', '\r']);
});
it('always applies untagged input (curl/legacy, no dedup)', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
const post = () => harness.app.inject({ method: 'POST', url, payload: { input: 'x' } });
await post();
await post();
// No seq/clientId ⇒ no dedup ⇒ both writes land.
expect(session.writeBuffer).toEqual(['x', 'x']);
});
});
// ========== POST /api/sessions/:id/resize ==========
+96
View File
@@ -317,3 +317,99 @@ describe('WorkflowRunWatcher — in-flight (live) runs', () => {
expect(watcher.getAllRuns()).toHaveLength(1);
});
});
/**
* Live ENRICHMENT: while a run is in-flight the watcher now parses each agent
* transcript for real tokens/tool-calls/model, maps state→colour from the journal,
* and derives the run name/summary/phases from the persisted script — so the
* floating window/panel show real data mid-run instead of "0 tok / agent N".
*/
describe('WorkflowRunWatcher — live enrichment (tokens/state/name)', () => {
const RUN = 'wf_enrich01-abc';
let projectsDir: string;
let watcher: WorkflowRunWatcher;
beforeEach(async () => {
projectsDir = await mkdtemp(join(tmpdir(), 'wfw-enrich-'));
const sessionDir = join(projectsDir, PROJECT_HASH, SESSION_UUID);
const liveDir = join(sessionDir, 'subagents', 'workflows', RUN);
await mkdir(liveDir, { recursive: true });
const scriptsDir = join(sessionDir, 'workflows', 'scripts');
await mkdir(scriptsDir, { recursive: true });
// Agent aaa: a user prompt + two assistant turns with usage + two tool_use blocks.
await writeFile(
join(liveDir, 'agent-aaa.jsonl'),
[
'{"type":"user","message":{"role":"user","content":"Audit the docs"}}',
'{"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":1000,"output_tokens":40},"content":[{"type":"tool_use","name":"Bash"}]}}',
'{"type":"assistant","message":{"model":"claude-opus-4-8","usage":{"input_tokens":2000,"cache_read_input_tokens":500,"output_tokens":80},"content":[{"type":"tool_use","name":"Edit"},{"type":"text","text":"done"}]}}',
].join('\n') + '\n',
'utf-8'
);
await writeFile(join(liveDir, 'agent-aaa.meta.json'), JSON.stringify({ agentType: 'workflow-subagent' }), 'utf-8');
// Agent bbb: started, no result yet, minimal transcript (no usage).
await writeFile(join(liveDir, 'agent-bbb.jsonl'), '{"type":"assistant","message":{"content":[]}}\n', 'utf-8');
// bbb starts BEFORE aaa, so journal launch order ['bbb','aaa'] differs from the
// old alphabetical sort ['aaa','bbb'] — the ordering assertion below is adversarial.
await writeFile(
join(liveDir, 'journal.jsonl'),
'{"type":"started","agentId":"bbb"}\n{"type":"started","agentId":"aaa"}\n{"type":"result","agentId":"aaa","result":{}}\n',
'utf-8'
);
// Persisted script — name from filename, summary/phases from the meta literal.
await writeFile(
join(scriptsDir, `my-cool-workflow-${RUN}.js`),
"export const meta = {\n name: 'my-cool-workflow',\n description: 'Audit and update the docs',\n phases: [ { title: 'Plan', detail: 'plan it' }, { title: 'Do', detail: 'do it' } ],\n}\n",
'utf-8'
);
watcher = new WorkflowRunWatcher(projectsDir);
});
afterEach(async () => {
watcher.stop();
await rm(projectsDir, { recursive: true, force: true });
});
function firstRun(): Promise<WorkflowRunInfo> {
return new Promise<WorkflowRunInfo>((resolve, reject) => {
const t = setTimeout(() => reject(new Error('timed out')), 5000);
watcher.once('run_discovered', (info: WorkflowRunInfo) => {
clearTimeout(t);
resolve(info);
});
watcher.start();
});
}
it('derives workflowName/summary/phases from the live script file', async () => {
const info = await firstRun();
expect(info.workflowName).toBe('my-cool-workflow');
expect(info.summary).toBe('Audit and update the docs');
expect(info.phases.map((p) => p.title)).toEqual(['Plan', 'Do']);
});
it('parses per-agent tokens (last usage-bearing message) + tool-call counts from the transcript', async () => {
const info = await firstRun();
const aaa = info.agents.find((a) => a.agentId === 'aaa')!;
expect(aaa.tokens).toBe(2580); // last turn: 2000 in + 500 cache + 80 out
expect(aaa.toolCalls).toBe(2); // Bash + Edit
expect(aaa.model).toBe('claude-opus-4-8');
expect(aaa.promptPreview).toBe('Audit the docs');
});
it('maps state to done (→green) / progress (→yellow) from the journal', async () => {
const info = await firstRun();
expect(info.agents.find((a) => a.agentId === 'aaa')!.state).toBe('done');
expect(info.agents.find((a) => a.agentId === 'bbb')!.state).toBe('progress');
});
it('orders agents by journal launch order (NOT alphabetical) and sums run-level totals', async () => {
const info = await firstRun();
// bbb started first in the journal though it sorts after aaa — launch order wins.
expect(info.agents.map((a) => a.agentId)).toEqual(['bbb', 'aaa']);
expect(info.agents[0].label).toBe('agent 1'); // = bbb, the first-launched
expect(info.totalToolCalls).toBe(2);
expect(info.totalTokens).toBe(2580);
});
});