diff --git a/.gitignore b/.gitignore index 669f6bd6..3ed63aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,14 @@ dist *.local .next *.vsix +.relay.env # Auto-generated extension/README.md scripts/.dev-relay.js scripts/.dev-relay.js.map +scripts/.remote-forwarder.js +scripts/.remote-forwarder.js.map app/dist/ next-env.d.ts *.tsbuildinfo diff --git a/README.md b/README.md index af79238c..62bac372 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,50 @@ Claude Code is powerful, but its execution is a black box — you see the final - **Live agent visualization**: Watch agent execution as an interactive node graph with real-time tool calls, branching, and return flows - **Claude Code + Codex**: Auto-detects sessions from both runtimes concurrently and shows them side-by-side, or restrict to one via the `agentVisualizer.runtime` setting - **Claude Code hooks**: Lightweight HTTP hook server receives events directly from Claude Code for zero-latency streaming +- **Hosted hybrid bridge**: Optional ephemeral Windows bridge forwards filtered local Claude/Codex session metadata to the VPS Office without installing a local service - **Codex rollout tailing**: Reads `~/.codex/sessions/**/rollout-*.jsonl` (respects `CODEX_HOME`) and surfaces tool calls, reasoning, and authoritative token counts from Codex's own event stream - **Multi-session support**: Track multiple concurrent agent sessions with tabs - **Interactive canvas**: Pan, zoom, click agents and tool calls to inspect details - **Timeline & transcript panels**: Review the full execution timeline, file attention heatmap, and message transcript - **JSONL log file support**: Point at any JSONL event log to replay or watch agent activity +- **Office mode (MVP)**: The visualizer opens in a privacy-scoped office view by default; the existing Graph view remains available as a reversible toggle +- **Office session roster**: Shows every known session, including sessions waiting for their first agent event, with live connection, runtime, origin, freshness, and session-scoped filters +- **Hybrid source fidelity**: Preserves bounded local/VPS and Claude/Codex metadata through the event bridge so background sessions remain identifiable without exposing prompts or transcripts + +## Office mode (MVP) + +Office is a read-only projection of the event stream already consumed by Agent +Flow. It places observed agents into evidence-backed work zones and shows +parent/child relationships only when a corresponding spawn or dispatch event +exists. It does not create a second source of truth or persist layout state. + +The projection discovers new Claude Code and Codex agents as events arrive. +Agent identities are deterministic and session-scoped, so equal names in +different sessions remain distinct and nested agents can be represented. Terra +and Luna receive their named avatar families; other and future model IDs are +kept as reported without a hard-coded model catalogue. + +Agents also receive a short work-based label (for example, `Seguridad · Terra` +or `Validador · Luna`). Labels come from a closed vocabulary inferred from +bounded work hints; prompts and task text are not copied into the UI. The +opaque agent ID remains available for exact identification. + +Office intentionally exposes only bounded names, model IDs, states, zones, and +relationship evidence. Prompts, transcript text, file paths, and tool +arguments are not passed to the Office view. The implementation is local and +read-only: it does not write to `.codex`. + +The Office roster also distinguishes an observed session from a decorative +standby character. A session card can exist before its first agent event; no +agent is fabricated for that state. Selecting a session filters the room view +and synchronizes the Graph session, while the global view keeps all observed +sessions available. Duplicate relay events with sequence numbers are ignored, +and unchanged background projections are cached so long-running offices do not +replay every session on each update. + +For the local/demo workflow, use the commands below and open the displayed +localhost URL. Office is the initial view; select **Graph** to return to the +original canvas, and **Office** to switch back. ## Getting Started @@ -53,6 +92,48 @@ pnpm run dev # start the web app + event relay Open http://localhost:3000 and start a Claude Code session in another terminal — events will stream to the browser in real-time. +The relay binds to localhost for the local workflow. It reads the existing +Claude Code and Codex event sources; it does not require a hosted service. + +### Hosted VPS Office + +For an internal office that remains available when a workstation is retired, +run the agents, relay, and Next.js server on the same VPS. The browser then +opens the launcher path (for example `/agent-flow/`) and receives the relay +over the same HTTPS origin; no local server or local transcript copy is +needed. + +The hosted build uses these public-build variables: + +```bash +NEXT_PUBLIC_BASE_PATH=/agent-flow +NEXT_PUBLIC_RELAY_URL=/agent-flow/events +NEXT_PUBLIC_DEMO=0 +``` + +To make the currently running local Claude/Codex sessions visible in that +hosted Office, run the ephemeral bridge from this repository (it does not +install a Windows service or local web server): + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\connect-hosted.ps1 +``` + +Stop it with `Ctrl+C` when local sessions no longer need to be shown. + +For a one-click launch, double-click `open-agent-flow-office.cmd` in the +repository root. It opens the hosted PWA and starts the temporary bridge; use +`stop-agent-flow-office.ps1` to disconnect it. + +The production units, reverse-proxy locations, PWA manifest, and network-only +service worker are in [`deploy/agent-flow/`](deploy/agent-flow/). Keep the +private web and relay listeners behind the launcher's existing authentication; +never publish the relay port directly or cache authenticated agent data. + +For a hybrid office that also receives bounded events from an existing local +Claude/Codex channel, see [`docs/hosted-hybrid-sessions.md`](docs/hosted-hybrid-sessions.md). +This uses the VPS as the aggregator and does not require a new Windows service. + ### VS Code Extension 1. Install the extension @@ -143,14 +224,23 @@ Created by [Simon Patole](https://github.com/patoles), for [CraftMyGame](https:/ ## Privacy & Telemetry -Agent Flow ships **opt-out** anonymous usage telemetry, enabled by default only -in the published `npx agent-flow-app` binary. `pnpm run dev` and the VS Code -extension emit nothing. Only aggregate events are sent — session count, -duration, event count, OS/arch, Agent Flow version, distinct model IDs -observed, which runtimes were watched, and error class names. Prompts, file -paths, tool calls, user info, and environment variables are never sent. +Telemetry is disabled by default for the Office MVP/local demo and creates no +install ID or telemetry directory. To opt in explicitly, set: + +```bash +AGENT_FLOW_TELEMETRY=true pnpm run dev +``` + +In PowerShell, use `$env:AGENT_FLOW_TELEMETRY="true"` before starting the +command. Only the explicit value `true` (case-insensitive) opts in; +`DO_NOT_TRACK=1` always disables telemetry, even when opt-in is set. +The same variables can be exported before using another local entry point. +When telemetry is enabled by a published entry point, only aggregate events +are sent; prompts, file paths, tool calls, user info, and environment variables +are not sent. Office itself has no telemetry path and does not write `.codex`. -- **Turn off:** `export AGENT_FLOW_TELEMETRY=false` or `export DO_NOT_TRACK=1` +- **Turn off:** unset `AGENT_FLOW_TELEMETRY` (the default) or use + `export DO_NOT_TRACK=1` (disabled installs write zero state to disk — no `~/.agent-flow/` directory) - **Inspect the payload:** `cat ~/.agent-flow/telemetry/events.jsonl` - **Full schema + exact fields:** see the v0.8.1 entry in @@ -159,6 +249,17 @@ paths, tool calls, user info, and environment variables are never sent. - **Reset your anonymous identity:** delete `~/.agent-flow/installation-id` — a fresh random UUIDv4 will be generated on next run +## MVP limitations and rollback + +Office states are derived only from events observed by the relay/parser. An +unseen event, an interrupted session, or an unfamiliar event type can leave an +agent as `unknown`, `stale`, or otherwise incomplete; the view does not infer +intent or claim a complete execution history. + +Rollback is reversible: close the local Office/Agent Flow process and reopen +the visualizer, then select **Graph**. No deployment or canary is implied by +this MVP documentation. + ## License diff --git a/app/src/server.ts b/app/src/server.ts index b4c8666f..ab6d9512 100644 --- a/app/src/server.ts +++ b/app/src/server.ts @@ -36,6 +36,10 @@ export async function startServer(options: ServerOptions) { return relay.handleSSE(req, res) } + if (req.url === '/ingest' && req.method === 'POST') { + return relay.handleIngest(req, res) + } + // Static files (UI) if (req.method === 'GET') { return serveStatic(req, res) diff --git a/deploy/agent-flow/README.md b/deploy/agent-flow/README.md new file mode 100644 index 00000000..78aeefc0 --- /dev/null +++ b/deploy/agent-flow/README.md @@ -0,0 +1,109 @@ +# Agent Flow Office on the VPS + +This is the production shape for the internal launcher/PWA. The Windows +checkout is only a development/review source; the runtime lives on the VPS. + +## Runtime contract + +- App root: `/home/discanary/apps/agent-flow-office` +- Watch root: `/home/discanary` so new Claude/Codex sessions and their subagents + under the VPS user's workspaces are incorporated automatically. +- Private web listener: `172.17.0.1:8612` (kept separate from the existing Mantenimiento service on `8610`) +- Private SSE relay: `172.17.0.1:3001` +- Public path: `/agent-flow/` on the already authenticated launcher host +- Browser SSE path: `/agent-flow/events` +- Workspace observed by the relay: `/home/discanary/apps/agent-flow-office` +- Claude and Codex session data remain on the VPS under the `discanary` account +- A future local source can submit bounded, authenticated lifecycle events to + `/agent-flow/ingest`; this is an optional hook/bridge channel, not a new + Windows service. The relay rejects the route until a token is configured. + +The relay must run on the same host as the agents. It is not a GitHub Actions +job and it is not a Windows process. + +For a pre-production preview, keep the same code and use a separate checkout, +path, and ports (for example `/agent-flow-preview/`, `8611`, and `3002`) with +`AGENT_FLOW_RELAY_PORT=3002`. Never point the preview proxy at the production +ports. + +## New agent onboarding contract + +The Office is event-driven, so adding a subagent to a supported Claude or Codex +session does not require a new page or another deployment. The runtime emits +the existing lifecycle events (`agent_spawn`, `model_detected`, +`subagent_dispatch`, and the normal tool or completion events). A future runtime +adapter can use the same contract. The projection keeps an opaque +session-scoped identity, derives a bounded work label, and gives an +unrecognised future model a stable generated avatar. Task text is not copied +into the Office view. This keeps new Terra, Luna, Codex, Claude, or future +model IDs visible without exposing their prompts or requiring a catalogue edit. + +## Build an approved release on the VPS + +Run only after the corresponding GitHub PR has been reviewed and the release +identity has been recorded: + +```bash +cd /home/discanary/apps/agent-flow-office +pnpm install --frozen-lockfile +node scripts/build-relay.js +NEXT_PUBLIC_BASE_PATH=/agent-flow \ +NEXT_PUBLIC_RELAY_URL=/agent-flow/events \ +NEXT_PUBLIC_DEMO=0 \ +pnpm --dir web build +``` + +The web build embeds the public path and SSE route. Do not use the development +server in production. + +## Services + +Install the two unit files in this directory, then perform a controlled +`daemon-reload`, enable, and start. The release operator must verify the +effective unit files before starting them; a merge alone is not deployment +evidence. + +```bash +sudo install -m 0644 deploy/agent-flow/agent-flow-relay.service /etc/systemd/system/ +sudo install -m 0644 deploy/agent-flow/agent-flow-web.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now agent-flow-relay.service agent-flow-web.service +``` + +The relay needs write access to `/home/discanary/.claude/agent-flow` for Claude +hook discovery. If that directory cannot be created, stop the release and fix +the VPS ownership/permissions; do not silently publish an office that cannot +observe the configured runtime. + +To enable the optional ingress, create an environment file owned by `discanary` +at `/home/discanary/apps/agent-flow-office/.relay.env` with mode `0600` and one +line: + +```text +AGENT_FLOW_INGEST_TOKEN= +``` + +Keep that value out of GitHub and out of the URL. The local runtime channel, +when available, sends `Authorization: Bearer ` to the public ingest +location documented below. + +## Nginx Proxy Manager route + +Create the route on the existing authenticated launcher host. Keep the private +ports inaccessible from the Internet and reuse the launcher's existing access +policy. The custom locations are documented in +`nginx-proxy-manager-locations.md`. + +After the proxy change, verify all of the following from an authenticated +browser session: + +1. `https:///agent-flow/` returns the Office UI. +2. `https:///agent-flow/healthz` returns the web service JSON. +3. The private relay `http://172.17.0.1:3001/healthz` returns the relay service JSON. +4. `/agent-flow/manifest.webmanifest` and `/agent-flow/sw.js` are reachable. +5. `/agent-flow/events` remains an open SSE stream (proxy buffering disabled). +6. A real VPS agent session appears with its role label and lifecycle events. +7. Reopening the page from another device still shows the remote office. + +Do not cache the authenticated HTML, SSE stream, transcripts, tool output, or +operational results in the PWA service worker. diff --git a/deploy/agent-flow/agent-flow-relay.service b/deploy/agent-flow/agent-flow-relay.service new file mode 100644 index 00000000..94a3fde9 --- /dev/null +++ b/deploy/agent-flow/agent-flow-relay.service @@ -0,0 +1,28 @@ +[Unit] +Description=Agent Flow Office event relay +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=discanary +Group=discanary +WorkingDirectory=/home/discanary/apps/agent-flow-office +Environment=HOME=/home/discanary +Environment=NODE_ENV=production +Environment=AGENT_FLOW_RUNTIME=auto +Environment=AGENT_FLOW_SOURCE=vps +Environment=AGENT_FLOW_HOST_ID=vps +EnvironmentFile=-/home/discanary/apps/agent-flow-office/.relay.env +Environment=AGENT_FLOW_RELAY_HOST=172.17.0.1 +ExecStart=/usr/bin/node /home/discanary/apps/agent-flow-office/scripts/.dev-relay.js /home/discanary +Restart=always +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=read-only +ReadWritePaths=/home/discanary/apps/agent-flow-office /home/discanary/.claude /home/discanary/.codex + +[Install] +WantedBy=multi-user.target diff --git a/deploy/agent-flow/agent-flow-web.service b/deploy/agent-flow/agent-flow-web.service new file mode 100644 index 00000000..fb8ee9b6 --- /dev/null +++ b/deploy/agent-flow/agent-flow-web.service @@ -0,0 +1,22 @@ +[Unit] +Description=Agent Flow Office web UI +After=network-online.target agent-flow-relay.service +Wants=network-online.target + +[Service] +Type=simple +User=discanary +Group=discanary +WorkingDirectory=/home/discanary/apps/agent-flow-office/web +Environment=NODE_ENV=production +ExecStart=/usr/bin/node /home/discanary/apps/agent-flow-office/web/node_modules/next/dist/bin/next start -H 172.17.0.1 -p 8612 +Restart=always +RestartSec=5 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=read-only +ReadWritePaths=/home/discanary/apps/agent-flow-office/.next + +[Install] +WantedBy=multi-user.target diff --git a/deploy/agent-flow/nginx-proxy-manager-locations.md b/deploy/agent-flow/nginx-proxy-manager-locations.md new file mode 100644 index 00000000..9573ea47 --- /dev/null +++ b/deploy/agent-flow/nginx-proxy-manager-locations.md @@ -0,0 +1,76 @@ +# Nginx Proxy Manager custom locations + +Attach these locations to the existing authenticated launcher host. Keep the +host's current access list/session policy enabled; this route is internal and +must not become an unauthenticated public service. + +Use `/agent-flow/` as the public path. The exact NPM form fields differ by +version, so preserve the existing host's SSL and access-list settings and add +the following locations: + +## Web UI + +```nginx +location = /agent-flow { + return 301 /agent-flow/; +} + +location /agent-flow/ { + auth_basic "Agent Flow Office"; + auth_basic_user_file /data/nginx/custom/agent-flow-office.htpasswd; + # Strip the public prefix; Next.js basePath is used for generated asset + # URLs, while the next-server itself serves the route at its root. + proxy_pass http://172.17.0.1:8612/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Prefix /agent-flow; + proxy_read_timeout 60s; +} +``` + +## SSE relay + +```nginx +location = /agent-flow/events { + proxy_pass http://172.17.0.1:3001/events; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + add_header Cache-Control no-cache; +} +``` + +## Local runtime ingest + +The local Claude/Codex integration sends only bounded lifecycle metadata to +this endpoint. It is protected by the relay's `AGENT_FLOW_INGEST_TOKEN`; do +not put the token in the URL or expose the private relay port directly. + +```nginx +location = /agent-flow/ingest { + proxy_pass http://172.17.0.1:3001/ingest; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Authorization $http_authorization; + proxy_set_header X-Agent-Flow-Token $http_x_agent_flow_token; + proxy_request_buffering off; + client_max_body_size 256k; + proxy_read_timeout 30s; +} +``` + +This route may be enabled before a local source is configured: without the +token the relay returns `503 ingest-disabled`, and without a valid token it +returns `401`. No Windows service is required. + +The private listeners must not be published directly. Test the proxy with +`nginx -t`/NPM's equivalent before reloading and then verify the authenticated +browser stream, not only an HTTP 200 response. diff --git a/deploy/agent-flow/nginx-proxy-manager-production.conf b/deploy/agent-flow/nginx-proxy-manager-production.conf new file mode 100644 index 00000000..9a7a2e03 --- /dev/null +++ b/deploy/agent-flow/nginx-proxy-manager-production.conf @@ -0,0 +1,37 @@ +# Internal Agent Flow Office route for the existing launcher host. +# Install this block in the NPM custom server config and create the referenced +# htpasswd file before reload. The public prefix is stripped before forwarding. +location = /agent-flow { + if ($host != "launcher.104-248-32-222.sslip.io") { return 404; } + auth_basic "Agent Flow Office"; + auth_basic_user_file /data/nginx/custom/agent-flow-office.htpasswd; + return 301 /agent-flow/; +} + +location = /agent-flow/events { + if ($host != "launcher.104-248-32-222.sslip.io") { return 404; } + auth_basic "Agent Flow Office"; + auth_basic_user_file /data/nginx/custom/agent-flow-office.htpasswd; + proxy_pass http://172.17.0.1:3001/events; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + add_header Cache-Control no-cache always; +} + +location ^~ /agent-flow/ { + if ($host != "launcher.104-248-32-222.sslip.io") { return 404; } + auth_basic "Agent Flow Office"; + auth_basic_user_file /data/nginx/custom/agent-flow-office.htpasswd; + proxy_pass http://172.17.0.1:8612/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Prefix /agent-flow; + proxy_read_timeout 60s; +} diff --git a/docs/OFFICE_MODE.md b/docs/OFFICE_MODE.md new file mode 100644 index 00000000..e5279972 --- /dev/null +++ b/docs/OFFICE_MODE.md @@ -0,0 +1,125 @@ +# Office mode + +Office mode is the default, read-only presentation introduced by the MVP. It +is a privacy-scoped projection over Agent Flow's existing Claude Code and Codex +events and materialized graph; it is not a replacement event store. + +## Local architecture + +The local relay/parser watches the existing Claude Code project transcripts and +Codex rollout JSONL files (honouring `CODEX_HOME` when set), then streams +observed events to the web UI over a localhost SSE connection. The Office +projection consumes only the small event/graph contract it needs: + +- bounded agent name, model ID, state/zone, session ID, and relationship evidence; +- no prompt or transcript payload, file path, or tool argument in the Office + projection; +- no Office persistence and no writes to `.codex`. + +The projection is pure and replayable. It can consume events or adapt the +already materialized Graph state. Unknown event types are ignored instead of +being converted into invented agent states. A stale decoration is applied +when the event clock has no recent observation (120 seconds by default). + +## Discovery, identity, and nesting + +New Claude Code and Codex sessions/agents are discovered automatically as the +local watchers observe them. Office IDs are deterministic opaque IDs derived +from the source agent ID and session ID. This keeps an identity stable across +render/replay while preventing same-named agents in separate sessions from +colliding. + +## Work-based names + +The UI assigns each agent a short role label from a closed Spanish vocabulary: +Orquestador, Seguridad, Validador, Investigador, Implementador, Documentador, +Diseñador, Integrador, Analista, or Especialista. The role is inferred from +bounded work hints such as a Codex `task_name`, a tool name, or the model family. +The original task/prompt is never copied into the label. The stable opaque ID +remains visible in the detail panel so agents with the same role are still +distinguishable. + +Nested agents are connected only by explicit `agent_spawn` or +`subagent_dispatch` evidence (or an existing Graph parent-child edge). Matching +names alone never create a parent/child relationship, and a parent that has +not yet been observed is not invented. + +## Models and states + +Model IDs are displayed as reported; there is no catalogue that blocks Terra, +Luna, or future providers/models. IDs containing Terra or Luna select the +corresponding avatar family. Other IDs use a deterministic generated avatar +key while preserving the original model ID. + +Zones mirror evidence-backed states such as planning, researching, editing, +executing, waiting for approval, blocked, completed, idle, unknown, and stale. +These are observations, not a claim about hidden reasoning or the full state +of a runtime. + +## Office controls + +The Office view remains read-only while making the observation easier to scan: + +- search by the bounded agent name or work label; +- filter by observed state or work role; +- zoom the floor between 85% and 130%; +- see room occupancy, relationship count, and a compact state legend; +- use the responsive list on narrow screens, with the same filters and selection. + +Filtering only changes the presentation. It does not alter the underlying graph, +dispatch work, or write to the watched workspaces. The **Graph** view remains +available as the technical topology view. + +## Privacy and localhost + +Office is local and read-only. It deliberately omits prompts, paths, and tool +arguments from its view contract, and it does not write `.codex`. Telemetry is +disabled by default for the MVP/demo, so no environment setting is required: + +```bash +pnpm run dev +``` + +To opt in, set `AGENT_FLOW_TELEMETRY=true`; `DO_NOT_TRACK=1` always prevails. +In PowerShell, use `$env:AGENT_FLOW_TELEMETRY="true"` before running the +command. + +The development relay and standalone UI use localhost (the relay's SSE endpoint +is bound to `127.0.0.1`). The hosted VPS profile is separate: the relay and +agents run on the VPS, the web UI is published under the launcher's protected +`/agent-flow/` path, and the browser connects to `/agent-flow/events` over the +same HTTPS origin. See `deploy/agent-flow/` for the service and proxy contract. + +## Start, demo, test, and build + +From the repository root: + +```bash +pnpm run dev +``` + +Open the reported `http://localhost:3000` URL. For a no-runtime visual check, +use `pnpm run dev:demo`. Useful verification commands are: + +```bash +pnpm run test +pnpm --filter agent-flow-web run build +pnpm run build:all +``` + +The focused Office projection tests live in +`web/lib/office/project-office.test.ts`; the commands above are the repository +test/build entry points and do not constitute a production deployment or +canary. + +## Rollback and honest limitations + +To roll back the presentation, close the local Agent Flow/Office process and +reopen the visualizer, then select **Graph**. Switching back to **Office** is +equally reversible; no data migration is required. + +States and relationships are based only on events actually observed by the +watcher/parser. Missing, delayed, interrupted, or unfamiliar events can leave +an agent unknown, stale, or incomplete. Office must not be read as proof of +intent, hidden work, or a complete execution history. No 30-minute canary or +deployment is claimed here because neither was executed as part of this MVP. diff --git a/docs/hosted-hybrid-sessions.md b/docs/hosted-hybrid-sessions.md new file mode 100644 index 00000000..87cf6f2e --- /dev/null +++ b/docs/hosted-hybrid-sessions.md @@ -0,0 +1,81 @@ +# Hosted hybrid sessions + +The production Office can aggregate sessions observed on the VPS and events +forwarded by an existing local runtime channel. The VPS remains the only +public source of truth; no local Agent Flow server is required. + +## Ingest contract + +Send a `POST` to `/agent-flow/ingest` with `Authorization: Bearer +`: + +```json +{ + "source": "local", + "hostId": "windows-main", + "runtime": "claude", + "session": { + "id": "session-id", + "label": "Local Claude session", + "status": "active" + }, + "events": [ + { + "time": 12.4, + "sequence": 7, + "type": "agent_spawn", + "sessionId": "session-id", + "payload": { + "name": "Terra", + "model": "Terra", + "isMain": true + } + } + ] +} +``` + +Only bounded lifecycle metadata is forwarded. Prompt text, task descriptions, +tool arguments, file paths, and tool results are deliberately removed at the +relay boundary. Events are namespaced by origin, host, runtime, and session so +equal local/VPS identifiers never collide. + +## Runtime sources + +- VPS Claude/Codex sessions continue to be watched directly from their JSONL + sources. +- A local Claude hook or existing Codex bridge may use this endpoint if that + channel is already available. +- A new Windows daemon, server, or Agent Flow installation is not required. +- If a runtime exposes no outgoing hook or bridge, it cannot be observed live + under the no-local-service constraint; the Office will not fabricate it. + +## Ephemeral Windows bridge + +The repository includes `scripts/connect-hosted.ps1` for the local Claude/Codex +apps. It obtains the ingest token through the existing VPS SSH access, keeps it +only in the process environment, and starts a temporary filesystem watcher. +It does not install a Windows service, scheduled task, or resident web server. + +Run it from the repository folder and stop it with `Ctrl+C`: + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\connect-hosted.ps1 +``` + +The bridge watches all recent local sessions by default and forwards only +bounded role/model/tool metadata. Prompts, file paths, arguments, tool results, +and task text are removed before the HTTP request is made. If the bridge is +not running, the hosted Office continues to show VPS sessions normally. + +For a launcher-style workflow, double-click `open-agent-flow-office.cmd` in the +repository root. It opens the hosted PWA and starts one bridge instance; a +second click reuses the existing connection. Run `stop-agent-flow-office.ps1` +when local sessions should stop being forwarded. + +## Expected response + +The relay returns `202` with the number of accepted events. Duplicate events +are ignored using their source, session, sequence, type, and bounded payload. +The endpoint returns `503` until a token is configured on the VPS and `401` +for an invalid token. diff --git a/extension/src/codex-rollout-parser.ts b/extension/src/codex-rollout-parser.ts index 60c7ab74..973ccd47 100644 --- a/extension/src/codex-rollout-parser.ts +++ b/extension/src/codex-rollout-parser.ts @@ -26,14 +26,18 @@ * only. event_msg.exec_command_end / patch_apply_end are * parallel signals and are skipped. * - * Subagents: Codex does not currently expose subagent spawning in rollouts. - * The parser emits a single orchestrator; if Codex adds spawn_agent / wait_agent - * in future, add mapping here. + * Collaboration calls are represented as ordinary Responses `function_call` + * items. The important detail is that the arguments are not a tool payload: + * `spawn_agent` takes `{ task_name, message, ... }`, while its successful + * result is either `{ task_name }` (older rollouts) or `{ agent_id, nickname }` + * (newer rollouts). Messages and prompts are deliberately never copied into + * agent events; only identity metadata is surfaced. */ import { AgentEvent } from './protocol' import { ORCHESTRATOR_NAME, HASH_PREFIX_MAX, MESSAGE_MAX, PREVIEW_MAX, RESULT_MAX, + SESSION_ID_DISPLAY, SYSTEM_PROMPT_BASE_TOKENS, SYSTEM_CONTENT_PREFIXES, } from './constants' import { @@ -67,6 +71,10 @@ export interface CodexRolloutState { model: string | null /** Cwd from session_meta, updated by turn_context. */ cwd: string | null + /** Session/thread id from session_meta, when present. */ + sessionId: string | null + /** Parent thread id from session_meta, when this is a nested rollout. */ + parentThreadId: string | null /** Label for the session (set from first non-system user message). */ label: string | null /** Pending tool calls, keyed by call_id. */ @@ -83,12 +91,22 @@ export interface CodexRolloutState { lastReportedTokens: number | null /** Authoritative model_context_window from event_msg.token_count, if any. */ reportedContextWindow: number | null + /** Collaboration calls waiting for their function_call_output. */ + pendingCollaborationCalls: Map + /** Reliable child identities observed in successful spawn results. */ + subagentIdentities: Map + /** Stable ids for which spawn events have already been emitted. */ + emittedSubagentIds: Set + /** Dispatch/return de-duplication keys. */ + emittedCollaborationEvents: Set } export function createCodexRolloutState(): CodexRolloutState { return { model: null, cwd: null, + sessionId: null, + parentThreadId: null, label: null, pendingToolCalls: new Map(), seenMessageHashes: new Set(), @@ -103,6 +121,10 @@ export function createCodexRolloutState(): CodexRolloutState { lastEmittedModel: null, lastReportedTokens: null, reportedContextWindow: null, + pendingCollaborationCalls: new Map(), + subagentIdentities: new Map(), + emittedSubagentIds: new Set(), + emittedCollaborationEvents: new Set(), } } @@ -115,6 +137,23 @@ export interface CodexParserDelegate { elapsed(): number /** Called when a session label is derived from the first user message. */ setLabel?(label: string): void + /** + * The stable visual identity assigned by the watcher after it has resolved + * the rollout parent graph. It is deliberately metadata-only: rollout + * prompts and task text are never used as agent names. + */ + principal?: CodexRolloutPrincipal +} + +/** The visual identity for one rollout inside its Codex thread family. */ +export interface CodexRolloutPrincipal { + id: string + name: string + isMain: boolean + parentId?: string + /** A missing-but-observed root thread. The watcher de-duplicates this event + * across every child rollout in the same family. */ + rootPlaceholder?: { id: string; name: string } } // ─── Record shapes (structural typing, all fields optional) ──────────────── @@ -142,7 +181,7 @@ interface FunctionCallPayload { interface FunctionCallOutputPayload { call_id?: string - output?: string | { output?: string; metadata?: unknown } + output?: unknown } interface CustomToolCallPayload { @@ -153,7 +192,7 @@ interface CustomToolCallPayload { interface CustomToolCallOutputPayload { call_id?: string - output?: string | { output?: string; metadata?: unknown } + output?: unknown } interface WebSearchCallPayload { @@ -163,11 +202,47 @@ interface WebSearchCallPayload { interface SessionMetaPayload { id?: string + session_id?: string + parent_thread_id?: string cwd?: string cli_version?: string base_instructions?: { text?: string } } +type CollaborationToolName = 'spawn_agent' | 'send_message' | 'followup_task' | 'wait_agent' + +interface PendingCollaborationCall { + name: CollaborationToolName + taskName?: string + target?: string + targets?: string[] + model?: string +} + +interface CodexSubagentIdentity { + id: string + name: string + task?: string + model?: string + /** Fixed role key inferred from task_name; task text is never emitted. */ + workRole?: string + parentId: string +} + +function codexWorkRole(hint: string | undefined): string { + const value = (hint || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().slice(0, 256) + if (/orchestrat|coordina|supervis|dispatch|delegat|manager/.test(value)) return 'orchestrator' + if (/security|seguridad|secure|vulnerab|auth|permission|secret|threat|audit/.test(value)) return 'security' + if (/test|qa|validat|verif|check|lint|review|regression|quality|prueba/.test(value)) return 'validator' + if (/research|investig|search|browse|explor|discover|documentacion|docs?\b|read|grep|find/.test(value)) return 'researcher' + if (/implement|build|code|develop|edit|write|patch|fix|refactor|feature|program/.test(value)) return 'implementer' + if (/document|readme|guide|manual|changelog/.test(value)) return 'documenter' + if (/design|diseñ|visual|ui|ux|frontend|css|layout/.test(value)) return 'designer' + if (/deploy|release|publish|github|merge|integrat|pr\b|pipeline|ship/.test(value)) return 'integrator' + if (/analys|analiz|inspect|diagnos|metrics|kpi|report/.test(value)) return 'analyst' + return 'specialist' +} + interface TurnContextPayload { model?: string cwd?: string @@ -236,6 +311,46 @@ function parseArgsJson(raw: string | undefined): Record | undef } catch { return undefined } } +function isCollaborationTool(name: string): name is CollaborationToolName { + return name === 'spawn_agent' || name === 'send_message' || name === 'followup_task' || name === 'wait_agent' +} + +/** Parse a tool result without exposing its text to the event stream. */ +function parseStructuredOutput(raw: unknown): unknown { + let value = raw + for (let i = 0; i < 2; i++) { + if (isRecord(value) && 'output' in value) { + value = value.output + continue + } + if (typeof value === 'string') { + try { + value = JSON.parse(value) + continue + } catch { /* plain result text */ } + } + break + } + return value +} + +function safeIdentityPart(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined + const value = raw.trim() + // IDs/nicknames are metadata. Do not turn arbitrary prompt text into an id. + if (!value || value.length > 256 || /[\r\n]/.test(value)) return undefined + return value +} + +function collaborationReference(raw: unknown): string | undefined { + const direct = safeIdentityPart(raw) + if (direct) return direct + if (!isRecord(raw)) return undefined + return safeIdentityPart( + raw.agent_id || raw.id || raw.thread_id || raw.nickname || raw.name || raw.task_name, + ) +} + /** Extract an output string from a function_call_output payload. * Codex sometimes encodes output as a JSON object with nested .output. */ function extractOutputString(raw: FunctionCallOutputPayload['output']): string { @@ -271,18 +386,23 @@ export class CodexRolloutParser { try { record = JSON.parse(trimmed) as RolloutRecord } catch { return /* partial line at file tail; resume on next read */ } - this.ensureSpawned(state) - switch (record.type) { case 'session_meta': - return this.handleSessionMeta(record.payload as SessionMetaPayload, state) + this.handleSessionMeta(record.payload as SessionMetaPayload, state, false) + this.ensureSpawned(state) + this.emitContextUpdate(state) + return case 'turn_context': + this.ensureSpawned(state) return this.handleTurnContext(record.payload as TurnContextPayload, state) case 'response_item': + this.ensureSpawned(state) return this.handleResponseItem(record.payload, state) case 'event_msg': + this.ensureSpawned(state) return this.handleEventMsg(record.payload, state) case 'compacted': + this.ensureSpawned(state) return this.handleCompacted(record.payload as CompactedPayload, state) // Ignore unknown types (forward-compatible). } @@ -293,17 +413,66 @@ export class CodexRolloutParser { private ensureSpawned(state: CodexRolloutState): void { if (state.spawnEmitted) return state.spawnEmitted = true + const principal = this.principalFor(state) + if (principal.rootPlaceholder) { + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'agent_spawn', + payload: { + id: principal.rootPlaceholder.id, + name: principal.rootPlaceholder.name, + isMain: true, + task: 'Observed parent session', + runtime: 'codex', + placeholder: true, + }, + }) + } this.delegate.emit({ time: this.delegate.elapsed(), type: 'agent_spawn', - payload: { name: ORCHESTRATOR_NAME, isMain: true, task: 'Codex session', runtime: 'codex' }, + payload: { + id: principal.id, + name: principal.name, + ...(principal.parentId ? { parentId: principal.parentId } : {}), + isMain: principal.isMain, + ...(principal.isMain ? { task: 'Codex session' } : {}), + runtime: 'codex', + }, }) } + /** Use watcher-resolved graph metadata when available. The fallback keeps + * standalone parser use safe: a child rollout creates an observed-parent + * placeholder and is never promoted to the main agent. */ + private principalFor(state: CodexRolloutState): CodexRolloutPrincipal { + if (this.delegate.principal) return this.delegate.principal + const id = state.sessionId || ORCHESTRATOR_NAME + const parentId = state.parentThreadId || undefined + if (!parentId) { + return { id, name: ORCHESTRATOR_NAME, isMain: true } + } + return { + id, + name: `Codex agent ${id.slice(0, SESSION_ID_DISPLAY)}`, + isMain: false, + parentId, + rootPlaceholder: { id: parentId, name: ORCHESTRATOR_NAME }, + } + } + // ─── session_meta ──────────────────────────────────────────────────────── - private handleSessionMeta(payload: SessionMetaPayload | undefined, state: CodexRolloutState): void { + private handleSessionMeta( + payload: SessionMetaPayload | undefined, + state: CodexRolloutState, + emitUpdate = true, + ): void { if (!payload) return + const sessionId = safeIdentityPart(payload.id || payload.session_id) + if (sessionId) state.sessionId = sessionId + const parentThreadId = safeIdentityPart(payload.parent_thread_id) + if (parentThreadId) state.parentThreadId = parentThreadId if (typeof payload.cwd === 'string') state.cwd = payload.cwd // Estimate system-prompt tokens from base_instructions if present const sys = payload.base_instructions?.text @@ -313,7 +482,7 @@ export class CodexRolloutParser { SYSTEM_PROMPT_BASE_TOKENS, ) } - this.emitContextUpdate(state) + if (emitUpdate) this.emitContextUpdate(state) } // ─── turn_context ──────────────────────────────────────────────────────── @@ -327,7 +496,7 @@ export class CodexRolloutParser { this.delegate.emit({ time: this.delegate.elapsed(), type: 'model_detected', - payload: { agent: ORCHESTRATOR_NAME, model: payload.model }, + payload: { agent: this.currentAgentId(state), model: payload.model }, }) } } @@ -349,7 +518,7 @@ export class CodexRolloutParser { case 'custom_tool_call_output': return this.handleCustomToolCallOutput(payload as CustomToolCallOutputPayload, state) case 'web_search_call': - return this.handleWebSearchCall(payload as WebSearchCallPayload) + return this.handleWebSearchCall(payload as WebSearchCallPayload, state) // `reasoning` items carry encrypted_content + a short summary; we emit // plaintext reasoning from event_msg.agent_reasoning instead. } @@ -383,7 +552,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'message', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), role, content: text.slice(0, MESSAGE_MAX), }, @@ -397,6 +566,10 @@ export class CodexRolloutParser { if (!callId) return const args = parseArgsJson(payload.arguments) + if (isCollaborationTool(name)) { + this.handleCollaborationCall(name, args, callId, state) + return + } const argsSummary = summarizeInput(name, args) const filePath = extractFilePath(args) @@ -408,7 +581,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_start', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: name, args: argsSummary, preview: `${name}: ${argsSummary}`.slice(0, PREVIEW_MAX), @@ -420,6 +593,12 @@ export class CodexRolloutParser { private handleFunctionCallOutput(payload: FunctionCallOutputPayload, state: CodexRolloutState): void { const callId = payload.call_id if (!callId) return + const collaboration = state.pendingCollaborationCalls.get(callId) + if (collaboration) { + state.pendingCollaborationCalls.delete(callId) + this.handleCollaborationOutput(collaboration, payload.output, state) + return + } const pending = state.pendingToolCalls.get(callId) if (!pending) return state.pendingToolCalls.delete(callId) @@ -436,7 +615,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_end', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: pending.name, result: resultSummary, tokenCost, @@ -465,7 +644,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_start', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: name, args: argsSummary, preview: `${name}: ${argsSummary}`.slice(0, PREVIEW_MAX), @@ -479,7 +658,179 @@ export class CodexRolloutParser { this.handleFunctionCallOutput(payload, state) } - private handleWebSearchCall(payload: WebSearchCallPayload): void { + private currentAgentId(state: CodexRolloutState): string { + return this.delegate.principal?.id || state.sessionId || ORCHESTRATOR_NAME + } + + /** + * Track a collaboration call and emit only a safe, metadata-only tool card. + * Real Codex rollouts contain the complete child prompt in `message`; it is + * intentionally not passed to summarizeInput, extractInputData, or events. + */ + private handleCollaborationCall( + name: CollaborationToolName, + args: Record | undefined, + callId: string, + state: CodexRolloutState, + ): void { + const taskName = safeIdentityPart(args?.task_name) + const target = collaborationReference(args?.target) + const targets = Array.isArray(args?.targets) + ? args!.targets.map(collaborationReference).filter((x): x is string => !!x) + : undefined + const model = safeIdentityPart(args?.model) + state.pendingCollaborationCalls.set(callId, { + name, taskName, target, targets, model, + }) + + const safeArgs = taskName || target || (targets && targets.length > 0 ? `${targets.length} targets` : '') || name + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'tool_call_start', + payload: { + agent: this.currentAgentId(state), + tool: name, + args: safeArgs.slice(0, PREVIEW_MAX), + preview: `${name}: ${safeArgs}`.slice(0, PREVIEW_MAX), + }, + }) + } + + private handleCollaborationOutput( + call: PendingCollaborationCall, + rawOutput: unknown, + state: CodexRolloutState, + ): void { + const structured = parseStructuredOutput(rawOutput) + const output = typeof structured === 'string' ? structured : '' + const outputRecord = isRecord(structured) ? structured : undefined + + // Keep collaboration tool cards balanced without reflecting result text. + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'tool_call_end', + payload: { + agent: this.currentAgentId(state), + tool: call.name, + result: 'completed', + tokenCost: 0, + }, + }) + + if (call.name === 'spawn_agent') { + const child = this.identityFromSpawn(call, outputRecord, state) + if (child) this.emitSubagent(child, state) + return + } + + const references = call.targets ?? (call.target ? [call.target] : []) + if (references.length === 0) return // wait_agent(timeout_ms) has no child identity + for (const reference of references) { + const child = this.resolveSubagent(reference, state) + if (!child) continue + if (call.name === 'wait_agent') { + this.emitSubagentReturn(child, state) + } else { + this.emitSubagentDispatch(child, state, call.name) + } + } + } + + private identityFromSpawn( + call: PendingCollaborationCall, + output: Record | undefined, + state: CodexRolloutState, + ): CodexSubagentIdentity | undefined { + // New schema: successful output has an opaque stable id and nickname. + const id = safeIdentityPart(output?.agent_id || output?.id || output?.thread_id) + // Do not promote task_name (or any task text) to an agent name. A missing + // nickname still has a stable, opaque-id-derived display name. + if (!id) return undefined + const name = safeIdentityPart(output?.nickname || output?.name) + || `Codex agent ${id.slice(0, SESSION_ID_DISPLAY)}` + const stableId = id + const parentId = safeIdentityPart(output?.parent_id || output?.parentId || output?.parent) || this.currentAgentId(state) + return { + id: stableId, + name, + parentId, + ...((safeIdentityPart(output?.model) || call.model) + ? { model: safeIdentityPart(output?.model) || call.model } + : {}), + workRole: codexWorkRole(call.taskName || safeIdentityPart(output?.role)), + } + } + + private resolveSubagent(reference: string, state: CodexRolloutState): CodexSubagentIdentity | undefined { + const byId = state.subagentIdentities.get(reference) + if (byId) return byId + const byName = Array.from(state.subagentIdentities.values()).filter(child => child.name === reference) + // A nominal target is safe only when it resolves to exactly one stable id. + // Unknown and homonymous targets must not create a fabricated relation. + return byName.length === 1 ? byName[0] : undefined + } + + private emitSubagent(child: CodexSubagentIdentity, state: CodexRolloutState): void { + if (state.emittedSubagentIds.has(child.id)) return + state.emittedSubagentIds.add(child.id) + state.subagentIdentities.set(child.id, child) + this.emitSubagentDispatch(child, state, 'spawn_agent') + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'agent_spawn', + payload: { + id: child.id, + name: child.name, + parentId: child.parentId, + parent: this.parentName(child.parentId, state), + isMain: false, + task: child.task || child.name, + ...(child.model ? { model: child.model } : {}), + ...(child.workRole ? { workRole: child.workRole } : {}), + runtime: 'codex', + }, + }) + } + + private emitSubagentDispatch(child: CodexSubagentIdentity, state: CodexRolloutState, operation: string): void { + const key = `dispatch:${operation}:${child.id}` + if (state.emittedCollaborationEvents.has(key)) return + state.emittedCollaborationEvents.add(key) + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'subagent_dispatch', + payload: { + parent: this.parentName(child.parentId, state), + parentId: child.parentId, + child: child.name, + childId: child.id, + task: operation === 'spawn_agent' ? (child.task || child.name) : operation, + }, + }) + } + + private emitSubagentReturn(child: CodexSubagentIdentity, state: CodexRolloutState): void { + const key = `return:${child.id}` + if (state.emittedCollaborationEvents.has(key)) return + state.emittedCollaborationEvents.add(key) + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'subagent_return', + payload: { + parent: this.parentName(child.parentId, state), + parentId: child.parentId, + child: child.name, + childId: child.id, + summary: 'completed', + }, + }) + } + + private parentName(parentId: string, state: CodexRolloutState): string { + return state.subagentIdentities.get(parentId)?.name || parentId + } + + private handleWebSearchCall(payload: WebSearchCallPayload, state: CodexRolloutState): void { const query = String(payload.action?.query || '') if (!query) return // Web search is self-contained — emit start + end together. @@ -487,7 +838,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_start', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: 'WebSearch', args: query, preview: `WebSearch: ${query}`.slice(0, PREVIEW_MAX), @@ -498,7 +849,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_end', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: 'WebSearch', result: payload.status || 'completed', tokenCost: 0, @@ -572,7 +923,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'message', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), role: 'thinking', content: text.slice(0, MESSAGE_MAX), }, @@ -623,7 +974,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'context_update', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tokens, breakdown: { ...bd }, ...(state.reportedContextWindow ? { tokensMax: state.reportedContextWindow } : {}), diff --git a/extension/src/codex-session-watcher.ts b/extension/src/codex-session-watcher.ts index 0c227a4d..79bfbe00 100644 --- a/extension/src/codex-session-watcher.ts +++ b/extension/src/codex-session-watcher.ts @@ -23,6 +23,7 @@ import { readNewFileLines } from './fs-utils' import { createLogger } from './logger' import { CodexRolloutParser, CodexRolloutState, createCodexRolloutState, + type CodexRolloutPrincipal, } from './codex-rollout-parser' import type { AgentSessionWatcher, SessionLifecycleEvent } from './session-runtime' import { TypedEventEmitter } from './typed-event-emitter' @@ -39,6 +40,10 @@ const SESSION_ID_FROM_FILENAME = /rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([ interface WatchedCodexSession { sessionId: string + /** Stable root id shared by every rollout in one Codex thread family. */ + groupSessionId: string + agentName: string + isMain: boolean filePath: string fileWatcher: fs.FSWatcher | null pollTimer: NodeJS.Timeout | null @@ -56,6 +61,66 @@ interface WatchedCodexSession { parser: CodexRolloutParser } +/** Metadata available in the first session_meta record. Kept separately from + * live tail state so discovery can resolve a whole parent graph before parsing + * any rollout content. */ +interface CodexSessionMetadata { + sessionId: string + parentThreadId: string | null + cwd: string | null + filePath: string + stat: fs.Stats +} + +/** Minimal metadata needed to resolve a Codex rollout family. Exported so + * callers and tests can validate grouping without starting filesystem watches. */ +export interface CodexThreadMetadata { + sessionId: string + parentThreadId: string | null +} + +/** + * Resolve every rollout id to one stable family/root id using only explicit + * parent_thread_id metadata. A known parent with no rollout is retained as the + * conservative root; malformed cycles use their lowest observed id so every + * member still lands in one deterministic family. + */ +export function resolveCodexThreadGroups( + records: Iterable, +): Map { + const byId = new Map() + for (const record of records) byId.set(record.sessionId, record) + + const rootFor = (sessionId: string): string => { + const chain: string[] = [] + let current = sessionId + while (true) { + const cycleAt = chain.indexOf(current) + if (cycleAt >= 0) return chain.slice(cycleAt).sort()[0] + chain.push(current) + const metadata = byId.get(current) + if (!metadata?.parentThreadId) return current + const parentId = metadata.parentThreadId + if (!byId.has(parentId)) return parentId + current = parentId + } + } + + const groups = new Map() + for (const id of byId.keys()) groups.set(id, rootFor(id)) + return groups +} + +/** One visual session, which may be backed by several rollout files. */ +interface CodexSessionGroup { + sessionId: string + label: string + lifecycleEnded: boolean + completionEmitted: boolean + /** agent_spawn is idempotent at the group boundary, including placeholders. */ + emittedAgentIds: Set +} + function codexHome(): string { return process.env.CODEX_HOME || path.join(os.homedir(), '.codex') } @@ -87,7 +152,15 @@ function recentSessionDirs(now: Date): string[] { return Array.from(seen) } -/** Read the first line of a rollout file to extract cwd from session_meta. +function safeMetadataId(value: unknown): string | null { + if (typeof value !== 'string') return null + const normalized = value.trim() + return normalized && normalized.length <= 256 && !/[\r\n]/.test(normalized) + ? normalized + : null +} + +/** Read the first line of a rollout file to extract session_meta metadata. * * UTF-8 safety: `\n` is 0x0a, which never appears as a continuation byte in * a multi-byte UTF-8 sequence (continuation bytes are 0x80–0xBF), so slicing @@ -98,7 +171,7 @@ function recentSessionDirs(now: Date): string[] { * it far larger — keep reading in chunks until the first newline, up to a * 1MB cap. Past the cap we give up: JSON.parse fails on the truncated object * and we return null rather than emit a corrupted cwd. */ -function readSessionCwd(filePath: string): string | null { +function readSessionMetadata(filePath: string, fallbackSessionId: string): Pick | null { const CHUNK_SIZE = 65536 const MAX_FIRST_LINE = 1048576 try { @@ -121,9 +194,17 @@ function readSessionCwd(filePath: string): string | null { } const data = Buffer.concat(chunks) const line = data.subarray(0, end >= 0 ? end : data.length).toString('utf-8') - const parsed = JSON.parse(line) as { type?: string; payload?: { cwd?: string } } - if (parsed.type !== 'session_meta') return null - return typeof parsed.payload?.cwd === 'string' ? parsed.payload.cwd : null + const parsed = JSON.parse(line) as { + type?: string + payload?: { id?: unknown; session_id?: unknown; parent_thread_id?: unknown; cwd?: unknown } + } + if (parsed.type !== 'session_meta') return null + const payload = parsed.payload + return { + sessionId: safeMetadataId(payload?.id) || safeMetadataId(payload?.session_id) || fallbackSessionId, + parentThreadId: safeMetadataId(payload?.parent_thread_id), + cwd: typeof payload?.cwd === 'string' ? payload.cwd : null, + } } finally { fs.closeSync(fd) } } catch { return null } } @@ -133,6 +214,10 @@ function readSessionCwd(filePath: string): string | null { export class CodexSessionWatcher implements AgentSessionWatcher { private dirWatchers = new Map() private sessions = new Map() + /** All eligible rollout metadata seen during this watcher's lifetime. */ + private sessionMetadata = new Map() + /** Visual sessions keyed by their resolved root thread id. */ + private groups = new Map() private workspacePath: string | null = null private scanInterval: NodeJS.Timeout | null = null /** One-shot flag so the cwd-mismatch hint is logged at most once per process. */ @@ -159,25 +244,23 @@ export class CodexSessionWatcher implements AgentSessionWatcher { } isSessionActive(sessionId: string): boolean { + const group = this.groups.get(sessionId) + if (group) return this.groupIsActive(group.sessionId) const s = this.sessions.get(sessionId) return !!s && s.sessionDetected && !s.sessionCompleted } getActiveSessions(): SessionInfo[] { - return Array.from(this.sessions.values()).map(s => ({ - id: s.sessionId, - label: s.label, - status: s.sessionCompleted ? 'completed' : 'active', - startTime: s.sessionStartTime, - lastActivityTime: s.lastActivityTime, - })) + return Array.from(this.groups.values()) + .map(group => this.sessionInfoForGroup(group)) + .filter((info): info is SessionInfo => info !== null) } replaySessionStart(sessionIds?: string[]): void { - for (const [id, session] of this.sessions) { - if (!session.sessionDetected) continue - if (sessionIds && !sessionIds.includes(id)) continue - this._onSessionLifecycle.fire({ type: 'started', sessionId: id, label: session.label }) + for (const group of this.groups.values()) { + if (!this.groupHasDetectedSession(group.sessionId)) continue + if (sessionIds && !sessionIds.includes(group.sessionId)) continue + this._onSessionLifecycle.fire({ type: 'started', sessionId: group.sessionId, label: group.label }) } } @@ -205,6 +288,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { private scanForSessions(): void { const now = new Date() let skippedByCwd = 0 + const candidates = new Map() for (const dir of recentSessionDirs(now)) { if (!fs.existsSync(dir)) continue @@ -217,13 +301,12 @@ export class CodexSessionWatcher implements AgentSessionWatcher { } let entries: string[] - try { entries = fs.readdirSync(dir) } + try { entries = fs.readdirSync(dir).sort() } catch { continue } for (const name of entries) { if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue const filePath = path.join(dir, name) - if (this.sessions.has(this.sessionIdFor(filePath))) continue // Recency filter — skip stale files let stat: fs.Stats @@ -232,9 +315,12 @@ export class CodexSessionWatcher implements AgentSessionWatcher { const ageS = (Date.now() - stat.mtimeMs) / 1000 if (ageS > ACTIVE_SESSION_AGE_S) continue + const metadata = readSessionMetadata(filePath, this.sessionIdFor(filePath)) + if (!metadata) continue + // Workspace filter — only attach if cwd matches (or no workspace set) if (this.workspacePath) { - const cwd = readSessionCwd(filePath) + const cwd = metadata.cwd if (cwd === null) continue const resolvedCwd = this.resolvePath(cwd) if (!resolvedCwd || !this.pathMatchesWorkspace(resolvedCwd)) { @@ -243,10 +329,39 @@ export class CodexSessionWatcher implements AgentSessionWatcher { } } - this.attachSession(filePath, stat) + const discovered: CodexSessionMetadata = { ...metadata, filePath, stat } + this.sessionMetadata.set(discovered.sessionId, discovered) + if (!this.sessions.has(discovered.sessionId)) candidates.set(discovered.sessionId, discovered) } } + // Discovery is intentionally two-phase. A child file can sort before its + // root (or a grandchild before its parent); resolve every observed parent + // edge before attaching a parser so all events share the same group id. + const groupIds = resolveCodexThreadGroups(this.sessionMetadata.values()) + const depthFor = (metadata: CodexSessionMetadata): number => { + let depth = 0 + let current = metadata + const seen = new Set() + while (current.parentThreadId && !seen.has(current.sessionId)) { + seen.add(current.sessionId) + depth++ + const parent = this.sessionMetadata.get(current.parentThreadId) + if (!parent) break + current = parent + } + return depth + } + const ordered = Array.from(candidates.values()).sort((a, b) => { + const groupOrder = (groupIds.get(a.sessionId) || a.sessionId).localeCompare(groupIds.get(b.sessionId) || b.sessionId) + if (groupOrder !== 0) return groupOrder + const depthOrder = depthFor(a) - depthFor(b) + return depthOrder !== 0 ? depthOrder : a.sessionId.localeCompare(b.sessionId) + }) + for (const metadata of ordered) { + this.attachSession(metadata, groupIds.get(metadata.sessionId) || metadata.sessionId) + } + // Recent Codex activity exists but none of it belongs to this workspace — // the #1 reason users see no Codex events. Say so once, loudly enough to // survive the standalone app's default log level. @@ -280,28 +395,111 @@ export class CodexSessionWatcher implements AgentSessionWatcher { return candidate.startsWith(workspace + path.sep) } - private attachSession(filePath: string, stat: fs.Stats): void { - const sessionId = this.sessionIdFor(filePath) + private ensureGroup(groupSessionId: string): CodexSessionGroup { + let group = this.groups.get(groupSessionId) + if (!group) { + group = { + sessionId: groupSessionId, + label: `Codex ${groupSessionId.slice(0, SESSION_ID_DISPLAY)}`, + lifecycleEnded: false, + completionEmitted: false, + emittedAgentIds: new Set(), + } + this.groups.set(groupSessionId, group) + } + return group + } + + private sessionsInGroup(groupSessionId: string): WatchedCodexSession[] { + return Array.from(this.sessions.values()).filter(session => session.groupSessionId === groupSessionId) + } + + private groupIsActive(groupSessionId: string): boolean { + return this.sessionsInGroup(groupSessionId).some(session => session.sessionDetected && !session.sessionCompleted) + } + + private groupHasDetectedSession(groupSessionId: string): boolean { + return this.sessionsInGroup(groupSessionId).some(session => session.sessionDetected) + } + + private sessionInfoForGroup(group: CodexSessionGroup): SessionInfo | null { + const sessions = this.sessionsInGroup(group.sessionId) + if (sessions.length === 0) return null + return { + id: group.sessionId, + label: group.label, + status: this.groupIsActive(group.sessionId) ? 'active' : 'completed', + startTime: Math.min(...sessions.map(session => session.sessionStartTime)), + lastActivityTime: Math.max(...sessions.map(session => session.lastActivityTime)), + } + } + + /** Re-emit a rollout event against its family id. Stable agent_spawn ids are + * deduplicated here because Codex can describe the same child both in its + * parent's spawn result and in the child's own rollout. */ + private emitForGroup(session: WatchedCodexSession, event: AgentEvent): void { + if (event.type === 'agent_spawn') { + const id = typeof event.payload.id === 'string' ? event.payload.id : null + if (id) { + const group = this.ensureGroup(session.groupSessionId) + if (group.emittedAgentIds.has(id)) return + group.emittedAgentIds.add(id) + } + } + this._onEvent.fire({ ...event, sessionId: session.groupSessionId }) + } + + private principalFor(metadata: CodexSessionMetadata, groupSessionId: string): CodexRolloutPrincipal { + const rootMetadata = this.sessionMetadata.get(groupSessionId) + const rootHasRollout = !!rootMetadata && rootMetadata.parentThreadId === null + const isMain = metadata.sessionId === groupSessionId && rootHasRollout + return { + id: metadata.sessionId, + name: isMain ? ORCHESTRATOR_NAME : `Codex agent ${metadata.sessionId.slice(0, SESSION_ID_DISPLAY)}`, + isMain, + ...(metadata.parentThreadId ? { parentId: metadata.parentThreadId } : {}), + ...(!rootHasRollout ? { + rootPlaceholder: { id: groupSessionId, name: ORCHESTRATOR_NAME }, + } : {}), + } + } + + private attachSession(metadata: CodexSessionMetadata, groupSessionId: string): void { + const { sessionId, filePath, stat } = metadata + const group = this.ensureGroup(groupSessionId) + const principal = this.principalFor(metadata, groupSessionId) const label = `Codex ${sessionId.slice(0, SESSION_ID_DISPLAY)}` + const wasGroupActive = this.groupIsActive(groupSessionId) // Build the parser once per session so the delegate closures capture the // right session reference and re-emission is stateless on this side. const parser = new CodexRolloutParser({ - emit: (event) => this._onEvent.fire({ ...event, sessionId }), + emit: (event) => { + const s = this.sessions.get(sessionId) + if (s) this.emitForGroup(s, event) + }, elapsed: () => { const s = this.sessions.get(sessionId) return s ? (Date.now() - s.sessionStartTime) / 1000 : 0 }, setLabel: (newLabel) => { const s = this.sessions.get(sessionId) - if (!s || !s.label.startsWith('Codex ')) return // only replace auto-label + // A user message is useful as a title only for a real root rollout. + // Never derive the synthetic parent's name/title from a child prompt. + if (!s || !s.isMain || !s.label.startsWith('Codex ')) return s.label = newLabel - this._onSessionLifecycle.fire({ type: 'updated', sessionId, label: newLabel }) + const currentGroup = this.ensureGroup(s.groupSessionId) + currentGroup.label = newLabel + this._onSessionLifecycle.fire({ type: 'updated', sessionId: s.groupSessionId, label: newLabel }) }, + principal, }) const session: WatchedCodexSession = { sessionId, + groupSessionId, + agentName: principal.name, + isMain: principal.isMain, filePath, fileWatcher: null, pollTimer: null, @@ -322,8 +520,12 @@ export class CodexSessionWatcher implements AgentSessionWatcher { this.readNewLines(sessionId) session.sessionDetected = true - this._onSessionDetected.fire(sessionId) - this._onSessionLifecycle.fire({ type: 'started', sessionId, label }) + if (!wasGroupActive) { + group.lifecycleEnded = false + group.completionEmitted = false + this._onSessionDetected.fire(groupSessionId) + this._onSessionLifecycle.fire({ type: 'started', sessionId: groupSessionId, label: group.label }) + } try { session.fileWatcher = fs.watch(filePath, () => this.readNewLines(sessionId)) @@ -333,7 +535,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { session.pollTimer = setInterval(() => this.readNewLines(sessionId), POLL_FALLBACK_MS) this.resetInactivityTimer(sessionId) - log.info(`Attached to session ${sessionId.slice(0, SESSION_ID_DISPLAY)} at ${filePath}`) + log.info(`Attached to rollout ${sessionId.slice(0, SESSION_ID_DISPLAY)} in group ${groupSessionId.slice(0, SESSION_ID_DISPLAY)} at ${filePath}`) } private readNewLines(sessionId: string): void { @@ -346,11 +548,17 @@ export class CodexSessionWatcher implements AgentSessionWatcher { session.fileTail = result.tail session.lastActivityTime = Date.now() + const wasGroupActive = this.groupIsActive(session.groupSessionId) // Re-activate if the session had been marked complete on inactivity — // new content means the user resumed the Codex CLI. if (session.sessionCompleted) { session.sessionCompleted = false - this._onSessionLifecycle.fire({ type: 'started', sessionId, label: session.label }) + if (!wasGroupActive) { + const group = this.ensureGroup(session.groupSessionId) + group.lifecycleEnded = false + group.completionEmitted = false + this._onSessionLifecycle.fire({ type: 'started', sessionId: session.groupSessionId, label: group.label }) + } log.info(`Session ${sessionId.slice(0, SESSION_ID_DISPLAY)} re-activated after idle`) } @@ -369,13 +577,31 @@ export class CodexSessionWatcher implements AgentSessionWatcher { session.inactivityTimer = setTimeout(() => { if (session.sessionCompleted) return session.sessionCompleted = true - this._onEvent.fire({ - time: (Date.now() - session.sessionStartTime) / 1000, - type: 'agent_complete', - payload: { name: ORCHESTRATOR_NAME, sessionEnd: true }, - sessionId, - }) - this._onSessionLifecycle.fire({ type: 'ended', sessionId, label: session.label }) + // Child rollouts complete independently. A real root remains alive until + // the whole family is idle, otherwise the UI would complete active + // descendants when its own rollout pauses first. + if (!session.isMain) { + this.emitForGroup(session, { + time: (Date.now() - session.sessionStartTime) / 1000, + type: 'agent_complete', + payload: { id: session.sessionId, name: session.agentName }, + }) + } + if (!this.groupIsActive(session.groupSessionId)) { + const group = this.ensureGroup(session.groupSessionId) + if (!group.completionEmitted) { + group.completionEmitted = true + this.emitForGroup(session, { + time: (Date.now() - session.sessionStartTime) / 1000, + type: 'agent_complete', + payload: { id: session.groupSessionId, name: ORCHESTRATOR_NAME, sessionEnd: true }, + }) + } + if (!group.lifecycleEnded) { + group.lifecycleEnded = true + this._onSessionLifecycle.fire({ type: 'ended', sessionId: session.groupSessionId, label: group.label }) + } + } }, INACTIVITY_TIMEOUT_MS) } @@ -389,6 +615,8 @@ export class CodexSessionWatcher implements AgentSessionWatcher { if (s.inactivityTimer) clearTimeout(s.inactivityTimer) } this.sessions.clear() + this.sessionMetadata.clear() + this.groups.clear() this._onEvent.dispose() this._onSessionDetected.dispose() this._onSessionLifecycle.dispose() diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index 563fb94a..6fc4296f 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -26,6 +26,11 @@ export interface AgentEvent { type: AgentEventType payload: Record sessionId?: string + /** Origin metadata used by the hosted multi-machine office. */ + source?: 'local' | 'vps' | 'unknown' + hostId?: string + runtime?: 'claude' | 'codex' | 'unknown' + sequence?: number } export interface SessionInfo { @@ -34,6 +39,9 @@ export interface SessionInfo { status: 'active' | 'completed' startTime: number lastActivityTime: number + source?: 'local' | 'vps' | 'unknown' + hostId?: string + runtime?: 'claude' | 'codex' | 'unknown' } // ─── Extension → Webview Messages ──────────────────────────────────────────── diff --git a/extension/test/codex-rollout-parser.test.ts b/extension/test/codex-rollout-parser.test.ts index aac38e41..c1206d5c 100644 --- a/extension/test/codex-rollout-parser.test.ts +++ b/extension/test/codex-rollout-parser.test.ts @@ -273,4 +273,214 @@ describe('CodexRolloutParser', () => { assert.ok(Number.isFinite(state.contextBreakdown.userMessages)) assert.ok(Number.isFinite(state.contextBreakdown.toolResults)) }) + + it('emits one stable child identity from the modern spawn_agent result', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ type: 'session_meta', payload: { id: 'root-thread' } }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-1', + arguments: JSON.stringify({ task_name: 'researcher', model: 'future-codex-model', message: 'redacted task prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'spawn-1', + output: JSON.stringify({ agent_id: 'child-thread-1', nickname: 'researcher' }), + }, + }), state) + // Replayed output must not create a second child. A second call with the + // same stable identity is deliberately tolerated. + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-2', + arguments: JSON.stringify({ task_name: 'researcher', message: 'another prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'spawn-2', + output: JSON.stringify({ agent_id: 'child-thread-1', nickname: 'researcher' }), + }, + }), state) + + const spawns = events.filter(e => e.type === 'agent_spawn' && e.payload.name === 'researcher') + assert.equal(spawns.length, 1) + assert.equal(spawns[0].payload.id, 'child-thread-1') + assert.equal(spawns[0].payload.parentId, 'root-thread') + assert.equal(spawns[0].payload.task, 'researcher') + assert.equal(spawns[0].payload.model, 'future-codex-model') + assert.equal(spawns[0].payload.workRole, 'researcher') + assert.equal(events.filter(e => e.type === 'subagent_dispatch').length, 1) + assert.equal(JSON.stringify(events).includes('redacted task prompt'), false) + }) + + it('uses an opaque-id-derived name rather than task_name when spawn output has no nickname', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-no-nickname', + arguments: JSON.stringify({ task_name: 'private task title', message: 'private task prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'spawn-no-nickname', + output: JSON.stringify({ agent_id: 'child-thread-without-nickname' }), + }, + }), state) + + const spawn = events.find(event => event.type === 'agent_spawn' && event.payload.id === 'child-thread-without-nickname') + assert.ok(spawn) + assert.equal(spawn!.payload.name, 'Codex agent child-th') + assert.equal(JSON.stringify(spawn).includes('private task title'), false) + assert.equal(JSON.stringify(events).includes('private task prompt'), false) + }) + + it('fails closed for a legacy task_name-only spawn result', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-legacy', + arguments: JSON.stringify({ task_name: 'legacy-child', message: 'private prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call_output', call_id: 'spawn-legacy', output: JSON.stringify({ task_name: 'legacy-child' }) }, + }), state) + assert.equal(events.some(e => e.type === 'agent_spawn' && e.payload.name === 'legacy-child'), false) + assert.equal(events.some(e => e.type === 'subagent_dispatch'), false) + assert.equal(JSON.stringify(events).includes('private prompt'), false) + }) + + it('does not create a relation for an unknown followup or wait target', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + for (const [name, callId, args, output] of [ + ['followup_task', 'unknown-followup', { target: 'missing-child', message: 'private' }, ''], + ['wait_agent', 'unknown-wait', { targets: ['missing-child'], timeout_ms: 1 }, JSON.stringify({ status: 'completed' })], + ] as const) { + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name, call_id: callId, arguments: JSON.stringify(args), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: callId, output, + } }), state) + } + assert.equal(events.some(e => e.type === 'subagent_dispatch'), false) + assert.equal(events.some(e => e.type === 'subagent_return'), false) + }) + + it('treats duplicate nicknames as ambiguous for nominal relations', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + for (const [callId, id] of [['spawn-a', 'child-a'], ['spawn-b', 'child-b']] as const) { + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name: 'spawn_agent', call_id: callId, + arguments: JSON.stringify({ task_name: 'same-name', message: 'private' }), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: callId, + output: JSON.stringify({ agent_id: id, nickname: 'same-name' }), + } }), state) + } + const dispatchesAfterSpawn = events.filter(e => e.type === 'subagent_dispatch').length + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name: 'followup_task', call_id: 'ambiguous-followup', + arguments: JSON.stringify({ target: 'same-name', message: 'private' }), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: 'ambiguous-followup', output: '', + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name: 'wait_agent', call_id: 'ambiguous-wait', + arguments: JSON.stringify({ targets: ['same-name'], timeout_ms: 1 }), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: 'ambiguous-wait', output: '', + } }), state) + assert.equal(dispatchesAfterSpawn, 2) + assert.equal(events.filter(e => e.type === 'subagent_dispatch').length, dispatchesAfterSpawn) + assert.equal(events.some(e => e.type === 'subagent_return'), false) + }) + + it('fails closed when spawn success has no reliable identity', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-unknown', + arguments: JSON.stringify({ message: 'do not use this as identity' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call_output', call_id: 'spawn-unknown', output: 'accepted' }, + }), state) + assert.equal(events.some(e => e.type === 'agent_spawn' && e.payload.name !== 'orchestrator'), false) + assert.equal(events.some(e => e.type === 'subagent_dispatch'), false) + }) + + it('uses rollout thread metadata for nested principal and direct-child hierarchy', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'session_meta', + payload: { id: 'child-thread', parent_thread_id: 'root-thread' }, + }), state) + const placeholder = events.find(e => e.type === 'agent_spawn' && e.payload.id === 'root-thread') + assert.ok(placeholder) + assert.equal(placeholder!.payload.isMain, true) + assert.equal(placeholder!.payload.placeholder, true) + const principal = events.find(e => e.type === 'agent_spawn' && e.payload.id === 'child-thread') + assert.ok(principal) + assert.equal(principal!.payload.id, 'child-thread') + assert.equal(principal!.payload.parentId, 'root-thread') + assert.equal(principal!.payload.isMain, false) + + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'grandchild-call', + arguments: JSON.stringify({ task_name: 'grandchild', message: 'nested private prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'grandchild-call', + output: JSON.stringify({ agent_id: 'grandchild-thread', nickname: 'grandchild' }), + }, + }), state) + + const grandchild = events.find(e => e.type === 'agent_spawn' && e.payload.name === 'grandchild') + assert.ok(grandchild) + assert.equal(grandchild!.payload.id, 'grandchild-thread') + assert.equal(grandchild!.payload.parentId, 'child-thread') + const dispatch = events.find(e => e.type === 'subagent_dispatch' && e.payload.child === 'grandchild') + assert.ok(dispatch) + assert.equal(dispatch!.payload.parentId, 'child-thread') + const principalEvents = events.filter(e => e.type === 'tool_call_start' || e.type === 'tool_call_end' || e.type === 'context_update') + for (const event of principalEvents) assert.equal(event.payload.agent, 'child-thread') + assert.equal(JSON.stringify(events).includes('nested private prompt'), false) + }) }) diff --git a/extension/test/codex-session-watcher.test.ts b/extension/test/codex-session-watcher.test.ts new file mode 100644 index 00000000..4f35274b --- /dev/null +++ b/extension/test/codex-session-watcher.test.ts @@ -0,0 +1,179 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { CodexSessionWatcher, resolveCodexThreadGroups } from '../src/codex-session-watcher' +import type { AgentEvent } from '../src/protocol' +import type { SessionLifecycleEvent } from '../src/session-runtime' + +function stableId(n: number): string { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` +} + +function dayDir(home: string): string { + const now = new Date() + return path.join( + home, + 'sessions', + String(now.getFullYear()), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + ) +} + +interface RolloutFixture { + id: string + parentId?: string + /** This is only a filename sort key; mtime remains current for discovery. */ + timestamp: string +} + +function collectFamily(rollouts: RolloutFixture[]): { + events: AgentEvent[] + lifecycle: SessionLifecycleEvent[] + detected: string[] + sessions: ReturnType +} { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-flow-codex-family-')) + const home = path.join(temp, 'codex-home') + const workspace = path.join(temp, 'workspace') + fs.mkdirSync(workspace) + const rolloutDir = dayDir(home) + fs.mkdirSync(rolloutDir, { recursive: true }) + for (const rollout of rollouts) { + const name = `rollout-${rollout.timestamp}-${rollout.id}.jsonl` + const payload = { + id: rollout.id, + cwd: workspace, + ...(rollout.parentId ? { parent_thread_id: rollout.parentId } : {}), + } + fs.writeFileSync(path.join(rolloutDir, name), `${JSON.stringify({ type: 'session_meta', payload })}\n`) + } + + const previousCodexHome = process.env.CODEX_HOME + process.env.CODEX_HOME = home + const watcher = new CodexSessionWatcher(workspace) + const events: AgentEvent[] = [] + const lifecycle: SessionLifecycleEvent[] = [] + const detected: string[] = [] + const eventSubscription = watcher.onEvent(event => events.push(event)) + const lifecycleSubscription = watcher.onSessionLifecycle(event => lifecycle.push(event)) + const detectedSubscription = watcher.onSessionDetected(id => detected.push(id)) + try { + watcher.start() + return { events, lifecycle, detected, sessions: watcher.getActiveSessions() } + } finally { + eventSubscription.dispose() + lifecycleSubscription.dispose() + detectedSubscription.dispose() + watcher.dispose() + if (previousCodexHome === undefined) delete process.env.CODEX_HOME + else process.env.CODEX_HOME = previousCodexHome + fs.rmSync(temp, { recursive: true, force: true }) + } +} + +function spawns(events: AgentEvent[]): AgentEvent[] { + return events.filter(event => event.type === 'agent_spawn') +} + +describe('CodexSessionWatcher family grouping', () => { + it('exports pure parent-chain grouping without creating a watcher or fs.watch handle', () => { + const root = stableId(100) + const child = stableId(101) + const grandchild = stableId(102) + const groups = resolveCodexThreadGroups([ + { sessionId: grandchild, parentThreadId: child }, + { sessionId: child, parentThreadId: root }, + { sessionId: root, parentThreadId: null }, + ]) + + assert.deepEqual(Array.from(groups.entries()), [ + [grandchild, root], + [child, root], + [root, root], + ]) + }) + + it('groups three direct child rollouts under one discovered root despite adversarial filename order', () => { + const root = stableId(1) + const children = [stableId(2), stableId(3), stableId(4)] + // The root sorts after every child. Discovery must still parse root metadata + // before emitting any event, then attach by hierarchy depth. + const result = collectFamily([ + ...children.map((id, index) => ({ id, parentId: root, timestamp: `2026-09-05T09-00-0${index}` })), + { id: root, timestamp: '2026-09-05T23-59-59' }, + ]) + + assert.deepEqual(result.sessions.map(session => session.id), [root]) + assert.deepEqual(result.detected, [root]) + assert.equal(result.lifecycle.filter(event => event.type === 'started').length, 1) + assert.ok(result.events.every(event => event.sessionId === root)) + + const family = spawns(result.events) + assert.equal(family.length, 4) + assert.ok(family.some(event => event.payload.id === root && event.payload.isMain === true)) + for (const child of children) { + const spawn = family.find(event => event.payload.id === child) + assert.ok(spawn) + assert.equal(spawn!.payload.isMain, false) + assert.equal(spawn!.payload.parentId, root) + assert.equal(typeof spawn!.payload.task, 'undefined') + } + }) + + it('resolves a grandchild through its rollout parent chain to the same root and preserves both edges', () => { + const root = stableId(10) + const child = stableId(11) + const grandchild = stableId(12) + const result = collectFamily([ + { id: grandchild, parentId: child, timestamp: '2026-09-05T09-00-00' }, + { id: child, parentId: root, timestamp: '2026-09-05T10-00-00' }, + { id: root, timestamp: '2026-09-05T11-00-00' }, + ]) + + assert.deepEqual(result.sessions.map(session => session.id), [root]) + assert.ok(result.events.every(event => event.sessionId === root)) + const childSpawn = spawns(result.events).find(event => event.payload.id === child) + const grandchildSpawn = spawns(result.events).find(event => event.payload.id === grandchild) + assert.ok(childSpawn) + assert.ok(grandchildSpawn) + // The web simulation turns these parentId fields into root→child→grandchild edges. + assert.equal(childSpawn!.payload.parentId, root) + assert.equal(grandchildSpawn!.payload.parentId, child) + assert.equal(childSpawn!.payload.isMain, false) + assert.equal(grandchildSpawn!.payload.isMain, false) + }) + + it('emits exactly one observed-parent placeholder when the root has no rollout', () => { + const missingRoot = stableId(20) + const child = stableId(21) + const result = collectFamily([{ id: child, parentId: missingRoot, timestamp: '2026-09-05T12-00-00' }]) + + assert.deepEqual(result.sessions.map(session => session.id), [missingRoot]) + assert.deepEqual(result.detected, [missingRoot]) + const family = spawns(result.events) + assert.equal(family.length, 2) + const placeholder = family.find(event => event.payload.id === missingRoot) + const childSpawn = family.find(event => event.payload.id === child) + assert.ok(placeholder) + assert.ok(childSpawn) + assert.equal(placeholder!.payload.placeholder, true) + assert.equal(placeholder!.payload.isMain, true) + assert.equal(childSpawn!.payload.isMain, false) + assert.equal(childSpawn!.payload.parentId, missingRoot) + }) + + it('keeps an unparented rollout as its own ordinary session', () => { + const root = stableId(30) + const result = collectFamily([{ id: root, timestamp: '2026-09-05T13-00-00' }]) + + assert.deepEqual(result.sessions.map(session => session.id), [root]) + const family = spawns(result.events) + assert.equal(family.length, 1) + assert.equal(family[0].payload.id, root) + assert.equal(family[0].payload.isMain, true) + assert.equal(family[0].payload.placeholder, undefined) + }) +}) diff --git a/open-agent-flow-office.cmd b/open-agent-flow-office.cmd new file mode 100644 index 00000000..5bf83ca0 --- /dev/null +++ b/open-agent-flow-office.cmd @@ -0,0 +1,9 @@ +@echo off +setlocal +set "ROOT=%~dp0" + +start "Agent Flow bridge" powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Minimized -File "%ROOT%scripts\connect-hosted.ps1" +start "Agent Flow Office" "https://launcher.104-248-32-222.sslip.io/agent-flow/" + +endlocal + diff --git a/package.json b/package.json index 1cd0c2fb..f7f460bb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "setup": "node scripts/setup.js", "dev": "NEXT_PUBLIC_DEMO=0 NEXT_PUBLIC_RELAY_PORT=3001 concurrently -n relay,web -c blue,green \"pnpm run dev:relay\" \"pnpm run dev:web\"", "dev:relay": "node scripts/build-relay.js && node scripts/.dev-relay.js", + "build:forwarder": "node scripts/build-forwarder.js", + "connect:hosted": "node scripts/build-forwarder.js && node scripts/.remote-forwarder.js", "dev:demo": "NEXT_PUBLIC_DEMO=1 pnpm run dev:web", "dev:web": "pnpm --filter agent-flow-web run dev", "dev:extension": "pnpm --filter agent-flow run watch", @@ -12,7 +14,7 @@ "build:webview": "pnpm --filter agent-flow-web run build:webview", "build:all": "pnpm run build:webview && pnpm run build:extension", "build:app": "node app/build.js", - "test": "node --import tsx --test \"scripts/**/*.test.ts\" \"app/src/**/*.test.ts\"" + "test": "node --import tsx --test \"scripts/**/*.test.ts\" \"app/src/**/*.test.ts\" && node scripts/run-workspace-tests.js" }, "devDependencies": { "concurrently": "^9.2.1", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 432a7320..2ebfdd28 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ packages: - extension - web +allowBuilds: + esbuild: true + sharp: true diff --git a/scripts/build-forwarder.js b/scripts/build-forwarder.js new file mode 100644 index 00000000..bd97b038 --- /dev/null +++ b/scripts/build-forwarder.js @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** Builds the ephemeral hosted bridge without installing a local service. */ +'use strict' + +const esbuild = require('esbuild') +const path = require('path') + +esbuild.buildSync({ + entryPoints: [path.join(__dirname, 'remote-forwarder.ts')], + bundle: true, + platform: 'node', + target: 'node18', + outfile: path.join(__dirname, '.remote-forwarder.js'), + alias: { + vscode: path.join(__dirname, 'vscode-shim.js'), + }, + sourcemap: true, + logLevel: 'warning', +}) + diff --git a/scripts/connect-hosted.ps1 b/scripts/connect-hosted.ps1 new file mode 100644 index 00000000..75925963 --- /dev/null +++ b/scripts/connect-hosted.ps1 @@ -0,0 +1,57 @@ +param( + [string]$Workspace, + [ValidateSet('auto', 'claude', 'codex')] + [string]$Runtime = 'auto' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $Workspace) { $Workspace = $repoRoot } +$nodeCommand = (Get-Command node.exe -ErrorAction SilentlyContinue).Source +if (-not $nodeCommand -and (Test-Path -LiteralPath 'C:\Program Files\nodejs\node.exe')) { + $nodeCommand = 'C:\Program Files\nodejs\node.exe' +} +if (-not $nodeCommand) { throw 'No encuentro Node.js. Instala Node.js LTS o añádelo al PATH.' } +$remoteUrl = 'https://launcher.104-248-32-222.sslip.io/agent-flow/ingest' +$sshKey = if ($env:AGENT_FLOW_VPS_KEY) { $env:AGENT_FLOW_VPS_KEY } else { 'C:\Users\aday_\.ssh\id_ed25519_vps' } +$remoteTokenFile = '/home/discanary/apps/agent-flow-office/.relay.env' +$knownHosts = Join-Path ([IO.Path]::GetTempPath()) "agent-flow-vps-$PID-known_hosts" + +if (-not (Test-Path -LiteralPath $sshKey)) { + throw "No encuentro la clave SSH del VPS en $sshKey" +} + +# Read the token over the already configured SSH trust. It is kept only in +# this PowerShell process environment and is never written or displayed. +$tokenLine = (& ssh -i $sshKey -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$knownHosts" -o LogLevel=ERROR ` + discanary@104.248.32.222 "grep '^AGENT_FLOW_INGEST_TOKEN=' $remoteTokenFile" 2>$null) +if ($LASTEXITCODE -ne 0 -or -not $tokenLine) { + throw 'No se pudo obtener el token seguro del relay del VPS.' +} +$token = ($tokenLine -join "`n") -replace '^AGENT_FLOW_INGEST_TOKEN=', '' +if (-not $token) { throw 'El token del relay del VPS está vacío.' } + +$env:AGENT_FLOW_REMOTE_URL = $remoteUrl +$env:AGENT_FLOW_INGEST_TOKEN = $token +$env:AGENT_FLOW_RUNTIME = $Runtime +$env:AGENT_FLOW_WORKSPACE = $Workspace +$env:AGENT_FLOW_WATCH_ALL = '1' +$env:AGENT_FLOW_HOST_ID = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { 'windows-local' } + +Push-Location -LiteralPath $repoRoot +try { + & $nodeCommand scripts/build-forwarder.js + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $nodeCommand scripts/.remote-forwarder.js + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} +finally { + Pop-Location + Remove-Item -LiteralPath $knownHosts -Force -ErrorAction SilentlyContinue + Remove-Item Env:AGENT_FLOW_REMOTE_URL -ErrorAction SilentlyContinue + Remove-Item Env:AGENT_FLOW_INGEST_TOKEN -ErrorAction SilentlyContinue + Remove-Item Env:AGENT_FLOW_RUNTIME -ErrorAction SilentlyContinue + Remove-Item Env:AGENT_FLOW_WORKSPACE -ErrorAction SilentlyContinue + Remove-Item Env:AGENT_FLOW_WATCH_ALL -ErrorAction SilentlyContinue + Remove-Item Env:AGENT_FLOW_HOST_ID -ErrorAction SilentlyContinue +} diff --git a/scripts/dev-relay.ts b/scripts/dev-relay.ts index 838086fd..f10c45c0 100644 --- a/scripts/dev-relay.ts +++ b/scripts/dev-relay.ts @@ -9,6 +9,12 @@ import { DEFAULT_RELAY_PORT, DEV_WEB_ORIGIN_PATTERN } from '../extension/src/con async function main() { const workspace = process.argv[2] || process.cwd() + const relayHost = process.env.AGENT_FLOW_RELAY_HOST || '127.0.0.1' + const configuredPort = process.env.AGENT_FLOW_RELAY_PORT + const relayPort = configuredPort === undefined ? DEFAULT_RELAY_PORT : Number.parseInt(configuredPort, 10) + if (!Number.isInteger(relayPort) || relayPort < 1 || relayPort > 65535) { + throw new Error(`Invalid AGENT_FLOW_RELAY_PORT: ${configuredPort}`) + } console.log('Starting Agent Flow dev relay...\n') console.log(`Workspace: ${workspace}`) @@ -23,8 +29,8 @@ async function main() { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Vary', 'Origin') } - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Agent-Flow-Token') if (req.method === 'OPTIONS') { res.writeHead(204) @@ -36,12 +42,22 @@ async function main() { return relay.handleSSE(req, res) } + if (req.url === '/ingest' && req.method === 'POST') { + return relay.handleIngest(req, res) + } + + if (req.url === '/healthz') { + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ status: 'ok', service: 'agent-flow-office-relay' })) + return + } + res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Agent Flow Dev Relay') }) - server.listen(DEFAULT_RELAY_PORT, '127.0.0.1', () => { - console.log(`\nSSE relay on http://127.0.0.1:${DEFAULT_RELAY_PORT}/events`) + server.listen(relayPort, relayHost, () => { + console.log(`\nSSE relay on http://${relayHost}:${relayPort}/events`) console.log('Ready! Events will appear in the web app.') }) diff --git a/scripts/relay.ts b/scripts/relay.ts index cbca8298..58cafdc0 100644 --- a/scripts/relay.ts +++ b/scripts/relay.ts @@ -24,16 +24,65 @@ import { setLogLevel } from '../extension/src/logger' import type { TelemetryClient } from './telemetry' const MAX_EVENT_BUFFER = 5000 +const MAX_EXTERNAL_BODY_BYTES = 256 * 1024 +const MAX_EXTERNAL_DEDUPE_KEYS = 10000 const DISCOVERY_DIR = path.join(os.homedir(), '.claude', 'agent-flow') const CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects') let relayCreated = false let verbose = false let sessionEventCount = 0 +type EventSource = 'local' | 'vps' | 'unknown' +type RuntimeName = 'claude' | 'codex' | 'unknown' +let relaySource: EventSource = 'vps' +let relayHostId = os.hostname() +let ingestToken = '' +let relayEventSink: ((event: AgentEvent) => void) | null = null +let relayLifecycleSink: ((event: RelayLifecycleEvent) => void) | null = null /** Distinct model IDs seen across all watched sessions during this relay session. * Populated from `model_detected` events (emitted by both the Claude transcript * parser and the Codex rollout parser). Read at session_end for telemetry. */ const observedModels = new Set() +const externalSessions = new Map() +const externalDedupeKeys = new Set() + +function safeSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 80) || 'unknown' +} + +/** Namespace session IDs so local and VPS sessions can never collide. */ +function publicSessionId(source: EventSource, hostId: string, runtime: RuntimeName, sessionId: string): string { + return `${source}:${safeSegment(hostId)}:${runtime}:${sessionId}` +} + +function publicOrigin(source?: EventSource, hostId?: string, runtime?: RuntimeName) { + return { + source: source || relaySource, + hostId: hostId || relayHostId, + runtime: runtime || 'unknown', + } as const +} + +function publicEvent(event: AgentEvent, runtime?: RuntimeName): AgentEvent { + const origin = publicOrigin(event.source, event.hostId, runtime || event.runtime) + return { + ...event, + source: origin.source, + hostId: origin.hostId, + runtime: origin.runtime, + ...(event.sessionId ? { sessionId: publicSessionId(origin.source, origin.hostId, origin.runtime, event.sessionId) } : {}), + } +} + +function publicSession(info: SessionInfo, runtime: RuntimeName, source: EventSource = relaySource, hostId = relayHostId): SessionInfo { + return { + ...info, + id: publicSessionId(source, hostId, runtime, info.id), + source, + hostId, + runtime, + } +} // agent-flow-app version. Inlined by esbuild at bundle time via `define`. // In dev (running from source via tsx), falls back to reading app/package.json. @@ -79,37 +128,62 @@ function broadcast(data: string) { const eventBuffer = new Map() -function broadcastEvent(event: AgentEvent) { +function broadcastEvent(event: AgentEvent, runtime?: RuntimeName) { + // A remote forwarder can subscribe to the raw, un-namespaced event before + // this relay adds its public source/host/runtime namespace. The hosted + // relay performs that namespacing exactly once on ingest. + relayEventSink?.({ + ...event, + source: event.source || relaySource, + hostId: event.hostId || relayHostId, + runtime: event.runtime || runtime || 'unknown', + }) + const observedEvent = publicEvent(event, runtime) sessionEventCount++ - if (event.type === 'model_detected') { - const m = (event.payload as { model?: unknown } | undefined)?.model + if (observedEvent.type === 'model_detected') { + const m = (observedEvent.payload as { model?: unknown } | undefined)?.model if (typeof m === 'string' && m.length > 0) observedModels.add(m) } - const sid = event.sessionId?.slice(0, SESSION_ID_DISPLAY) || '?' - log(`[event] ${event.type} (session ${sid})`) + const sid = observedEvent.sessionId?.slice(0, SESSION_ID_DISPLAY) || '?' + log(`[event] ${observedEvent.type} (session ${sid})`) - if (event.sessionId) { - let buf = eventBuffer.get(event.sessionId) || [] - buf.push(event) + if (observedEvent.sessionId) { + let buf = eventBuffer.get(observedEvent.sessionId) || [] + buf.push(observedEvent) if (buf.length > MAX_EVENT_BUFFER) { buf = buf.slice(buf.length - MAX_EVENT_BUFFER) } - eventBuffer.set(event.sessionId, buf) + eventBuffer.set(observedEvent.sessionId, buf) + + const external = externalSessions.get(observedEvent.sessionId) + if (external) { + external.lastActivityTime = Date.now() + external.status = 'active' + } } - broadcast(JSON.stringify({ type: 'agent-event', event })) + broadcast(JSON.stringify({ type: 'agent-event', event: observedEvent })) } -function broadcastSessionLifecycle(type: 'started' | 'ended' | 'updated', sessionId: string, label: string) { +function broadcastSessionLifecycle(type: 'started' | 'ended' | 'updated', sessionId: string, label: string, runtime: RuntimeName = 'unknown') { + relayLifecycleSink?.({ + type, + sessionId, + label, + runtime, + source: relaySource, + hostId: relayHostId, + }) + const session = publicSession({ + id: sessionId, label, status: type === 'ended' ? 'completed' : 'active', + startTime: Date.now(), lastActivityTime: Date.now(), + }, runtime) if (type === 'started') { - broadcast(JSON.stringify({ - type: 'session-started', - session: { id: sessionId, label, status: 'active', startTime: Date.now(), lastActivityTime: Date.now() } as SessionInfo, - })) + broadcast(JSON.stringify({ type: 'session-started', session })) } else if (type === 'ended') { - broadcast(JSON.stringify({ type: 'session-ended', sessionId })) + broadcast(JSON.stringify({ type: 'session-ended', sessionId: session.id })) } else if (type === 'updated') { - broadcast(JSON.stringify({ type: 'session-updated', sessionId, label })) + broadcast(JSON.stringify({ type: 'session-updated', sessionId: session.id, label })) } } @@ -137,14 +211,14 @@ function emitContextUpdate(agentName: string, session: WatchedSession, sessionId } function emitEvent(event: AgentEvent, sessionId?: string) { - broadcastEvent(sessionId ? { ...event, sessionId } : event) + broadcastEvent(sessionId ? { ...event, sessionId } : event, 'claude') } const parser = new TranscriptParser({ emit: emitEvent, elapsed, getSession: (sessionId: string) => sessions.get(sessionId), - fireSessionLifecycle: (event) => broadcastSessionLifecycle(event.type, event.sessionId, event.label), + fireSessionLifecycle: (event) => broadcastSessionLifecycle(event.type, event.sessionId, event.label, 'claude'), emitContextUpdate, }) @@ -170,8 +244,8 @@ function resetInactivityTimer(sessionId: string) { type: 'agent_spawn', payload: { name: ORCHESTRATOR_NAME, isMain: true, task: session.label, ...(session.model ? { model: session.model } : {}) }, sessionId, - }) - broadcastSessionLifecycle('started', sessionId, session.label) + }, 'claude') + broadcastSessionLifecycle('started', sessionId, session.label, 'claude') } if (session.inactivityTimer) clearTimeout(session.inactivityTimer) @@ -184,8 +258,8 @@ function resetInactivityTimer(sessionId: string) { type: 'agent_complete', payload: { name: ORCHESTRATOR_NAME }, sessionId, - }) - broadcastSessionLifecycle('ended', sessionId, session.label) + }, 'claude') + broadcastSessionLifecycle('ended', sessionId, session.label, 'claude') } }, INACTIVITY_TIMEOUT_MS) } @@ -219,12 +293,12 @@ function watchSession(sessionId: string, filePath: string) { session.fileSize = stat.size parser.extractSessionLabel(catchUpEntries, session) - broadcastSessionLifecycle('started', sessionId, session.label) + broadcastSessionLifecycle('started', sessionId, session.label, 'claude') broadcastEvent({ time: 0, type: 'agent_spawn', payload: { name: ORCHESTRATOR_NAME, isMain: true, task: session.label, ...(session.model ? { model: session.model } : {}) }, sessionId, - }) + }, 'claude') session.sessionDetected = true emitContextUpdate(ORCHESTRATOR_NAME, session, sessionId) @@ -267,29 +341,39 @@ function readNewLines(sessionId: string) { // ─── Session scanner ──────────────────────────────────────────────────────── -function scanForActiveSessions(workspace: string) { +function scanForActiveSessions(workspace: string, watchAll = false) { if (!fs.existsSync(CLAUDE_DIR)) return + const dirsToScan: string[] = [] + if (watchAll) { + try { + for (const dir of fs.readdirSync(CLAUDE_DIR, { withFileTypes: true })) { + if (dir.isDirectory()) dirsToScan.push(path.join(CLAUDE_DIR, dir.name)) + } + } catch { /* ignore an unavailable Claude directory */ } + } + let resolved = workspace try { resolved = fs.realpathSync(resolved) } catch {} const encoded = resolved.replace(/[^a-zA-Z0-9]/g, '-') - const dirsToScan: string[] = [] // Case-folded on Windows — VS Code/shells report `c:\...` while Claude Code // encodes `C--...`, so exact string matching never found the project dir there. - const encodedFolded = foldPathCase(encoded) - try { - for (const dir of fs.readdirSync(CLAUDE_DIR, { withFileTypes: true })) { - if (!dir.isDirectory()) continue - const nameFolded = foldPathCase(dir.name) - if (nameFolded === encodedFolded || nameFolded.startsWith(encodedFolded + '-')) { - dirsToScan.push(path.join(CLAUDE_DIR, dir.name)) + if (!watchAll) { + const encodedFolded = foldPathCase(encoded) + try { + for (const dir of fs.readdirSync(CLAUDE_DIR, { withFileTypes: true })) { + if (!dir.isDirectory()) continue + const nameFolded = foldPathCase(dir.name) + if (nameFolded === encodedFolded || nameFolded.startsWith(encodedFolded + '-')) { + dirsToScan.push(path.join(CLAUDE_DIR, dir.name)) + } } + } catch { + // readdir failed — fall back to the exact-match dir if it exists + const projectDir = path.join(CLAUDE_DIR, encoded) + if (fs.existsSync(projectDir)) dirsToScan.push(projectDir) } - } catch { - // readdir failed — fall back to the exact-match dir if it exists - const projectDir = path.join(CLAUDE_DIR, encoded) - if (fs.existsSync(projectDir)) dirsToScan.push(projectDir) } for (const dirPath of dirsToScan) { @@ -353,6 +437,8 @@ function removeDiscoveryFile() { export interface Relay { /** Handle an incoming SSE connection */ handleSSE: (req: http.IncomingMessage, res: http.ServerResponse) => void + /** Accept privacy-filtered events from an existing local runtime channel. */ + handleIngest: (req: http.IncomingMessage, res: http.ServerResponse) => void /** Clean up all resources */ dispose: () => void } @@ -367,6 +453,112 @@ export interface RelayOptions { * Mirrors the extension's `agentVisualizer.runtime` setting so users of the * dev relay and `npx agent-flow-app` have a way to opt out of one runtime. */ runtime?: RelayRuntimeMode + /** Origin metadata used when this relay forwards local sessions upstream. */ + source?: EventSource + hostId?: string + /** Subscribe to raw local events/lifecycle without starting an HTTP server. */ + onEvent?: (event: AgentEvent) => void + onSessionLifecycle?: (event: RelayLifecycleEvent) => void + /** Watch every Claude/Codex session, regardless of the workspace path. */ + watchAll?: boolean + /** Disable the optional loopback Claude hook listener for file-only bridges. */ + enableClaudeHookServer?: boolean +} + +export interface RelayLifecycleEvent { + type: 'started' | 'ended' | 'updated' + sessionId: string + label: string + runtime: RuntimeName + source: EventSource + hostId: string +} + +interface ExternalIngestBody { + source?: EventSource + hostId?: string + runtime?: RuntimeName + lifecycle?: 'started' | 'ended' | 'updated' + session?: { + id?: string + label?: string + status?: 'active' | 'completed' + startTime?: number + lastActivityTime?: number + } + event?: Partial + events?: Array> +} + +const EXTERNAL_EVENT_TYPES = new Set([ + 'agent_spawn', 'agent_complete', 'agent_idle', 'message', 'context_update', + 'model_detected', 'tool_call_start', 'tool_call_end', 'subagent_dispatch', + 'subagent_return', 'permission_requested', 'error', +]) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function safeExternalPayload(value: unknown): Record { + if (!isRecord(value)) return {} + // Keep only bounded metadata. In particular, prompts, task text, arguments, + // file paths, and tool results must never be forwarded by the local bridge. + const allowed = new Set(['agent', 'name', 'parent', 'child', 'model', 'isMain', 'isError', 'tool', 'role', 'workRole', 'id', 'tokens']) + const result: Record = {} + for (const [key, raw] of Object.entries(value)) { + if (!allowed.has(key)) continue + if (typeof raw === 'string') result[key] = raw.slice(0, 160) + else if (typeof raw === 'number' || typeof raw === 'boolean') result[key] = raw + } + if (isRecord(value.breakdown)) { + const breakdown: Record = {} + for (const [key, raw] of Object.entries(value.breakdown)) { + if (typeof raw === 'number' && Number.isFinite(raw)) breakdown[key] = raw + } + if (Object.keys(breakdown).length) result.breakdown = breakdown + } + return result +} + +function normalizeExternalEvent(raw: unknown, defaults: { hostId: string; runtime: RuntimeName; sessionId?: string }): AgentEvent | null { + if (!isRecord(raw) || typeof raw.type !== 'string' || !EXTERNAL_EVENT_TYPES.has(raw.type as AgentEvent['type'])) return null + const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId : defaults.sessionId + if (!sessionId) return null + const time = typeof raw.time === 'number' && Number.isFinite(raw.time) ? raw.time : Date.now() / 1000 + const sequence = typeof raw.sequence === 'number' && Number.isFinite(raw.sequence) ? raw.sequence : undefined + return { + time, + type: raw.type as AgentEvent['type'], + payload: safeExternalPayload(raw.payload), + sessionId, + source: 'local', + hostId: defaults.hostId, + runtime: defaults.runtime, + ...(sequence === undefined ? {} : { sequence }), + } +} + +async function readJsonBody(req: http.IncomingMessage): Promise { + return await new Promise((resolve, reject) => { + let size = 0 + const chunks: Buffer[] = [] + req.on('data', (chunk: Buffer | string) => { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + size += data.length + if (size > MAX_EXTERNAL_BODY_BYTES) { + reject(new Error('body-too-large')) + req.destroy() + return + } + chunks.push(data) + }) + req.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) } + catch { reject(new Error('invalid-json')) } + }) + req.on('error', reject) + }) } function resolveRuntimeMode(explicit?: RelayRuntimeMode): RelayRuntimeMode { @@ -385,6 +577,13 @@ export async function createRelay(options: RelayOptions): Promise { throw new Error('createRelay() can only be called once per process') } relayCreated = true + relaySource = options.source || (process.env.AGENT_FLOW_SOURCE === 'local' ? 'local' : 'vps') + relayHostId = options.hostId || process.env.AGENT_FLOW_HOST_ID || os.hostname() + ingestToken = process.env.AGENT_FLOW_INGEST_TOKEN || '' + relayEventSink = options.onEvent || null + relayLifecycleSink = options.onSessionLifecycle || null + externalSessions.clear() + externalDedupeKeys.clear() const mode = resolveRuntimeMode(options.runtime) const wantClaude = mode === 'claude' || mode === 'auto' @@ -396,20 +595,22 @@ export async function createRelay(options: RelayOptions): Promise { let projectDirWatcher: fs.FSWatcher | null = null if (wantClaude) { - hookServer = new HookServer() - const hookPort = await hookServer.start() - if (hookPort === HOOK_SERVER_NOT_STARTED) { - throw new Error('Failed to start hook server (port in use)') - } + if (options.enableClaudeHookServer !== false) { + hookServer = new HookServer() + const hookPort = await hookServer.start() + if (hookPort === HOOK_SERVER_NOT_STARTED) { + throw new Error('Failed to start hook server (port in use)') + } - hookServer.onEvent((event: AgentEvent) => { - broadcast(JSON.stringify({ type: 'agent-event', event })) - }) + hookServer.onEvent((event: AgentEvent) => { + broadcastEvent({ ...event, runtime: 'claude' }, 'claude') + }) - writeDiscoveryFile(hookPort, workspace) + writeDiscoveryFile(hookPort, workspace) + } - scanForActiveSessions(workspace) - scanInterval = setInterval(() => scanForActiveSessions(workspace), SCAN_INTERVAL_MS) + scanForActiveSessions(workspace, options.watchAll === true) + scanInterval = setInterval(() => scanForActiveSessions(workspace, options.watchAll === true), SCAN_INTERVAL_MS) const resolved = (() => { try { return fs.realpathSync(workspace) } catch { return workspace } })() const encoded = resolved.replace(/[^a-zA-Z0-9]/g, '-') @@ -431,10 +632,10 @@ export async function createRelay(options: RelayOptions): Promise { // wiring both would double-broadcast session-started to SSE clients. let codexWatcher: CodexSessionWatcher | null = null if (wantCodex) { - codexWatcher = new CodexSessionWatcher(workspace) - codexWatcher.onEvent((event) => broadcastEvent(event)) + codexWatcher = new CodexSessionWatcher(options.watchAll === true ? null : workspace) + codexWatcher.onEvent((event) => broadcastEvent(event, 'codex')) codexWatcher.onSessionLifecycle((lifecycle) => { - broadcastSessionLifecycle(lifecycle.type, lifecycle.sessionId, lifecycle.label) + broadcastSessionLifecycle(lifecycle.type, lifecycle.sessionId, lifecycle.label, 'codex') }) codexWatcher.start() } @@ -469,6 +670,38 @@ export async function createRelay(options: RelayOptions): Promise { process.exit(1) }) + const upsertExternalSession = (rawId: string, hostId: string, runtime: RuntimeName, input?: ExternalIngestBody['session']): SessionInfo => { + const id = publicSessionId('local', hostId, runtime, rawId) + const existing = externalSessions.get(id) + const next: SessionInfo = { + id, + label: typeof input?.label === 'string' && input.label.trim() ? input.label.trim().slice(0, 160) : `Local ${runtime} ${rawId.slice(0, SESSION_ID_DISPLAY)}`, + status: input?.status === 'completed' ? 'completed' : 'active', + startTime: typeof input?.startTime === 'number' ? input.startTime : existing?.startTime ?? Date.now(), + lastActivityTime: typeof input?.lastActivityTime === 'number' ? input.lastActivityTime : Date.now(), + source: 'local', hostId, runtime, + } + externalSessions.set(id, next) + if (!existing) { + broadcast(JSON.stringify({ type: 'session-started', session: next })) + } else if (existing.label !== next.label) { + broadcast(JSON.stringify({ type: 'session-updated', sessionId: id, label: next.label })) + } + return next + } + + const rememberExternalEvent = (event: AgentEvent): boolean => { + const raw = `${event.hostId}|${event.runtime}|${event.sessionId}|${event.sequence ?? ''}|${event.type}|${JSON.stringify(event.payload)}` + const key = crypto.createHash('sha256').update(raw).digest('hex') + if (externalDedupeKeys.has(key)) return false + externalDedupeKeys.add(key) + if (externalDedupeKeys.size > MAX_EXTERNAL_DEDUPE_KEYS) { + const oldest = externalDedupeKeys.values().next().value + if (typeof oldest === 'string') externalDedupeKeys.delete(oldest) + } + return true + } + return { handleSSE(req: http.IncomingMessage, res: http.ServerResponse) { res.writeHead(200, { @@ -489,13 +722,16 @@ export async function createRelay(options: RelayOptions): Promise { const sessionList: SessionInfo[] = [] for (const session of sessions.values()) { if (!session.sessionDetected) continue - sessionList.push({ + sessionList.push(publicSession({ id: session.sessionId, label: session.label, status: session.sessionCompleted ? 'completed' : 'active', startTime: session.sessionStartTime, lastActivityTime: session.lastActivityTime, - }) + }, 'claude')) + } + if (codexWatcher) { + sessionList.push(...codexWatcher.getActiveSessions().map(session => publicSession(session, 'codex'))) } - if (codexWatcher) sessionList.push(...codexWatcher.getActiveSessions()) + sessionList.push(...externalSessions.values()) if (sessionList.length > 0) { sendSSE(res, { type: 'session-list', sessions: sessionList }) } @@ -515,6 +751,67 @@ export async function createRelay(options: RelayOptions): Promise { } }, + handleIngest(req: http.IncomingMessage, res: http.ServerResponse) { + const suppliedToken = typeof req.headers.authorization === 'string' + ? req.headers.authorization.replace(/^Bearer\s+/i, '') + : typeof req.headers['x-agent-flow-token'] === 'string' ? req.headers['x-agent-flow-token'] : '' + if (!ingestToken) { + res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ error: 'ingest-disabled' })) + return + } + if (suppliedToken !== ingestToken) { + res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ error: 'unauthorized' })) + return + } + + void readJsonBody(req).then(raw => { + if (!isRecord(raw)) throw new Error('invalid-payload') + const body = raw as ExternalIngestBody + if (body.source && body.source !== 'local') throw new Error('source-must-be-local') + if (typeof body.hostId !== 'string' || !body.hostId.trim()) throw new Error('host-id-required') + if (body.runtime !== 'claude' && body.runtime !== 'codex') throw new Error('runtime-required') + const hostId = body.hostId.trim().slice(0, 80) + const runtime = body.runtime + const incoming = [ + ...(Array.isArray(body.events) ? body.events : []), + ...(body.event ? [body.event] : []), + ] + const defaultSessionId = body.session?.id || incoming.find(item => isRecord(item) && typeof item.sessionId === 'string')?.sessionId + let session: SessionInfo | undefined + if (defaultSessionId) session = upsertExternalSession(defaultSessionId, hostId, runtime, body.session) + + let accepted = 0 + for (const rawEvent of incoming) { + const event = normalizeExternalEvent(rawEvent, { hostId, runtime, sessionId: defaultSessionId }) + if (!event || !rememberExternalEvent(event)) continue + if (!session || session.id !== publicSessionId('local', hostId, runtime, event.sessionId!)) { + session = upsertExternalSession(event.sessionId!, hostId, runtime, body.session) + } + broadcastEvent(event, runtime) + accepted++ + } + + const ended = body.lifecycle === 'ended' || body.session?.status === 'completed' + if (ended && session) { + const completed = { ...session, status: 'completed' as const, lastActivityTime: Date.now() } + externalSessions.set(session.id, completed) + broadcast(JSON.stringify({ type: 'session-ended', sessionId: session.id })) + } + + res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ accepted, sessionId: session?.id ?? null })) + }).catch(error => { + const message = error instanceof Error ? error.message : 'invalid-payload' + const status = message === 'body-too-large' ? 413 : message === 'unauthorized' ? 401 : 400 + if (!res.headersSent) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ error: message })) + } + }) + }, + dispose() { // Defense in depth — server.ts already guards cleanup(), but direct // callers or hot-reload could call this twice. @@ -542,6 +839,8 @@ export async function createRelay(options: RelayOptions): Promise { } } codexWatcher?.dispose() + relayEventSink = null + relayLifecycleSink = null }, } } diff --git a/scripts/remote-forwarder.ts b/scripts/remote-forwarder.ts new file mode 100644 index 00000000..243275d9 --- /dev/null +++ b/scripts/remote-forwarder.ts @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * Ephemeral local bridge for the hosted Office. + * + * It reuses the existing Claude/Codex filesystem watchers but does not open a + * public/local web server. Only bounded metadata is sent upstream. Start it + * for the period in which local sessions should be visible and stop with + * Ctrl+C; no Windows service or scheduled task is installed. + */ +import * as os from 'os' +import * as fs from 'fs' +import * as path from 'path' +import { createRelay, RelayLifecycleEvent } from './relay' +import { AgentEvent } from '../extension/src/protocol' + +const DEFAULT_REMOTE_URL = 'https://launcher.104-248-32-222.sslip.io/agent-flow/ingest' +const ALLOWED_PAYLOAD_KEYS = new Set([ + 'agent', 'name', 'parent', 'child', 'model', 'isMain', 'isError', 'tool', + 'role', 'workRole', 'id', 'tokens', +]) + +function safePayload(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + const result: Record = {} + for (const [key, raw] of Object.entries(value as Record)) { + if (!ALLOWED_PAYLOAD_KEYS.has(key)) continue + if (typeof raw === 'string') result[key] = raw.slice(0, 160) + else if (typeof raw === 'number' || typeof raw === 'boolean') result[key] = raw + } + const breakdown = (value as Record).breakdown + if (breakdown && typeof breakdown === 'object' && !Array.isArray(breakdown)) { + const bounded: Record = {} + for (const [key, raw] of Object.entries(breakdown as Record)) { + if (typeof raw === 'number' && Number.isFinite(raw)) bounded[key] = raw + } + if (Object.keys(bounded).length > 0) result.breakdown = bounded + } + return result +} + +function safeEvent(event: AgentEvent): AgentEvent { + return { + time: Number.isFinite(event.time) ? event.time : Date.now() / 1000, + type: event.type, + payload: safePayload(event.payload), + ...(event.sessionId ? { sessionId: event.sessionId } : {}), + ...(typeof event.sequence === 'number' ? { sequence: event.sequence } : {}), + source: 'local', + hostId: HOST_ID, + runtime: event.runtime === 'claude' || event.runtime === 'codex' ? event.runtime : RUNTIME, + } +} + +function safeLabel(event: RelayLifecycleEvent): string { + const prefix = event.runtime === 'codex' ? 'Codex' : 'Claude' + return `${prefix} ${event.sessionId.slice(0, 8)}` +} + +const REMOTE_URL = process.env.AGENT_FLOW_REMOTE_URL || DEFAULT_REMOTE_URL +const TOKEN = process.env.AGENT_FLOW_INGEST_TOKEN || '' +const HOST_ID = process.env.AGENT_FLOW_HOST_ID || os.hostname() +const RUNTIME = process.env.AGENT_FLOW_RUNTIME === 'claude' || process.env.AGENT_FLOW_RUNTIME === 'codex' + ? process.env.AGENT_FLOW_RUNTIME + : 'unknown' +const LOCK_PATH = path.join(os.tmpdir(), 'agent-flow-hosted-bridge.lock') + +if (!TOKEN) { + console.error('Missing AGENT_FLOW_INGEST_TOKEN. Use the hosted launcher so the token is obtained without printing it.') + process.exit(2) +} + +let chain = Promise.resolve() +let acceptEvents = true +const activeSessions = new Map() + +function enqueue(body: Record): void { + if (!acceptEvents) return + chain = chain.then(async () => { + const response = await fetch(REMOTE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${TOKEN}`, + }, + body: JSON.stringify(body), + }) + if (!response.ok) throw new Error(`hosted ingest returned HTTP ${response.status}`) + }).catch((error: unknown) => { + // Keep watching after a transient network failure. The Office is an + // observability view; a failed update must not stop Claude/Codex sessions. + const message = error instanceof Error ? error.message : 'unknown ingest error' + console.error(`[agent-flow] ${message}`) + }) +} + +function sendLifecycle(event: RelayLifecycleEvent): void { + const key = `${event.runtime}:${event.sessionId}` + if (event.type === 'ended') activeSessions.delete(key) + else activeSessions.set(key, event) + const session = { + id: event.sessionId, + label: safeLabel(event), + status: event.type === 'ended' ? 'completed' : 'active', + lastActivityTime: Date.now(), + } + enqueue({ + source: 'local', + hostId: HOST_ID, + runtime: event.runtime === 'claude' || event.runtime === 'codex' ? event.runtime : RUNTIME, + lifecycle: event.type, + session, + }) +} + +async function main(): Promise { + let lockOwned = false + try { + const lock = fs.openSync(LOCK_PATH, 'wx') + fs.writeFileSync(lock, `${process.pid}\n`, 'utf8') + fs.closeSync(lock) + lockOwned = true + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === 'EEXIST') { + const existingPid = Number.parseInt(fs.readFileSync(LOCK_PATH, 'utf8').trim(), 10) + let running = false + if (Number.isInteger(existingPid) && existingPid > 0) { + try { process.kill(existingPid, 0); running = true } catch { /* stale lock */ } + } + if (running) { + console.log('[agent-flow] hosted bridge ya está conectado') + return + } + try { fs.unlinkSync(LOCK_PATH) } catch { /* retry below */ } + const lock = fs.openSync(LOCK_PATH, 'wx') + fs.writeFileSync(lock, `${process.pid}\n`, 'utf8') + fs.closeSync(lock) + lockOwned = true + } else { + throw error + } + } + + const workspace = process.env.AGENT_FLOW_WORKSPACE || process.cwd() + const watchAll = process.env.AGENT_FLOW_WATCH_ALL !== '0' + const relay = await createRelay({ + workspace, + runtime: RUNTIME === 'unknown' ? 'auto' : RUNTIME, + source: 'local', + hostId: HOST_ID, + watchAll, + enableClaudeHookServer: false, + verbose: process.env.AGENT_FLOW_VERBOSE === '1', + onEvent: (event) => enqueue({ + source: 'local', + hostId: HOST_ID, + runtime: event.runtime === 'claude' || event.runtime === 'codex' ? event.runtime : RUNTIME, + event: safeEvent(event), + }), + onSessionLifecycle: sendLifecycle, + }) + + console.log(`[agent-flow] connected to hosted Office (${watchAll ? 'all local sessions' : workspace})`) + console.log('[agent-flow] Ctrl+C disconnects; no local service remains installed') + + const cleanup = async () => { + if (!acceptEvents) return + for (const event of [...activeSessions.values()]) { + sendLifecycle({ ...event, type: 'ended' }) + } + acceptEvents = false + relay.dispose() + await chain + if (lockOwned) { + try { fs.unlinkSync(LOCK_PATH) } catch { /* another instance may own it */ } + } + process.exit(0) + } + process.once('SIGINT', cleanup) + process.once('SIGTERM', cleanup) + process.once('SIGHUP', cleanup) +} + +main().catch((error: unknown) => { + console.error('[agent-flow] unable to connect:', error instanceof Error ? error.message : error) + process.exit(1) +}) diff --git a/scripts/run-workspace-tests.js b/scripts/run-workspace-tests.js new file mode 100644 index 00000000..22370710 --- /dev/null +++ b/scripts/run-workspace-tests.js @@ -0,0 +1,33 @@ +const { spawnSync } = require('node:child_process') +const path = require('node:path') + +const root = path.resolve(__dirname, '..') +const suites = [ + { + name: 'web', + cwd: path.join(root, 'web'), + args: ['--import', 'tsx', '--test', 'hooks/**/*.test.ts', 'lib/**/*.test.ts'], + }, + { + name: 'extension', + cwd: path.join(root, 'extension'), + args: ['--import', 'tsx', '--test', 'test/**/*.test.ts'], + }, +] + +for (const suite of suites) { + console.log(`\n[test] ${suite.name}`) + const result = spawnSync(process.execPath, suite.args, { + cwd: suite.cwd, + stdio: 'inherit', + shell: false, + }) + + if (result.error) { + console.error(`[test] ${suite.name} failed to start: ${result.error.message}`) + process.exit(1) + } + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} diff --git a/scripts/telemetry.test.ts b/scripts/telemetry.test.ts index 937bfe24..596f7420 100644 --- a/scripts/telemetry.test.ts +++ b/scripts/telemetry.test.ts @@ -9,13 +9,15 @@ function setup() { return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-flow-tel-')) } -function makeClient(dir: string) { +function makeClient(dir: string, env: NodeJS.ProcessEnv = {}, fetch?: typeof globalThis.fetch) { return createTelemetryClient({ logDir: path.join(dir, 'telemetry'), installIdPath: path.join(dir, 'installation-id'), // Unroutable so tests never hit the real endpoint. endpoint: 'http://127.0.0.1:1', apiKey: 'test', + env, + fetch, }) } @@ -34,8 +36,8 @@ test('hardcoded constants are present', () => { assert.match(TELEMETRY_PUBLISHABLE_KEY, /^sb_publishable_/) }) -test('isTelemetryEnabled: default (no env) is true', () => { - assert.equal(isTelemetryEnabled({}), true) +test('isTelemetryEnabled: default (no env) is false', () => { + assert.equal(isTelemetryEnabled({}), false) }) test('isTelemetryEnabled: AGENT_FLOW_TELEMETRY=false disables', () => { @@ -47,7 +49,8 @@ test('isTelemetryEnabled: AGENT_FLOW_TELEMETRY=false disables', () => { test('isTelemetryEnabled: AGENT_FLOW_TELEMETRY=true stays enabled', () => { assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: 'true' }), true) - assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: '1' }), true) + assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: 'TRUE' }), true) + assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: '1' }), false) }) test('isTelemetryEnabled: DO_NOT_TRACK=1 disables', () => { @@ -59,11 +62,25 @@ test('isTelemetryEnabled: DO_NOT_TRACK wins even when AGENT_FLOW_TELEMETRY=true' assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: 'true', DO_NOT_TRACK: '1' }), false) }) +test('disabled by default creates no state and does not call transport', async () => { + const dir = setup() + let transportCalls = 0 + const fetch = (() => { + transportCalls += 1 + return Promise.reject(new Error('transport must not be called')) + }) as typeof globalThis.fetch + const client = makeClient(dir, {}, fetch) + await client.init() + client.emit(baseEvent()) + await client.dispose() + assert.equal(transportCalls, 0) + assert.equal(fs.existsSync(path.join(dir, 'telemetry')), false) + assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) +}) + test('emit appends to JSONL when enabled', async () => { const dir = setup() - const client = makeClient(dir) - delete process.env.AGENT_FLOW_TELEMETRY - delete process.env.DO_NOT_TRACK + const client = makeClient(dir, { AGENT_FLOW_TELEMETRY: 'true' }) await client.init() client.emit(baseEvent()) await client.dispose() @@ -79,47 +96,36 @@ test('emit appends to JSONL when enabled', async () => { test('disabled via AGENT_FLOW_TELEMETRY=false writes nothing to disk', async () => { const dir = setup() - process.env.AGENT_FLOW_TELEMETRY = 'false' - try { - const client = makeClient(dir) - await client.init() - client.emit(baseEvent()) - await client.dispose() - // No events log AND no install-id file — disabled means zero disk footprint. - assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) - assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) - } finally { - delete process.env.AGENT_FLOW_TELEMETRY - } + const client = makeClient(dir, { AGENT_FLOW_TELEMETRY: 'false' }) + await client.init() + client.emit(baseEvent()) + await client.dispose() + // No events log AND no install-id file — disabled means zero disk footprint. + assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) + assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) }) test('disabled via DO_NOT_TRACK=1 writes nothing to disk', async () => { const dir = setup() - process.env.DO_NOT_TRACK = '1' - try { - const client = makeClient(dir) - await client.init() - client.emit(baseEvent()) - await client.dispose() - assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) - assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) - } finally { - delete process.env.DO_NOT_TRACK - } + const client = makeClient(dir, { DO_NOT_TRACK: '1', AGENT_FLOW_TELEMETRY: 'true' }) + await client.init() + client.emit(baseEvent()) + await client.dispose() + assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) + assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) }) test('install-id persists across init calls', async () => { const dir = setup() - delete process.env.AGENT_FLOW_TELEMETRY - delete process.env.DO_NOT_TRACK - const client1 = makeClient(dir) + const env = { AGENT_FLOW_TELEMETRY: 'true' } + const client1 = makeClient(dir, env) await client1.init() client1.emit(baseEvent()) await client1.dispose() const idPath = path.join(dir, 'installation-id') const id1 = fs.readFileSync(idPath, 'utf-8').trim() - const client2 = makeClient(dir) + const client2 = makeClient(dir, env) await client2.init() client2.emit(baseEvent()) await client2.dispose() @@ -131,9 +137,7 @@ test('install-id persists across init calls', async () => { test('emit sanitizes session_id', async () => { const dir = setup() - delete process.env.AGENT_FLOW_TELEMETRY - delete process.env.DO_NOT_TRACK - const client = makeClient(dir) + const client = makeClient(dir, { AGENT_FLOW_TELEMETRY: 'true' }) await client.init() client.emit({ ...baseEvent(), session_id: 'quote"backslash\\newline\n' }) await client.dispose() diff --git a/scripts/telemetry.ts b/scripts/telemetry.ts index 4183d38b..bbe674d4 100644 --- a/scripts/telemetry.ts +++ b/scripts/telemetry.ts @@ -8,7 +8,9 @@ import { syncOnce } from './telemetry/sync' * Hardcoded telemetry endpoint + publishable key. * * These ship inside every published binary. No env var override, no runtime - * fallback. All enabled installs send events to Agent Flow's Supabase project. + * fallback. Telemetry is disabled by default; only installs that explicitly + * opt in with AGENT_FLOW_TELEMETRY=true send events to Agent Flow's Supabase + * project. * Forks that republish under a different name must edit these constants and * rebuild. * @@ -32,8 +34,6 @@ const FIRST_SYNC_DELAY_MS = 2 * 1000 const SYNC_SCHEDULE_MS = [2 * 60 * 1000, 3 * 60 * 1000] const SYNC_REPEAT_MS = 5 * 60 * 1000 -const FALSY_VALUES = new Set(['false', '0', 'disabled', '']) - export interface TelemetryEvent { event_type: 'session_start' | 'session_end' | 'error' session_id: string @@ -63,6 +63,8 @@ export interface TelemetryClientOptions { endpoint?: string /** Override the key for tests. Defaults to the hardcoded constant. */ apiKey?: string + /** Override the transport for tests. Defaults to global fetch. */ + fetch?: typeof fetch } export interface TelemetryClient { @@ -81,19 +83,15 @@ export interface TelemetryClient { * * Rules: * - `DO_NOT_TRACK` truthy → disabled (wins over everything) - * - `AGENT_FLOW_TELEMETRY` falsy (`false`, `0`, `disabled`, ``) → disabled - * - Otherwise enabled (including when AGENT_FLOW_TELEMETRY is unset) + * - `AGENT_FLOW_TELEMETRY=true` (case-insensitive) → enabled + * - Any other value, including when unset, → disabled */ export function isTelemetryEnabled(env: NodeJS.ProcessEnv): boolean { const dnt = env.DO_NOT_TRACK if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') { return false } - const flag = env.AGENT_FLOW_TELEMETRY - if (flag !== undefined && FALSY_VALUES.has(flag.toLowerCase())) { - return false - } - return true + return env.AGENT_FLOW_TELEMETRY?.trim().toLowerCase() === 'true' } export function createTelemetryClient(opts: TelemetryClientOptions): TelemetryClient { @@ -143,7 +141,7 @@ export function createTelemetryClient(opts: TelemetryClientOptions): TelemetryCl function fireSync() { if (syncInFlight) return // another sync is already running — skip, not queue - syncInFlight = syncOnce({ jsonlPath, cursorPath, endpoint, apiKey }) + syncInFlight = syncOnce({ jsonlPath, cursorPath, endpoint, apiKey, fetch: opts.fetch }) .catch(() => { // Swallow — next tick retries from the same cursor position. }) @@ -200,7 +198,7 @@ export function createTelemetryClient(opts: TelemetryClientOptions): TelemetryCl // starts from an up-to-date cursor. if (syncInFlight) { try { await syncInFlight } catch { /* best effort */ } } if (enabled()) { - try { await syncOnce({ jsonlPath, cursorPath, endpoint, apiKey }) } catch { /* best effort */ } + try { await syncOnce({ jsonlPath, cursorPath, endpoint, apiKey, fetch: opts.fetch }) } catch { /* best effort */ } } }, } diff --git a/stop-agent-flow-office.ps1 b/stop-agent-flow-office.ps1 new file mode 100644 index 00000000..7cd4a887 --- /dev/null +++ b/stop-agent-flow-office.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = 'SilentlyContinue' +$lockPath = Join-Path ([IO.Path]::GetTempPath()) 'agent-flow-hosted-bridge.lock' +if (-not (Test-Path -LiteralPath $lockPath)) { + Write-Host 'El puente no está conectado.' + exit 0 +} +$pidText = (Get-Content -LiteralPath $lockPath -Raw).Trim() +if ($pidText -match '^\d+$') { + Stop-Process -Id ([int]$pidText) -ErrorAction SilentlyContinue +} +Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue +Write-Host 'Puente desconectado.' + diff --git a/web/app/healthz/route.ts b/web/app/healthz/route.ts new file mode 100644 index 00000000..68246fbd --- /dev/null +++ b/web/app/healthz/route.ts @@ -0,0 +1,6 @@ +export function GET() { + return Response.json( + { status: 'ok', service: 'agent-flow-office-web' }, + { headers: { 'Cache-Control': 'no-store' } }, + ) +} diff --git a/web/app/ingest/route.ts b/web/app/ingest/route.ts new file mode 100644 index 00000000..dd6ac344 --- /dev/null +++ b/web/app/ingest/route.ts @@ -0,0 +1,42 @@ +/** + * Same-origin bridge for optional local runtime events. + * + * Keeping this as a Next route means the public launcher can forward the + * request to the private relay without exposing port 3001 or requiring a new + * local Agent Flow service. The relay remains the authentication and privacy + * boundary. + */ +export const dynamic = 'force-dynamic' + +const RELAY_INGEST_URL = process.env.AGENT_FLOW_RELAY_INGEST_URL || 'http://172.17.0.1:3001/ingest' + +export async function POST(request: Request): Promise { + const headers = new Headers({ + 'Content-Type': request.headers.get('content-type') || 'application/json', + }) + const authorization = request.headers.get('authorization') + const sourceToken = request.headers.get('x-agent-flow-token') + if (authorization) headers.set('Authorization', authorization) + if (sourceToken) headers.set('X-Agent-Flow-Token', sourceToken) + + try { + const upstream = await fetch(RELAY_INGEST_URL, { + method: 'POST', + headers, + body: await request.text(), + cache: 'no-store', + }) + return new Response(upstream.body, { + status: upstream.status, + headers: { + 'Content-Type': upstream.headers.get('content-type') || 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }) + } catch { + return Response.json({ error: 'relay-unavailable' }, { + status: 503, + headers: { 'Cache-Control': 'no-store' }, + }) + } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 44a28a9b..2cbefa3a 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,26 +1,29 @@ import type { Metadata } from 'next' import './globals.css' +import { PwaRegister } from './pwa-register' + +const basePath = (process.env.NEXT_PUBLIC_BASE_PATH || '').replace(/\/$/, '') export const metadata: Metadata = { - title: 'LLM Agent Visualizer', - description: 'Real-time visualization of LLM agent execution flows - VS Code extension concept', + title: 'Agent Flow Office', + description: 'Internal real-time visualization of agent execution flows', generator: 'v0.app', icons: { icon: [ { - url: '/icon-light-32x32.png', + url: `${basePath}/icon-light-32x32.png`, media: '(prefers-color-scheme: light)', }, { - url: '/icon-dark-32x32.png', + url: `${basePath}/icon-dark-32x32.png`, media: '(prefers-color-scheme: dark)', }, { - url: '/icon.svg', + url: `${basePath}/icon.svg`, type: 'image/svg+xml', }, ], - apple: '/apple-icon.png', + apple: `${basePath}/apple-icon.png`, }, } @@ -32,6 +35,7 @@ export default function RootLayout({ return ( + {children} diff --git a/web/app/manifest.ts b/web/app/manifest.ts new file mode 100644 index 00000000..77c5f43a --- /dev/null +++ b/web/app/manifest.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from 'next' + +const basePath = (process.env.NEXT_PUBLIC_BASE_PATH || '').replace(/\/$/, '') + +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'Agent Flow Office', + short_name: 'Agent Office', + description: 'Internal real-time view of agent execution flows', + start_url: `${basePath}/`, + scope: `${basePath}/`, + display: 'standalone', + background_color: '#0a0a1a', + theme_color: '#0a0a1a', + icons: [ + { src: `${basePath}/icon-light-32x32.png`, sizes: '32x32', type: 'image/png' }, + { src: `${basePath}/apple-icon.png`, sizes: '180x180', type: 'image/png' }, + ], + } +} diff --git a/web/app/pwa-register.tsx b/web/app/pwa-register.tsx new file mode 100644 index 00000000..a5f732ac --- /dev/null +++ b/web/app/pwa-register.tsx @@ -0,0 +1,16 @@ +'use client' + +import { useEffect } from 'react' + +const basePath = (process.env.NEXT_PUBLIC_BASE_PATH || '').replace(/\/$/, '') + +export function PwaRegister() { + useEffect(() => { + if (!('serviceWorker' in navigator)) return + navigator.serviceWorker.register(`${basePath}/sw.js`, { scope: `${basePath}/` }).catch(() => { + // PWA installation is optional; the live office remains usable without it. + }) + }, []) + + return null +} diff --git a/web/components/agent-visualizer/agent-detail-card.tsx b/web/components/agent-visualizer/agent-detail-card.tsx index fd2b881a..b5eaf60f 100644 --- a/web/components/agent-visualizer/agent-detail-card.tsx +++ b/web/components/agent-visualizer/agent-detail-card.tsx @@ -10,6 +10,7 @@ interface AgentDetailCardProps { agent: { id: string name: string + workLabel?: string state: AgentState model?: string tokensUsed: number @@ -51,7 +52,7 @@ export function AgentDetailCard({ />
- {agent.name} + {agent.workLabel || agent.name} {agent.model && ( diff --git a/web/components/agent-visualizer/canvas/draw-agents.ts b/web/components/agent-visualizer/canvas/draw-agents.ts index 04c510ff..78f88061 100644 --- a/web/components/agent-visualizer/canvas/draw-agents.ts +++ b/web/components/agent-visualizer/canvas/draw-agents.ts @@ -312,7 +312,7 @@ function drawAgentLabel(ctx: CanvasRenderingContext2D, agent: Agent, r: number, ctx.textAlign = 'center' ctx.textBaseline = 'top' const maxLabelW = r * AGENT_DRAW.labelWidthMultiplier - const agentLabel = truncateText(ctx, agent.name, maxLabelW) + const agentLabel = truncateText(ctx, agent.workLabel || agent.name, maxLabelW) ctx.fillText(agentLabel, agent.x, agent.y + r + AGENT_DRAW.labelYOffset) } diff --git a/web/components/agent-visualizer/index.tsx b/web/components/agent-visualizer/index.tsx index ca88a0c8..fe8bf573 100644 --- a/web/components/agent-visualizer/index.tsx +++ b/web/components/agent-visualizer/index.tsx @@ -17,13 +17,17 @@ import { AgentChatPanel } from "./chat-panel" import { SessionTranscriptPanel } from "./session-transcript-panel" import { OpenFileProvider } from "./tool-content-renderer" import { stopPropagationHandlers } from "./shared-ui" -import { TimelineEvent, TIMING } from "@/lib/agent-types" +import { TimelineEvent, TIMING, type SimulationEvent } from "@/lib/agent-types" import { COLORS } from "@/lib/colors" import { MOCK_DURATION } from "@/lib/mock-scenario" import { MessageFeedPanel } from "./message-feed-panel" import { TopBar } from "./top-bar" import { useAudioEffects } from "@/hooks/use-audio-effects" +import { OfficeView } from "./office" +import { mergeOfficeProjections, officeAgentId, projectOffice, projectOfficeGraph } from "@/lib/office" + +type VisualizerMode = 'office' | 'graph' export function AgentVisualizer() { const bridge = useVSCodeBridge() @@ -63,6 +67,115 @@ export function AgentVisualizer() { const selection = useSelectionState({ agents, toolCalls, discoveries }) + const [viewMode, setViewMode] = useState('office') + + const officeSessions = useMemo(() => bridge.sessions.map(session => ({ + ...session, + // Session labels may be prompt-derived. Office exposes only a short ID. + label: `Sesión ${session.id.slice(0, 8) || 'desconocida'}`, + })), [bridge.sessions]) + const officeSessionLabels = useMemo(() => new Map( + officeSessions.map(session => [session.id, session.label]), + ), [officeSessions]) + + const selectedOfficeSession = useMemo( + () => officeSessions.find(session => session.id === bridge.selectedSessionId), + [bridge.selectedSessionId, officeSessions], + ) + + // Background sessions are replayed into Office independently of the Graph. + // Cache unchanged projections so a busy session does not reprocess every + // historical event for every other session on each incoming event. + const officeProjectionCacheRef = useRef(new Map + }>()) + + // Office is an aggregate view: it keeps every session visible while the + // Graph view and its controls remain scoped to the selected session. + const officeProjection = useMemo(() => { + const projections: ReturnType[] = [] + const activeSessionIds = new Set() + for (const [sessionId, events] of bridge.sessionEvents) { + activeSessionIds.add(sessionId) + const lastEvent = events[events.length - 1] + const cached = officeProjectionCacheRef.current.get(sessionId) + if (cached && cached.length === events.length && cached.lastEvent === lastEvent) { + projections.push(cached.projection) + continue + } + const projection = projectOffice(events) + officeProjectionCacheRef.current.set(sessionId, { length: events.length, lastEvent, projection }) + projections.push(projection) + } + for (const sessionId of officeProjectionCacheRef.current.keys()) { + if (!activeSessionIds.has(sessionId)) officeProjectionCacheRef.current.delete(sessionId) + } + if (agents.size > 0 || projections.length === 0) { + projections.push(projectOfficeGraph({ + sessionId: bridge.selectedSessionId ?? undefined, + source: selectedOfficeSession?.source, + hostId: selectedOfficeSession?.hostId, + runtime: selectedOfficeSession?.runtime, + agents: agents.values(), + edges, + })) + } + const merged = mergeOfficeProjections(projections) + const labelledAgents = new Map(Array.from(merged.agents, ([id, agent]) => { + const sessionLabel = agent.sessionId && agent.sessionId !== 'default-session' + ? officeSessionLabels.get(agent.sessionId) ?? `Sesión ${agent.sessionId.slice(0, 8)}` + : undefined + return [id, sessionLabel ? { ...agent, sessionLabel } : agent] as const + })) + return { ...merged, agents: labelledAgents } + }, [agents, bridge.selectedSessionId, bridge.sessionEvents, edges, officeSessionLabels, selectedOfficeSession]) + + // Office selection can jump to another session before selecting its agent in + // the graph, after the session cache has been restored. + const pendingOfficeSelectionRef = useRef<{ sessionId: string; sourceAgentId: string } | null>(null) + const officeAgentOrigins = useMemo(() => { + const origins = new Map() + for (const agent of officeProjection.agents.values()) { + if (!agent.sourceAgentId) continue + origins.set(agent.id, { + sessionId: agent.sessionId === 'default-session' ? null : (agent.sessionId ?? null), + sourceAgentId: agent.sourceAgentId, + }) + } + return origins + }, [officeProjection]) + const officeToGraphAgentId = useMemo(() => { + const mapping = new Map() + for (const graphAgentId of agents.keys()) { + mapping.set(officeAgentId(bridge.selectedSessionId ?? undefined, graphAgentId), graphAgentId) + } + return mapping + }, [bridge.selectedSessionId, agents]) + const officeSelectedAgentId = selection.selectedAgentId + ? officeAgentId(bridge.selectedSessionId ?? undefined, selection.selectedAgentId) + : null + const handleOfficeAgentSelect = useCallback((officeId: string) => { + const origin = officeAgentOrigins.get(officeId) + if (!origin) return + const targetSessionId = origin.sessionId + if (targetSessionId && targetSessionId !== bridge.selectedSessionId) { + pendingOfficeSelectionRef.current = { sessionId: targetSessionId, sourceAgentId: origin.sourceAgentId } + bridge.selectSession(targetSessionId) + return + } + const graphAgentId = officeToGraphAgentId.get(officeId) ?? origin.sourceAgentId + if (agents.has(graphAgentId)) selection.handleAgentClick(graphAgentId) + }, [agents, bridge, officeAgentOrigins, officeToGraphAgentId, selection.handleAgentClick]) + + useEffect(() => { + const pending = pendingOfficeSelectionRef.current + if (!pending || pending.sessionId !== bridge.selectedSessionId || !agents.has(pending.sourceAgentId)) return + pendingOfficeSelectionRef.current = null + selection.handleAgentClick(pending.sourceAgentId) + }, [agents, bridge.selectedSessionId, selection.handleAgentClick]) + const [showStats, setShowStats] = useState(false) const [showHexGrid, setShowHexGrid] = useState(true) const [showCostOverlay, setShowCostOverlay] = useState(false) @@ -260,7 +373,7 @@ export function AgentVisualizer() {
{/* Empty state when no demo and no live data */} - {isEmpty && ( + {isEmpty && viewMode !== 'office' && (
WAITING FOR AGENT SESSION
@@ -269,85 +382,130 @@ export function AgentVisualizer() {
)} - {/* Canvas fills everything */} - - - {/* Message feed panel (top-left) */} - - - {/* Agent detail card (floating, tethered to node) */} - {selectedAgent && selection.selectedAgentWorldPos && ( -
- + ) : ( + <> + {/* Canvas fills everything */} + -
- )} - {/* Tool call detail popup */} - {selection.selectedToolData && selection.selectedToolScreenPos && ( -
- -
- )} - {/* Discovery detail popup */} - {selection.selectedDiscoveryData && selection.selectedDiscoveryScreenPos && ( -
- + +
+ )} + + {/* Tool call detail popup */} + {selection.selectedToolData && selection.selectedToolScreenPos && ( +
+ +
+ )} + + {/* Discovery detail popup */} + {selection.selectedDiscoveryData && selection.selectedDiscoveryScreenPos && ( +
+ +
+ )} + + {/* Chat panel (bottom-right, shown when agent selected) */} + -
- )} - {/* Chat panel (bottom-right, shown when agent selected) */} - - - {/* Context menu */} - {selection.contextMenu && ( - selection.setContextMenu(null)} - /> + {/* Context menu */} + {selection.contextMenu && ( + selection.setContextMenu(null)} + /> + )} + )} + {/* Accessible, reversible view selector. Office is the default. */} +
+ {(['office', 'graph'] as const).map(mode => { + const isActive = viewMode === mode + return ( + + ) + })} +
+ {/* Floating control strip */} {agentsWithMessages.map(agentId => { const agent = agents.get(agentId) - const name = agent?.name ?? agentId + const name = agent?.workLabel || agent?.name || agentId const color = agent ? getStateColor(agent.state) : COLORS.idle return ( { onAgentClick(msg.agentId); setExpanded(false) }} diff --git a/web/components/agent-visualizer/office/OfficeView.tsx b/web/components/agent-visualizer/office/OfficeView.tsx new file mode 100644 index 00000000..b4ec1eea --- /dev/null +++ b/web/components/agent-visualizer/office/OfficeView.tsx @@ -0,0 +1,649 @@ +'use client' + +import { useMemo, useState, type CSSProperties, type KeyboardEvent } from 'react' +import type { + OfficeAgent, + OfficeAgentState, + OfficeConnectionStatus, + OfficeEdge, + OfficeProjection, + OfficeSession, + OfficeZone, +} from '@/lib/office' +import { AGENT_WORK_ROLES, agentWorkRoleLabel, modelFamilyLabel, type AgentWorkRole } from '@/lib/agent-role' +import styles from './office.module.css' + +type RoomId = 'entrance' | 'meetings' | 'library' | 'desks' | 'lab' | 'decisions' | 'incidents' | 'deliveries' + +interface RoomDefinition { + id: RoomId + label: string + hint: string + icon: string + x: number + y: number + width: number + height: number +} + +interface PlacedAgent { + agent: OfficeAgent + room: RoomDefinition + x: number + y: number +} + +const ROOMS: readonly RoomDefinition[] = [ + { id: 'entrance', label: 'Sala de espera', hint: 'Agentes disponibles', icon: '✦', x: 22, y: 22, width: 198, height: 150 }, + { id: 'meetings', label: 'Reuniones', hint: 'Coordinación', icon: '◌', x: 260, y: 22, width: 226, height: 150 }, + { id: 'library', label: 'Biblioteca', hint: 'Investigación', icon: '▤', x: 526, y: 22, width: 226, height: 150 }, + { id: 'lab', label: 'Laboratorio', hint: 'Herramientas', icon: '⚙', x: 792, y: 22, width: 186, height: 150 }, + { id: 'desks', label: 'Escritorios', hint: 'Trabajo en curso', icon: '▥', x: 22, y: 218, width: 334, height: 184 }, + { id: 'decisions', label: 'Decisiones', hint: 'Esperando permiso', icon: '⚖', x: 396, y: 218, width: 214, height: 184 }, + { id: 'incidents', label: 'Incidencias', hint: 'Necesita atención', icon: '!', x: 650, y: 218, width: 328, height: 184 }, + { id: 'deliveries', label: 'Entregas', hint: 'Trabajo terminado', icon: '✓', x: 260, y: 448, width: 480, height: 154 }, +] + +const STATE_LABELS: Record = { + unknown: 'Sin clasificar', + planning: 'Planificando', + researching: 'Investigando', + editing: 'Editando', + executing: 'Ejecutando', + waiting_approval: 'Esperando aprobación', + blocked: 'Bloqueado', + completed: 'Completado', + idle: 'Disponible', + stale: 'Sin actividad reciente', +} + +const STATE_MARKS: Record = { + unknown: '•', + planning: '⌁', + researching: '⌕', + editing: '✎', + executing: '▶', + waiting_approval: '!', + blocked: '×', + completed: '✓', + idle: '·', + stale: '…', +} + +const ROLE_MARKS: Record = { + orchestrator: '◆', + security: '◈', + validator: '✓', + researcher: '⌕', + implementer: '⌘', + documenter: '▤', + designer: '✦', + integrator: '⇄', + analyst: '▥', + specialist: '★', +} + +interface StandbyAgentDefinition { + id: string + name: string + role: AgentWorkRole + model?: string + avatar: 'terra' | 'luna' | 'generated' + room: RoomId + x: number + y: number +} + +/** Decorative, non-observed positions shown only while no session is active. */ +const STANDBY_AGENTS: readonly StandbyAgentDefinition[] = [ + { id: 'standby-terra', name: 'Terra', role: 'orchestrator', model: 'Terra', avatar: 'terra', room: 'entrance', x: 58, y: 124 }, + { id: 'standby-luna', name: 'Luna', role: 'researcher', model: 'Luna', avatar: 'luna', room: 'entrance', x: 121, y: 124 }, + { id: 'standby-specialist', name: 'Especialista', role: 'specialist', avatar: 'generated', room: 'entrance', x: 184, y: 124 }, +] + +function roomForZone(zone: OfficeZone): RoomId { + switch (zone) { + case 'planning': return 'meetings' + case 'researching': return 'library' + case 'editing': return 'desks' + case 'executing': return 'lab' + case 'waiting_approval': return 'decisions' + case 'blocked': return 'incidents' + // A completed agent is available for the next task again. Keep its green + // completed state badge, but return the character to the waiting room so + // the office does not strand finished sessions in the delivery area. + case 'completed': return 'entrance' + case 'idle': + case 'stale': return 'entrance' + case 'unknown': return 'entrance' + } +} + +function roomForAgent(agent: OfficeAgent): RoomId { + // Available or unclassified agents stay together in the waiting room. This + // also handles a graph adapter that reports a stale zone alongside idle state. + if (agent.state === 'idle' || agent.state === 'unknown' || agent.state === 'stale') return 'entrance' + return roomForZone(agent.zone) +} + +function placeAgents(agents: readonly OfficeAgent[]): PlacedAgent[] { + const grouped = new Map() + for (const room of ROOMS) grouped.set(room.id, []) + for (const agent of agents) grouped.get(roomForAgent(agent))?.push(agent) + + const placements: PlacedAgent[] = [] + for (const room of ROOMS) { + const occupants = [...(grouped.get(room.id) ?? [])] + .sort((left, right) => left.slot - right.slot || left.id.localeCompare(right.id)) + const columns = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(occupants.length)))) + const rows = Math.max(1, Math.ceil(occupants.length / columns)) + + occupants.forEach((agent, index) => { + const column = index % columns + const row = Math.floor(index / columns) + // Keep the avatar well inside its room even when many agents arrive at once. + const x = room.x + room.width * ((column + 1) / (columns + 1)) + const y = room.y + 48 + (room.height - 68) * ((row + 0.55) / rows) + placements.push({ agent, room, x, y }) + }) + } + return placements +} + +function initials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean) + return (words.slice(0, 2).map(word => word[0]).join('') || 'A').toUpperCase() +} + +function visibleName(agent: OfficeAgent): string { + return agent.workLabel || agent.name +} + +function avatarHue(key: string): number { + let value = 0 + for (let index = 0; index < key.length; index += 1) value = (value * 31 + key.charCodeAt(index)) % 360 + return value +} + +function sessionFreshness(timestamp: number): string { + if (!Number.isFinite(timestamp) || timestamp <= 0) return 'sin fecha' + const elapsed = Math.max(0, Date.now() - timestamp) + if (elapsed < 60_000) return 'ahora' + const minutes = Math.floor(elapsed / 60_000) + if (minutes < 60) return `hace ${minutes} min` + const hours = Math.floor(minutes / 60) + return `hace ${hours} h` +} + +type OfficeFilterValue = 'all' +type StateFilter = OfficeAgentState | OfficeFilterValue +type RoleFilter = AgentWorkRole | OfficeFilterValue +type OriginFilter = 'all' | 'local' | 'vps' | 'unknown' +type RuntimeFilter = 'all' | 'claude' | 'codex' | 'unknown' +type SessionFilter = 'all' | string + +export interface OfficeViewProps { + /** Privacy-scoped source. This is the preferred Office contract. */ + projection?: OfficeProjection + /** Alternative Office contract when the caller already holds the two parts. */ + agents?: ReadonlyMap + edges?: readonly OfficeEdge[] + selectedAgentId?: string | null + sessions?: readonly OfficeSession[] + selectedSessionId?: string | null + onSelectSession?: (sessionId: string) => void + connectionStatus?: OfficeConnectionStatus + onSelectAgent?: (agentId: string) => void + onClearSelection?: () => void + className?: string + ariaLabel?: string +} + +/** + * Read-only office representation of the current agent graph. + * It deliberately has no simulation or mutation controls: selecting an agent is + * delegated to its parent so the existing selection state remains authoritative. + */ +export function OfficeView({ + projection, + agents: agentMap, + edges: suppliedEdges, + selectedAgentId = null, + sessions = [], + selectedSessionId = null, + onSelectSession, + connectionStatus = 'disconnected', + onSelectAgent, + onClearSelection, + className, + ariaLabel = 'Oficina de agentes', +}: OfficeViewProps) { + const [stateFilter, setStateFilter] = useState('all') + const [roleFilter, setRoleFilter] = useState('all') + const [originFilter, setOriginFilter] = useState('all') + const [runtimeFilter, setRuntimeFilter] = useState('all') + const [sessionFilter, setSessionFilter] = useState('all') + const [search, setSearch] = useState('') + const [zoom, setZoom] = useState(1) + const sessionById = useMemo(() => new Map(sessions.map(session => [session.id, session])), [sessions]) + const agents = useMemo(() => Array.from((projection?.agents ?? agentMap ?? new Map()).values()).map(agent => { + // Session metadata is safe bounded context and fills older adapters that + // emitted an agent before attaching source/runtime to its event. + const session = agent.sessionId ? sessionById.get(agent.sessionId) : undefined + if (!session) return agent + return { + ...agent, + source: agent.source ?? session.source, + hostId: agent.hostId ?? session.hostId, + runtime: agent.runtime ?? session.runtime, + sessionLabel: agent.sessionLabel ?? session.label, + } + }), [agentMap, projection, sessionById]) + const activeSessionFilter = sessionFilter === 'all' || sessionById.has(sessionFilter) ? sessionFilter : 'all' + const visibleAgents = useMemo(() => { + const query = search.trim().toLocaleLowerCase() + return agents.filter(agent => { + if (activeSessionFilter !== 'all' && agent.sessionId !== activeSessionFilter) return false + if (stateFilter !== 'all' && agent.state !== stateFilter) return false + if (roleFilter !== 'all' && agent.workRole !== roleFilter) return false + if (originFilter !== 'all' && (agent.source ?? 'unknown') !== originFilter) return false + if (runtimeFilter !== 'all' && (agent.runtime ?? 'unknown') !== runtimeFilter) return false + if (!query) return true + return `${agent.name} ${agent.workLabel ?? ''}`.toLocaleLowerCase().includes(query) + }) + }, [activeSessionFilter, agents, originFilter, roleFilter, runtimeFilter, search, stateFilter]) + const placements = useMemo(() => placeAgents(visibleAgents), [visibleAgents]) + const placementById = useMemo(() => new Map(placements.map(placement => [placement.agent.id, placement])), [placements]) + const sessionCount = useMemo( + () => sessions.length || new Set(agents.map(agent => agent.sessionId ?? 'default-session')).size, + [agents, sessions.length], + ) + const hierarchy = useMemo( + () => (projection?.edges ?? suppliedEdges ?? []).filter(edge => placementById.has(edge.parentId) && placementById.has(edge.childId)), + [projection, suppliedEdges, placementById], + ) + const selected = selectedAgentId ? placementById.get(selectedAgentId)?.agent ?? null : null + const showStandby = agents.length === 0 + && !search.trim() + && stateFilter === 'all' + && roleFilter === 'all' + && originFilter === 'all' + && runtimeFilter === 'all' + && activeSessionFilter === 'all' + const sessionWaiting = activeSessionFilter !== 'all' + && !agents.some(agent => agent.sessionId === activeSessionFilter) + && !search.trim() + && stateFilter === 'all' + && roleFilter === 'all' + && originFilter === 'all' + && runtimeFilter === 'all' + const recentCompleted = useMemo( + () => visibleAgents.filter(agent => agent.state === 'completed').sort((a, b) => b.lastActivityAt - a.lastActivityAt).slice(0, 5), + [visibleAgents], + ) + const roomCounts = useMemo(() => { + const counts = new Map() + for (const placement of placements) counts.set(placement.room.id, (counts.get(placement.room.id) ?? 0) + 1) + return counts + }, [placements]) + + const selectAt = (nextIndex: number) => { + const next = placements[(nextIndex + placements.length) % placements.length] + if (next) onSelectAgent?.(next.agent.id) + } + + const handleAgentKeyDown = (event: KeyboardEvent, agentId: string) => { + const index = placements.findIndex(placement => placement.agent.id === agentId) + if (index < 0) return + + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault() + selectAt(index + 1) + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault() + selectAt(index - 1) + } else if (event.key === 'Home') { + event.preventDefault() + selectAt(0) + } else if (event.key === 'End') { + event.preventDefault() + selectAt(placements.length - 1) + } else if (event.key === 'Escape') { + event.preventDefault() + onClearSelection?.() + } + } + + const handleSessionSelect = (sessionId: string) => { + setSessionFilter(sessionId) + onSelectSession?.(sessionId) + } + + const clearFilters = () => { + setSearch('') + setStateFilter('all') + setRoleFilter('all') + setOriginFilter('all') + setRuntimeFilter('all') + setSessionFilter('all') + } + + const connectionLabel = connectionStatus === 'watching' ? 'LIVE' : connectionStatus === 'connected' ? 'Conectado' : 'Sin conexión' + + return ( +
+
+ {selected + ? `${visibleName(selected)}: ${STATE_LABELS[selected.state]}` + : showStandby + ? 'No hay una sesión activa. Tres agentes preparados aparecen en la sala de espera.' + : sessionWaiting + ? 'La sesión está visible, pero todavía no se ha observado ningún agente.' + : `${visibleAgents.length} agentes visibles de ${agents.length}`} +
+ + {sessions.length > 0 && ( +
+ + {sessions.map(session => { + const isSelected = activeSessionFilter === session.id + const isCurrent = selectedSessionId === session.id + const isActive = session.status === 'active' + return ( + + ) + })} +
+ )} + +
+ + {activeSessionFilter !== 'all' ? `Sesión · ${sessionById.get(activeSessionFilter)?.label ?? activeSessionFilter.slice(0, 8)}` : sessionCount > 1 ? `Todas las sesiones · ${sessionCount}` : 'Vista global'} + + + {connectionLabel} + + + + + + +
+ + {Math.round(zoom * 100)}% + + +
+ {(search || stateFilter !== 'all' || roleFilter !== 'all' || originFilter !== 'all' || runtimeFilter !== 'all' || activeSessionFilter !== 'all') && ( + + )} +
+ + {recentCompleted.length > 0 && ( +
+ Últimas entregas + {recentCompleted.map(agent => ( + + ))} +
+ )} + +
+
+ + + + +
+ {placements.map(({ agent, room, x, y }) => ( + + ))} +
+ {showStandby && ( +
+ {STANDBY_AGENTS.map(standby => )} +
+ )} + {sessionWaiting && ( +
+ Sesión preparada + Todavía no se ha observado ningún agente en esta sesión. +
+ )} + {visibleAgents.length === 0 && !showStandby && !sessionWaiting && ( +
+ Ningún agente coincide + Cambia o limpia los filtros para volver a ver agentes. +
+ )} +
+
+ +
+ Activo + Permiso + En espera + Bloqueado + Completado + {hierarchy.length} conexiones +
+ +
+ {showStandby + ? STANDBY_AGENTS.map(standby => ) + : sessionWaiting + ?
Sesión preparada · aún sin agentes observados
+ : placements.map(({ agent, room }) => ( + + ))} +
+ + {selected && ( + + )} +
+ ) +} + +interface StandbyAgentProps { + definition: StandbyAgentDefinition + mobile?: boolean +} + +function StandbyAgent({ definition, mobile = false }: StandbyAgentProps) { + const roleLabel = agentWorkRoleLabel(definition.role) + const familyLabel = modelFamilyLabel(definition.model) + const roleMark = ROLE_MARKS[definition.role] + const className = [styles.agent, styles.standbyAgent, mobile ? styles.standbyMobile : ''].filter(Boolean).join(' ') + return ( +
+
+ ) +} + +interface OfficeAgentProps { + agent: OfficeAgent + room: RoomDefinition + isSelected: boolean + onSelect?: (agentId: string) => void + onKeyDown: (event: KeyboardEvent, agentId: string) => void + style?: CSSProperties +} + +function OfficeAgent({ agent, room, isSelected, onSelect, onKeyDown, style }: OfficeAgentProps) { + const stateLabel = STATE_LABELS[agent.state] + const name = visibleName(agent) + const roleLabel = agent.workRole ? agentWorkRoleLabel(agent.workRole) : 'Especialista' + const familyLabel = modelFamilyLabel(agent.model) + const roleMark = agent.workRole ? ROLE_MARKS[agent.workRole] : ROLE_MARKS.specialist + const stateMark = STATE_MARKS[agent.state] + return ( + + ) +} diff --git a/web/components/agent-visualizer/office/index.ts b/web/components/agent-visualizer/office/index.ts new file mode 100644 index 00000000..5dc4e4c0 --- /dev/null +++ b/web/components/agent-visualizer/office/index.ts @@ -0,0 +1 @@ +export { OfficeView, type OfficeViewProps } from './OfficeView' diff --git a/web/components/agent-visualizer/office/office.module.css b/web/components/agent-visualizer/office/office.module.css new file mode 100644 index 00000000..0c969496 --- /dev/null +++ b/web/components/agent-visualizer/office/office.module.css @@ -0,0 +1,262 @@ +.office { + --ink: #18213b; + --muted: #60708f; + --paper: #f7f2e8; + --line: #d6ccb9; + --blue: #3c72b7; + position: relative; + width: 100%; + min-height: 390px; + overflow: hidden; + border: 1px solid rgba(67, 75, 96, .25); + border-radius: 16px; + background: #e6edf2; + color: var(--ink); + font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", sans-serif; +} + +.sessionRoster { display: flex; gap: 7px; overflow-x: auto; padding: 9px 12px 8px; border-bottom: 1px solid rgba(67, 75, 96, .14); background: rgba(245, 248, 249, .82); scrollbar-width: thin; } +.sessionChip { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; min-height: 32px; max-width: 245px; padding: 5px 8px; border: 1px solid rgba(77, 93, 120, .22); border-radius: 9px; background: rgba(255, 255, 255, .72); color: #536179; cursor: pointer; font: inherit; text-align: left; } +.sessionChip:hover, .sessionChip:focus-visible { border-color: rgba(58, 102, 151, .52); background: #fff; outline: none; } +.sessionChipSelected { border-color: #5e9ba8; background: #edf8f8; color: #2c5771; box-shadow: 0 2px 0 rgba(94, 155, 168, .2); } +.sessionChipCurrent { box-shadow: inset 0 -2px 0 #5e9ba8; } +.sessionChipSelected.sessionChipCurrent { box-shadow: inset 0 -2px 0 #2f8d61, 0 2px 0 rgba(94, 155, 168, .2); } +.sessionChipLabel { max-width: 116px; overflow: hidden; font-size: 10px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } +.sessionChip small { color: #7a8799; font-size: 8px; font-weight: 800; letter-spacing: .03em; white-space: nowrap; } +.sessionChipSelected small { color: #4f7586; } +.sessionDot { width: 7px; height: 7px; flex: 0 0 auto; border-radius: 50%; background: #bf7b22; box-shadow: 0 0 0 2px rgba(191, 123, 34, .14); } +.sessionDot[data-status="complete"] { background: #2f8d61; box-shadow: 0 0 0 2px rgba(47, 141, 97, .13); } + +.officeToolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 8px; padding: 10px 12px; border-bottom: 1px solid rgba(67, 75, 96, .16); background: rgba(255, 255, 255, .62); } +.scopeBadge { align-self: center; padding: 5px 8px; border: 1px solid rgba(75, 108, 146, .28); border-radius: 999px; background: rgba(231, 241, 250, .88); color: #355778; font-size: 10px; font-weight: 800; letter-spacing: .02em; white-space: nowrap; } +.connectionBadge { display: inline-flex; align-items: center; align-self: center; gap: 5px; padding: 5px 8px; border: 1px solid rgba(75, 108, 146, .22); border-radius: 999px; background: rgba(255, 255, 255, .75); color: #65748b; font-size: 9px; font-weight: 900; letter-spacing: .03em; white-space: nowrap; } +.connectionBadge i { width: 7px; height: 7px; border-radius: 50%; background: #bf7b22; } +.connectionBadge[data-status="watching"] i { background: #2f8d61; box-shadow: 0 0 0 3px rgba(47, 141, 97, .15); animation: office-live 1.6s ease-in-out infinite; } +.connectionBadge[data-status="connected"] i { background: #5e9ba8; } +.connectionBadge[data-status="disconnected"] { color: #9a5658; border-color: rgba(189, 79, 84, .25); background: #fff4f1; } +.connectionBadge[data-status="disconnected"] i { background: #bd4f54; } +.filterField { display: grid; gap: 3px; min-width: 128px; color: #5d6a82; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.filterField input, .filterField select { width: 100%; min-height: 30px; padding: 5px 8px; border: 1px solid rgba(77, 93, 120, .28); border-radius: 7px; background: rgba(255, 255, 255, .9); color: #24324e; font: inherit; letter-spacing: normal; text-transform: none; } +.filterField input:focus-visible, .filterField select:focus-visible, .zoomControls button:focus-visible, .clearFilters:focus-visible { outline: 2px solid #163d7b; outline-offset: 2px; } +.zoomControls { display: flex; align-items: center; gap: 5px; margin-left: auto; color: #536179; font-size: 11px; font-weight: 800; } +.zoomControls button, .clearFilters { min-height: 30px; padding: 4px 8px; border: 1px solid rgba(77, 93, 120, .28); border-radius: 7px; background: rgba(255, 255, 255, .9); color: #344463; cursor: pointer; font: inherit; } +.zoomControls button:first-child, .zoomControls button:nth-child(3) { width: 30px; padding: 0; font-size: 18px; line-height: 1; } +.zoomControls button:disabled { cursor: default; opacity: .45; } +.clearFilters { border-color: rgba(119, 102, 169, .45); background: #f4f0ff; color: #5c4d8d; font-weight: 800; } +.stageViewport { position: relative; overflow: hidden; background: #dce8ea; } + +.stage { + position: relative; + width: 100%; + height: 100%; + aspect-ratio: 1000 / 630; + min-height: 0; + background: + linear-gradient(135deg, rgba(255, 255, 255, .42), transparent 42%), + repeating-linear-gradient(45deg, rgba(64, 98, 122, .035) 0 1px, transparent 1px 10px), + #dce8ea; + transform: scale(var(--office-zoom, 1)); + transform-origin: center center; + transition: transform .2s ease; +} + +.floor, .edges, .agentLayer { position: absolute; inset: 0; } +.edges { z-index: 2; overflow: visible; pointer-events: none; } +.agentLayer { z-index: 3; } +.emptyState { position: absolute; z-index: 6; top: 50%; left: 50%; display: grid; gap: 5px; width: min(360px, calc(100% - 40px)); padding: 20px; border: 1px solid rgba(77, 93, 120, .2); border-radius: 12px; background: rgba(255, 255, 255, .84); box-shadow: 0 8px 24px rgba(27, 39, 59, .12); text-align: center; transform: translate(-50%, -50%); } +.emptyState strong { color: #283654; font-size: 15px; } +.emptyState span { color: #60708f; font-size: 12px; } +.deliveryStrip { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 12px; border-bottom: 1px solid rgba(67, 75, 96, .13); background: rgba(239, 248, 239, .7); color: #557261; } +.deliveryTitle { margin-right: 2px; color: #4b795b; font-size: 10px; font-weight: 900; letter-spacing: .04em; text-transform: uppercase; } +.deliveryChip { display: inline-flex; align-items: center; gap: 5px; max-width: 190px; overflow: hidden; padding: 3px 7px; border: 1px solid rgba(81, 132, 89, .25); border-radius: 999px; background: rgba(255, 255, 255, .72); color: #557261; cursor: pointer; font: inherit; font-size: 10px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } +.deliveryChip:hover, .deliveryChip:focus-visible { border-color: rgba(81, 132, 89, .55); background: #fff; outline: none; } +.deliveryChip i { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #2f8d61; } +.officeLegend { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; padding: 8px 12px; border-top: 1px solid rgba(67, 75, 96, .14); background: rgba(255, 255, 255, .58); color: #60708f; font-size: 11px; } +.officeLegend span { display: inline-flex; align-items: center; gap: 5px; } +.officeLegend i { width: 8px; height: 8px; border-radius: 50%; background: #3c72b7; } +.officeLegend i[data-tone="waiting"] { background: #bf7b22; } +.officeLegend i[data-tone="standby"] { background: #bf7b22; border: 1px dashed #fff; box-shadow: 0 0 0 1px #bf7b22; } +.officeLegend i[data-tone="blocked"] { background: #bd4f54; } +.officeLegend i[data-tone="complete"] { background: #2f8d61; } +.connectionCount { margin-left: auto; color: #536179; font-weight: 800; } + +.room { + --room-accent: #7e9b9a; + position: absolute; + left: calc(var(--room-x) * 0.1%); + top: calc(var(--room-y) / 6.3 * 1%); + width: calc(var(--room-w) * 0.1%); + height: calc(var(--room-h) / 6.3 * 1%); + overflow: hidden; + border: 2px solid #b8aa92; + border-radius: 8px 8px 14px 14px; + background: + linear-gradient(135deg, rgba(255, 255, 255, .55), transparent 44%), + repeating-linear-gradient(90deg, rgba(148, 123, 91, .08) 0 1px, transparent 1px 13px), + var(--paper); + box-shadow: 0 5px 0 #c5baa9, 0 10px 15px rgba(48, 57, 71, .12); +} + +.room::before { position: absolute; z-index: 0; top: 0; right: 0; left: 0; height: 8px; border-radius: 6px 6px 0 0; background: var(--room-accent); box-shadow: 0 2px 0 color-mix(in srgb, var(--room-accent) 40%, white); content: ''; opacity: .72; } +.room::after { + position: absolute; + right: 8%; + bottom: 10%; + width: 28%; + height: 14%; + content: ''; + border: 2px solid rgba(103, 118, 109, .5); + border-radius: 3px; + background: rgba(145, 181, 150, .2); + box-shadow: -9px -8px 0 -3px #6f9d75, -9px -8px 0 -1px rgba(49, 86, 58, .5); +} + +.roomIcon { position: absolute; z-index: 1; top: 10px; left: 9px; display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid color-mix(in srgb, var(--room-accent) 55%, #ffffff); border-radius: 6px; background: color-mix(in srgb, var(--room-accent) 18%, #ffffff); color: color-mix(in srgb, var(--room-accent) 82%, #25334d); font-size: 11px; font-weight: 900; } +.roomLabel, .roomHint { position: absolute; left: 34px; z-index: 1; } +.roomLabel { top: 10px; color: #6f5d48; font-size: clamp(9px, 1vw, 13px); font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.roomHint { top: 29px; color: #887b6c; font-size: clamp(7px, .75vw, 10px); } +.roomCount { position: absolute; z-index: 1; top: 8px; right: 8px; min-width: 18px; padding: 2px 5px; border-radius: 999px; background: rgba(99, 83, 62, .13); color: #6f5d48; font-size: 9px; font-weight: 900; text-align: center; } +.roomFurniture { position: absolute; left: 11%; bottom: 10%; width: 30%; height: 17%; border: 2px solid rgba(116, 91, 67, .55); border-radius: 4px; background: rgba(164, 125, 84, .18); } +.roomFurniture::before { position: absolute; right: 12%; bottom: 100%; width: 22%; height: 55%; border: 1px solid rgba(79, 109, 84, .55); border-bottom: 0; border-radius: 50% 50% 0 0; background: rgba(110, 160, 117, .35); content: ''; } +.roomFurniture::after { position: absolute; right: -10%; bottom: -8px; width: 38%; height: 5px; border-radius: 50%; background: rgba(77, 86, 96, .18); content: ''; } +.room_entrance { --room-accent: #5e9ba8; } +.room_meetings { --room-accent: #aa8b5f; } +.room_library { --room-accent: #7086b6; } +.room_lab { --room-accent: #5b9ca5; } +.room_desks { --room-accent: #c28b5c; } +.room_decisions { --room-accent: #9b76bc; } +.room_incidents { --room-accent: #bf6d5f; } +.room_deliveries { --room-accent: #6eaa7a; } +.room_meetings .roomFurniture { width: 50%; left: 25%; border-radius: 50%; } +.room_library .roomFurniture { width: 48%; height: 23%; border-radius: 2px; box-shadow: inset 0 4px #c29368, inset 0 8px #7991ae, inset 0 12px #bb7c71; } +.room_lab .roomFurniture { background: rgba(119, 181, 191, .18); } +.room_lab .roomFurniture::before { right: 18%; width: 25%; height: 80%; border-radius: 45% 45% 20% 20%; background: rgba(89, 161, 177, .35); } +.room_desks .roomFurniture { width: 25%; height: 14%; } +.room_decisions .roomFurniture { width: 55%; left: 22%; border-radius: 50%; } +.room_incidents .roomFurniture { width: 34%; border-color: rgba(174, 91, 79, .58); background: rgba(218, 139, 114, .18); } +.room_deliveries .roomFurniture { width: 35%; left: 32%; border-color: rgba(81, 132, 89, .55); background: rgba(126, 183, 131, .2); } +.room_incidents { background: linear-gradient(135deg, #fff7e9, #f6e7dc); } +.room_decisions { background: linear-gradient(135deg, #f7efff, #ebe2f7); } +.room_deliveries { background: linear-gradient(135deg, #eff8ef, #e3f0e3); } + +.edge { fill: none; stroke: #7766a9; stroke-dasharray: 5 5; stroke-linecap: round; stroke-width: 1.7; animation: office-dash 1.8s linear infinite; } +.edgeArrow { fill: #7766a9; } +.standbyLayer { position: absolute; z-index: 4; inset: 0; pointer-events: none; } +.standbyAgent { cursor: default; opacity: .9; filter: saturate(.74); } +.standbyAgent:hover { transform: translate(-50%, -50%); filter: saturate(.82); } +.standbyAgent .avatar { animation: office-standby 2.8s ease-in-out infinite; } +.standbyAgent .avatar::after { background: #bf7b22; animation: office-standby-dot 1.8s ease-in-out infinite; } +.standbyAgent .stateBadge { border-style: dashed; } + +.agent { + --accent: #3c72b7; + position: absolute; + left: var(--agent-x); + top: var(--agent-y); + display: grid; + justify-items: center; + width: min(15vw, 132px); + min-width: 54px; + padding: 0; + border: 0; + background: transparent; + color: var(--ink); + cursor: pointer; + transform: translate(-50%, -50%); + transition: transform .18s ease, filter .18s ease; +} +.agent[data-room="entrance"] { width: min(8vw, 68px); min-width: 40px; } +.agent:hover { transform: translate(-50%, -53%); filter: brightness(1.04); } +.agent:focus-visible { outline: 3px solid #163d7b; outline-offset: 4px; border-radius: 8px; } +.agent[aria-pressed="true"] { transform: translate(-50%, -54%) scale(1.08); z-index: 5; } +.agent[data-state="planning"] { --accent: #7766a9; } +.agent[data-state="researching"] { --accent: #5b74c7; } +.agent[data-state="editing"] { --accent: #2e83a6; } +.agent[data-state="executing"], .agent[data-state="waiting_approval"] { --accent: #bf7b22; } +.agent[data-state="completed"] { --accent: #2f8d61; } +.agent[data-state="blocked"] { --accent: #bd4f54; } +.agent[data-state="stale"] { --accent: #788196; } + +.avatar { position: relative; display: block; width: clamp(25px, 3.6vw, 40px); height: clamp(34px, 4.9vw, 53px); filter: drop-shadow(2px 3px 0 rgba(42, 48, 64, .18)); } +.avatar::after { position: absolute; z-index: 3; right: -4%; bottom: 2%; width: 7px; height: 7px; border: 2px solid #fff; border-radius: 50%; background: var(--accent); box-shadow: 0 1px 3px rgba(31, 42, 64, .25); content: ''; } +.face { position: absolute; top: 11%; left: 21%; display: grid; place-items: center; width: 58%; height: 45%; overflow: hidden; border: 2px solid #4a3e39; border-radius: 46% 46% 43% 43%; background: #efc29f; color: #4a3e39; font-size: clamp(5px, .7vw, 8px); font-weight: 900; line-height: 1; } +.hair { position: absolute; z-index: 1; top: 4%; left: 18%; width: 64%; height: 28%; border-radius: 60% 50% 20% 16%; background: #4e4852; } +.body { position: absolute; bottom: 0; left: 11%; width: 78%; height: 47%; border: 2px solid #314861; border-radius: 45% 45% 10% 10%; background: var(--accent); } +.avatarAccessory { position: absolute; z-index: 4; top: 31%; right: 3%; display: grid; place-items: center; width: 15px; height: 15px; border: 1px solid rgba(49, 72, 97, .55); border-radius: 50%; background: rgba(255, 255, 255, .88); color: #314861; font-size: 8px; font-weight: 900; line-height: 1; } +.agent[data-avatar="terra"] .hair { background: #5a3d2d; border-radius: 65% 42% 22% 18%; } +.agent[data-avatar="terra"] .body { background: #416b9d; } +.agent[data-avatar="luna"] .hair { background: #443f68; border-radius: 35% 68% 18% 30%; } +.agent[data-avatar="luna"] .body { background: #8b639f; } +.agent[data-avatar="generated"] .body { background: hsl(var(--avatar-hue) 43% 48%); } +.agent[data-state="planning"] .avatar, .agent[data-state="researching"] .avatar { animation: office-think 1.3s ease-in-out infinite; } +.agent[data-state="editing"] .avatar { animation: office-type 1.1s ease-in-out infinite; } +.agent[data-state="executing"] .avatar { animation: office-work 1.5s ease-in-out infinite; } +.agent[data-state="waiting_approval"] .avatar::after { animation: office-alert 1.1s ease-in-out infinite; } +.agent[data-state="blocked"] .avatar { animation: office-wobble .8s ease-in-out infinite; } +.agent[data-state="completed"] .avatar::after { background: #2f8d61; } +.agent[data-state="executing"] .body { background: repeating-linear-gradient(45deg, var(--accent), var(--accent) 3px, #ffd082 3px 6px); } +.agent[data-state="blocked"] .face { background: #f0a2a0; } + +.agent[data-role="security"] .avatarAccessory { background: #e8f0ff; } +.agent[data-role="designer"] .avatarAccessory { background: #fff0f8; } +.agent[data-role="researcher"] .avatarAccessory { background: #eef3ff; } +.agent[data-role="validator"] .avatarAccessory, .agent[data-role="integrator"] .avatarAccessory { background: #edfaef; } + +.agentName { max-width: 100%; margin-top: 2px; overflow: hidden; color: #273450; font-size: clamp(8px, .93vw, 12px); font-weight: 800; line-height: 1.1; text-overflow: ellipsis; white-space: nowrap; } +.roleBadge, .stateBadge { max-width: 100%; overflow: hidden; border-radius: 999px; text-overflow: ellipsis; white-space: nowrap; } +.roleBadge { margin-top: 2px; padding: 2px 4px; background: rgba(255, 255, 255, .72); color: #5b6680; font-size: clamp(6px, .64vw, 9px); font-weight: 700; line-height: 1; } +.roleBadge span, .stateBadge span { margin-right: 2px; } +.stateBadge { margin-top: 2px; padding: 2px 4px; border: 1px solid color-mix(in srgb, var(--accent) 42%, white); background: color-mix(in srgb, var(--accent) 14%, white); color: color-mix(in srgb, var(--accent) 85%, #18213b); font-size: clamp(6px, .66vw, 9px); font-weight: 700; line-height: 1; } +.sessionBadge { max-width: 100%; margin-top: 2px; overflow: hidden; color: #68758a; font-size: clamp(6px, .58vw, 8px); font-weight: 700; line-height: 1; text-overflow: ellipsis; white-space: nowrap; } +.sourceBadge { display: inline-flex; align-self: center; margin-top: 3px; padding: 1px 4px; border: 1px solid rgba(57, 87, 126, .22); border-radius: 999px; background: rgba(236, 243, 255, .8); color: #486487; font-size: clamp(5px, .52vw, 7px); font-weight: 800; letter-spacing: .06em; line-height: 1.1; } + +.detail { position: absolute; z-index: 8; right: 14px; bottom: 14px; width: min(280px, calc(100% - 28px)); padding: 10px 12px; border: 1px solid rgba(70, 80, 104, .28); border-radius: 10px; background: rgba(255, 255, 255, .93); box-shadow: 0 8px 24px rgba(27, 39, 59, .18); font-size: 12px; } +.detailTitle { display: flex; align-items: center; gap: 7px; } +.detailTitle strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.detailDot { width: 8px; height: 8px; border-radius: 50%; background: #3c72b7; } +.detailDot[data-state="completed"] { background: #2f8d61; }.detailDot[data-state="blocked"] { background: #bd4f54; }.detailDot[data-state="waiting_approval"], .detailDot[data-state="executing"] { background: #bf7b22; } +.dismiss { margin-left: auto; border: 0; border-radius: 4px; background: transparent; color: #536179; cursor: pointer; font-size: 19px; line-height: 1; } +.dismiss:focus-visible { outline: 2px solid #163d7b; } +.zoneLabel, .sourceName, .modelName, .agentId { display: block; margin-top: 5px; color: var(--muted); font-size: 11px; } +.modelName, .agentId { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.modelName { color: #485b7a; font-family: ui-monospace, monospace; } +.agentId { color: #6d778a; font-size: 10px; } + +.mobileList { display: none; } +.mobileWaiting { grid-column: 1 / -1; padding: 24px 12px; border: 1px dashed rgba(77, 93, 120, .28); border-radius: 9px; background: rgba(255, 255, 255, .58); color: #60708f; font-size: 12px; text-align: center; } +.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@keyframes office-dash { to { stroke-dashoffset: -20; } } +@keyframes office-think { 50% { transform: translateY(-3px) rotate(-1deg); } } + +@media (max-width: 760px) { + .office { min-height: 0; overflow: visible; background: transparent; border: 0; border-radius: 0; } + .officeToolbar { align-items: stretch; } + .filterField { flex: 1 1 125px; } + .zoomControls { margin-left: 0; } + .clearFilters { flex: 0 0 auto; } + .stageViewport { display: none; } + .mobileList { display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: 9px; padding: 12px; border: 1px solid rgba(67, 75, 96, .22); border-radius: 12px; background: #e6edf2; } + .mobileList .agent { position: relative; left: auto; top: auto; grid-template-columns: 36px minmax(0, 1fr); grid-template-rows: auto auto; justify-items: start; width: 100%; min-width: 0; padding: 7px; border: 1px solid rgba(68, 85, 110, .2); border-radius: 8px; background: rgba(255, 255, 255, .7); transform: none; text-align: left; } + .mobileList .agent:hover, .mobileList .agent[aria-pressed="true"] { transform: translateY(-1px); } + .mobileList .avatar { grid-row: 1 / span 2; width: 30px; height: 40px; } + .mobileList .agentName { align-self: end; font-size: 12px; } + .mobileList .roleBadge { align-self: start; margin-top: 1px; } + .mobileList .stateBadge { grid-column: 2; align-self: start; margin-top: 1px; } + .mobileList .standbyAgent { opacity: .88; } + .mobileList .standbyAgent:hover { transform: translateY(-1px); } + .detail { position: relative; right: auto; bottom: auto; width: auto; margin-top: 9px; } + .officeLegend { border: 1px solid rgba(67, 75, 96, .22); border-top: 0; border-radius: 0 0 12px 12px; } +} + +@media (prefers-reduced-motion: reduce) { + .edge, .agent, .stage, .connectionBadge[data-status="watching"] i, .agent[data-state="researching"] .avatar, .agent[data-state="planning"] .avatar, .agent[data-state="editing"] .avatar, .agent[data-state="executing"] .avatar, .agent[data-state="blocked"] .avatar, .agent[data-state="waiting_approval"] .avatar::after { animation: none; transition: none; } +} + +@keyframes office-type { 50% { transform: translateY(1px) rotate(1deg); } } +@keyframes office-work { 50% { transform: translateY(-2px) rotate(1deg); } } +@keyframes office-wobble { 25% { transform: translateX(-2px) rotate(-2deg); } 75% { transform: translateX(2px) rotate(2deg); } } +@keyframes office-alert { 50% { transform: scale(1.35); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent); } } +@keyframes office-standby { 50% { transform: translateY(-2px); } } +@keyframes office-standby-dot { 50% { opacity: .48; } } +@keyframes office-live { 50% { opacity: .46; } } diff --git a/web/hooks/simulation/handle-agent-events.test.ts b/web/hooks/simulation/handle-agent-events.test.ts new file mode 100644 index 00000000..901cab69 --- /dev/null +++ b/web/hooks/simulation/handle-agent-events.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + handleAgentComplete, + handleAgentIdle, + handleAgentSpawn, + handleModelDetected, + handlePermissionRequested, +} from './handle-agent-events' +import { handleSubagentDispatch, handleSubagentReturn } from './handle-subagent-events' +import { createEmptyState } from './types' +import type { ProcessEventContext } from './process-event' + +const context = (): ProcessEventContext => ({ + syncForceSimulation: () => {}, + findToolSlot: () => ({ x: 0, y: 0 }), + getContextWindowSize: () => 100, + blockIdCounter: { current: 0 }, + skipForceSync: true, +}) + +test('uses stable Codex ids for duplicate display names and lifecycle events', () => { + const state = createEmptyState() + const ctx = context() + + handleAgentSpawn({ id: 'root-1', name: 'Orchestrator', isMain: true }, 0, state, ctx) + handleAgentSpawn({ id: 'worker-1', name: 'worker', parentId: 'root-1' }, 1, state, ctx) + handleAgentSpawn({ agentId: 'worker-2', name: 'worker', parentId: 'root-1' }, 2, state, ctx) + // A repeated spawn for the same id reactivates that node; it must not merge + // with the other node that happens to have the same display name. + handleAgentSpawn({ id: 'worker-1', name: 'worker', parentId: 'root-1' }, 3, state, ctx) + + assert.equal(state.agents.size, 3) + assert.equal(state.agents.get('worker-1')?.name, 'worker') + assert.equal(state.agents.get('worker-2')?.name, 'worker') + assert.equal(state.agents.get('worker-1')?.parentId, 'root-1') + assert.equal(state.agents.get('worker-2')?.parentId, 'root-1') + assert.deepEqual( + state.edges.map(edge => edge.id), + ['edge-root-1-worker-1', 'edge-root-1-worker-2'], + ) + + handleSubagentDispatch({ + parentId: 'root-1', childId: 'worker-2', parent: 'worker', child: 'worker', task: 'inspect', + }, 4, state) + handleSubagentReturn({ + parentId: 'root-1', childId: 'worker-2', parent: 'worker', child: 'worker', summary: 'done', + }, 5, state) + assert.equal(state.particles[0]?.edgeId, 'edge-root-1-worker-2') + assert.equal(state.particles[1]?.edgeId, 'edge-root-1-worker-2') + + handleModelDetected({ agentId: 'worker-2', agent: 'worker', model: 'gpt-5.6-terra' }, state, ctx) + assert.equal(state.agents.get('worker-2')?.model, 'gpt-5.6-terra') + assert.equal(state.agents.get('worker-1')?.model, undefined) + + handlePermissionRequested({ agentId: 'worker-2', agent: 'worker' }, 6, state, ctx) + assert.equal(state.agents.get('worker-2')?.state, 'waiting_permission') + handleAgentIdle({ agentId: 'worker-2', name: 'worker' }, state) + assert.equal(state.agents.get('worker-2')?.state, 'thinking') + + handleAgentComplete({ id: 'worker-2', name: 'worker' }, 7, state, ctx) + assert.equal(state.agents.get('worker-2')?.state, 'complete') + assert.notEqual(state.agents.get('worker-1')?.state, 'complete') +}) + +test('retains name-based Claude relation fallback', () => { + const state = createEmptyState() + const ctx = context() + handleAgentSpawn({ name: 'orchestrator', isMain: true }, 0, state, ctx) + handleAgentSpawn({ name: 'worker', parent: 'orchestrator' }, 1, state, ctx) + + handleSubagentDispatch({ parent: 'orchestrator', child: 'worker', task: 'inspect' }, 2, state) + assert.equal(state.particles[0]?.edgeId, 'edge-orchestrator-worker') + handleAgentComplete({ name: 'worker' }, 3, state, ctx) + assert.equal(state.agents.get('worker')?.state, 'complete') +}) diff --git a/web/hooks/simulation/handle-agent-events.ts b/web/hooks/simulation/handle-agent-events.ts index cd7f562e..20c661ed 100644 --- a/web/hooks/simulation/handle-agent-events.ts +++ b/web/hooks/simulation/handle-agent-events.ts @@ -7,6 +7,43 @@ import { COLORS } from '@/lib/colors' import { AGENT_SPAWN_DISTANCE } from '@/lib/canvas-constants' import { pushTimelineBlock, type ProcessEventContext, type MutableEventState } from './process-event' import { edgeId, asString, asBoolean } from './types' +import { formatAgentWorkLabel, inferAgentWorkRole, isAgentWorkRole, type AgentWorkRole } from '@/lib/agent-role' + +/** Return the first non-empty string in a list of payload fields. */ +function firstString(payload: Record, fields: string[]): string | undefined { + for (const field of fields) { + const value = payload[field] + if (typeof value === 'string' && value.length > 0) return value + } + return undefined +} + +function workRoleFromPayload(payload: Record, isMain: boolean, ...hints: unknown[]): AgentWorkRole { + if (isMain) return 'orchestrator' + if (isAgentWorkRole(payload.workRole)) return payload.workRole + return inferAgentWorkRole(payload.task, payload.tool, payload.name, payload.model, ...hints) +} + +/** + * Resolve an event's internal agent key. Codex events carry a stable id, + * whereas the legacy Claude protocol identifies agents by their display name. + * An explicit id always wins; name lookup is only the compatibility path. + */ +function resolveAgentId(payload: Record, state: MutableEventState): string { + const explicitId = firstString(payload, ['id', 'agentId']) + if (explicitId) return explicitId + + const legacyReference = firstString(payload, ['agent', 'name']) + if (!legacyReference) return '' + if (state.agents.has(legacyReference)) return legacyReference + + // A legacy-shaped event can still arrive after a stable-id spawn. Resolve + // its display name only when there is no explicit id to disambiguate it. + for (const [id, agent] of state.agents) { + if (agent.name === legacyReference) return id + } + return legacyReference +} export function handleAgentSpawn( payload: Record, @@ -14,23 +51,32 @@ export function handleAgentSpawn( state: MutableEventState, ctx: ProcessEventContext, ): void { - const name = asString(payload.name) - const parentId = typeof payload.parent === 'string' ? payload.parent : undefined + const displayName = firstString(payload, ['name']) + const agentId = firstString(payload, ['id', 'agentId']) || displayName + if (!agentId) return + const name = displayName || agentId + const parentId = firstString(payload, ['parentId', 'parent']) const isMain = asBoolean(payload.isMain) const task = typeof payload.task === 'string' ? payload.task : undefined const model = typeof payload.model === 'string' ? payload.model : undefined const runtime = payload.runtime === 'codex' ? 'codex' as const : undefined + const workRole = workRoleFromPayload(payload, isMain, displayName, agentId) + const workLabel = formatAgentWorkLabel(workRole, model) // If the agent already exists (e.g. session resuming after inactivity), // reactivate it instead of replacing — preserves accumulated stats. - const existing = state.agents.get(name) + const existing = state.agents.get(agentId) if (existing) { - state.agents.set(name, { + state.agents.set(agentId, { ...existing, + ...(displayName ? { name: displayName } : {}), + ...(parentId !== undefined ? { parentId } : {}), state: 'idle', ...(task ? { task } : {}), ...(model ? { model, tokensMax: ctx.getContextWindowSize(model) } : {}), ...(runtime ? { runtime } : {}), + workRole, + workLabel, }) return } @@ -42,7 +88,7 @@ export function handleAgentSpawn( // Collect angles of existing siblings so we can avoid spawning too close const siblingAngles: number[] = [] for (const a of state.agents.values()) { - if (a.parentId === parentId && a.id !== name) { + if (a.parentId === parentId && a.id !== agentId) { siblingAngles.push(Math.atan2(a.y - parent.y, a.x - parent.x)) } } @@ -50,7 +96,7 @@ export function handleAgentSpawn( let angle: number if (siblingAngles.length === 0) { // First child: use hash-based angle - const hash = name.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0) + const hash = agentId.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0) angle = (Math.abs(hash) % 360) * (Math.PI / 180) } else { // Find the largest angular gap between existing siblings and place in the middle @@ -74,7 +120,7 @@ export function handleAgentSpawn( } const agent: Agent = { - id: name, name, state: 'idle', + id: agentId, name, state: 'idle', parentId: parentId || null, tokensUsed: 0, tokensMax: ctx.getContextWindowSize(model), contextBreakdown: emptyContextBreakdown(), @@ -83,28 +129,30 @@ export function handleAgentSpawn( pinned: false, isMain, ...(runtime ? { runtime } : {}), ...(model ? { model } : {}), + workRole, + workLabel, task, spawnTime: currentTime, opacity: 0, scale: 0.3, messageBubbles: [], } - state.agents.set(name, agent) + state.agents.set(agentId, agent) if (parentId) { - state.edges.push({ id: edgeId(parentId, name), from: parentId, to: name, type: 'parent-child', opacity: 0 }) + state.edges.push({ id: edgeId(parentId, agentId), from: parentId, to: agentId, type: 'parent-child', opacity: 0 }) } const timelineEntry: TimelineEntry = { - id: `timeline-${name}`, - agentId: name, + id: `timeline-${agentId}`, + agentId, agentName: name, startTime: currentTime, blocks: [], } pushTimelineBlock(timelineEntry, currentTime, { type: 'idle', label: 'Starting', color: COLORS.idle }, ctx) - state.timelineEntries.set(name, timelineEntry) + state.timelineEntries.set(agentId, timelineEntry) - state.conversations.set(name, []) + state.conversations.set(agentId, []) if (!ctx.skipForceSync) { setTimeout(() => ctx.syncForceSimulation(state.agents, state.edges), 0) @@ -117,20 +165,20 @@ export function handleAgentComplete( state: MutableEventState, ctx: ProcessEventContext, ): void { - const name = asString(payload.name) - const agent = state.agents.get(name) + const agentId = resolveAgentId(payload, state) + const agent = state.agents.get(agentId) if (agent && agent.state !== 'complete') { - state.agents.set(name, { ...agent, state: 'complete', completeTime: currentTime }) + state.agents.set(agentId, { ...agent, state: 'complete', completeTime: currentTime }) - const entry = state.timelineEntries.get(name) + const entry = state.timelineEntries.get(agentId) if (entry) { pushTimelineBlock(entry, currentTime, { type: 'complete', label: 'Done', color: COLORS.complete, endTime: currentTime }, ctx) entry.endTime = currentTime } - const agentsToComplete = [name] + const agentsToComplete = [agentId] for (const [childId, childAgent] of state.agents) { - if (childAgent.parentId === name && childAgent.state !== 'complete') { + if (childAgent.parentId === agentId && childAgent.state !== 'complete') { state.agents.set(childId, { ...childAgent, state: 'complete', completeTime: currentTime }) agentsToComplete.push(childId) const childEntry = state.timelineEntries.get(childId) @@ -155,15 +203,15 @@ export function handlePermissionRequested( state: MutableEventState, ctx: ProcessEventContext, ): void { - const agentName = asString(payload.agent, 'Orchestrator') - const agent = state.agents.get(agentName) + const agentId = resolveAgentId(payload, state) || 'Orchestrator' + const agent = state.agents.get(agentId) if (agent && agent.state !== 'complete') { - state.agents.set(agentName, { + state.agents.set(agentId, { ...agent, state: 'waiting_permission', }) - const entry = state.timelineEntries.get(agentName) + const entry = state.timelineEntries.get(agentId) if (entry) { pushTimelineBlock(entry, currentTime, { type: 'idle', label: 'Permission', color: COLORS.waiting_permission }, ctx) } @@ -174,10 +222,10 @@ export function handleAgentIdle( payload: Record, state: MutableEventState, ): void { - const idleName = asString(payload.name) - const idleAgent = state.agents.get(idleName) + const idleId = resolveAgentId(payload, state) + const idleAgent = state.agents.get(idleId) if (idleAgent && (idleAgent.state === 'tool_calling' || idleAgent.state === 'waiting_permission')) { - state.agents.set(idleName, { ...idleAgent, state: 'thinking', currentTool: undefined }) + state.agents.set(idleId, { ...idleAgent, state: 'thinking', currentTool: undefined }) } } @@ -186,13 +234,16 @@ export function handleModelDetected( state: MutableEventState, ctx: ProcessEventContext, ): void { - const agentName = asString(payload.agent) + const agentId = resolveAgentId(payload, state) const model = asString(payload.model) - const agent = state.agents.get(agentName) + const agent = state.agents.get(agentId) if (agent) { - state.agents.set(agentName, { + const workRole = agent.workRole || inferAgentWorkRole(model, agent.name) + state.agents.set(agentId, { ...agent, model, + workRole, + workLabel: formatAgentWorkLabel(workRole, model), tokensMax: ctx.getContextWindowSize(model), }) } diff --git a/web/hooks/simulation/handle-subagent-events.ts b/web/hooks/simulation/handle-subagent-events.ts index 73616ac7..90ee528d 100644 --- a/web/hooks/simulation/handle-subagent-events.ts +++ b/web/hooks/simulation/handle-subagent-events.ts @@ -2,14 +2,21 @@ import { COLORS } from '@/lib/colors' import type { MutableEventState } from './process-event' import { edgeId, asString, LABEL_LEN_SHORT } from './types' +/** Codex supplies stable relation ids; Claude events only have display names. */ +function relationId(payload: Record, idField: string, nameField: string): string { + return typeof payload[idField] === 'string' && payload[idField] + ? payload[idField] as string + : asString(payload[nameField]) +} + export function handleSubagentDispatch( payload: Record, currentTime: number, state: MutableEventState, ): void { - const parentName = asString(payload.parent) - const childName = asString(payload.child) - const eid = edgeId(parentName, childName) + const parentId = relationId(payload, 'parentId', 'parent') + const childId = relationId(payload, 'childId', 'child') + const eid = edgeId(parentId, childId) const task = asString(payload.task) state.particles.push({ @@ -26,9 +33,9 @@ export function handleSubagentReturn( currentTime: number, state: MutableEventState, ): void { - const parentName = asString(payload.parent) - const childName = asString(payload.child) - const eid = edgeId(parentName, childName) + const parentId = relationId(payload, 'parentId', 'parent') + const childId = relationId(payload, 'childId', 'child') + const eid = edgeId(parentId, childId) const summary = asString(payload.summary) state.particles.push({ diff --git a/web/hooks/simulation/handle-tool-events.ts b/web/hooks/simulation/handle-tool-events.ts index fe222924..9d03c374 100644 --- a/web/hooks/simulation/handle-tool-events.ts +++ b/web/hooks/simulation/handle-tool-events.ts @@ -2,6 +2,7 @@ import { COLORS } from '@/lib/colors' import { TOOL_DEDUP_WINDOW_S } from '@/lib/canvas-constants' import { pushTimelineBlock, type ProcessEventContext, type MutableEventState } from './process-event' import { appendConversation, asString, asBoolean, LABEL_LEN_PARTICLE, LABEL_LEN_TIMELINE } from './types' +import { formatAgentWorkLabel, inferAgentWorkRole } from '@/lib/agent-role' /** Extract file path from tool input data or fall back to first token of args */ function extractFilePath(inputData?: Record, args?: string): string { @@ -33,10 +34,13 @@ export function handleToolCallStart( } if (isDuplicate) return + const workRole = agent.workRole || inferAgentWorkRole(toolName, agent.task, agent.name) state.agents.set(agentName, { ...agent, state: 'tool_calling', currentTool: toolName, + workRole, + workLabel: formatAgentWorkLabel(workRole, agent.model), toolCalls: agent.toolCalls + 1 }) diff --git a/web/hooks/use-vscode-bridge.ts b/web/hooks/use-vscode-bridge.ts index e3a589c4..67750955 100644 --- a/web/hooks/use-vscode-bridge.ts +++ b/web/hooks/use-vscode-bridge.ts @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useCallback, useRef } from 'react' +import { useEffect, useState, useCallback, useMemo, useRef } from 'react' import { vscodeBridge, type ConnectionStatus, type AgentEvent, type SessionInfo } from '@/lib/vscode-bridge' import { SimulationEvent } from '@/lib/agent-types' @@ -27,6 +27,8 @@ interface BridgeHookResult { flushSessionEvents: (sessionId: string, fromIndex?: number) => void /** Get the current event count for a session (for save/restore) */ getSessionEventCount: (sessionId: string) => number + /** Read-only snapshots of every session event buffer for aggregate views. */ + sessionEvents: ReadonlyMap /** Ref to the currently selected session ID — updated synchronously, not via React state */ selectedSessionIdRef: React.RefObject /** Session IDs that have received events while not selected */ @@ -51,13 +53,15 @@ export function useVSCodeBridge(): BridgeHookResult { ) const [disable1MContext, setDisable1MContext] = useState(false) const pendingEventsRef = useRef([]) - const [, setEventVersion] = useState(0) // trigger re-render on new events + const [eventVersion, setEventVersion] = useState(0) // trigger re-render on new events // Session state const [sessions, setSessions] = useState([]) const [selectedSessionId, setSelectedSessionId] = useState(null) const selectedSessionIdRef = useRef(null) const sessionEventsRef = useRef>(new Map()) + const lastSequenceRef = useRef>(new Map()) + const lastSessionTouchRef = useRef>(new Map()) /** True while a session switch is pending (between auto-select and useLayoutEffect). * Prevents the animation frame from processing events in the wrong simulation context. */ const sessionSwitchPendingRef = useRef(false) @@ -72,12 +76,15 @@ export function useVSCodeBridge(): BridgeHookResult { // Skip in VS Code — extension handles events via postMessage if (bridge.isVSCode) return - // Connect to relay in dev mode or standalone CLI mode + // Connect to relay in dev mode, standalone CLI mode, or hosted VPS mode. const isStandalone = process.env.AGENT_FLOW_STANDALONE === '1' - if (!isStandalone && (process.env.NODE_ENV !== 'development' || process.env.NEXT_PUBLIC_DEMO !== '0')) return + const hostedRelayUrl = process.env.NEXT_PUBLIC_RELAY_URL || '' + const isDevRelay = process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_DEMO === '0' + if (!isStandalone && !hostedRelayUrl && !isDevRelay) return const relayPort = process.env.NEXT_PUBLIC_RELAY_PORT || '' - const es = new EventSource(relayPort ? `http://127.0.0.1:${relayPort}/events` : '/events') + const relayUrl = hostedRelayUrl || (relayPort ? `http://127.0.0.1:${relayPort}/events` : '/events') + const es = new EventSource(relayUrl) es.onopen = () => { setConnectionStatus('connected') @@ -112,11 +119,23 @@ export function useVSCodeBridge(): BridgeHookResult { // selectedSessionIdRef is updated synchronously (not via React state) so it's // always current even before React re-renders. const unsubEvent = bridge.onEvent((event: AgentEvent) => { + // Reconnects can replay the last relay batch. Sequence numbers are + // optional for older producers, but when present they make buffering + // idempotent and keep the Office projection from doing duplicate work. + if (event.sessionId && Number.isFinite(event.sequence)) { + const previous = lastSequenceRef.current.get(event.sessionId) + if (previous !== undefined && event.sequence! <= previous) return + lastSequenceRef.current.set(event.sessionId, event.sequence!) + } const simEvent: SimulationEvent = { time: event.time, type: event.type as SimulationEvent['type'], payload: event.payload, sessionId: event.sessionId, + source: event.source, + hostId: event.hostId, + runtime: event.runtime, + sequence: event.sequence, } // Always buffer by session (for replay on session switch) @@ -124,15 +143,47 @@ export function useVSCodeBridge(): BridgeHookResult { const buf = sessionEventsRef.current.get(event.sessionId) || [] buf.push(simEvent) sessionEventsRef.current.set(event.sessionId, buf) + + // Keep the roster's freshness indicator useful without allocating a + // new SessionInfo array for every high-frequency tool event. + const now = Date.now() + const previousTouch = lastSessionTouchRef.current.get(event.sessionId) ?? 0 + if (now - previousTouch >= 1_000) { + lastSessionTouchRef.current.set(event.sessionId, now) + setSessions(prev => { + const known = prev.some(session => session.id === event.sessionId) + if (known) { + return prev.map(session => session.id === event.sessionId + ? { ...session, status: 'active' as const, lastActivityTime: now, source: event.source ?? session.source, hostId: event.hostId ?? session.hostId, runtime: event.runtime ?? session.runtime } + : session) + } + // A lifecycle message can be lost during a reconnect. The event + // itself is enough evidence to add a bounded, non-prompt label. + const shortId = event.sessionId!.slice(0, 8) || 'desconocida' + return [...prev, { + id: event.sessionId!, + label: `Sesión ${shortId}`, + status: 'active' as const, + startTime: now, + lastActivityTime: now, + source: event.source, + hostId: event.hostId, + runtime: event.runtime, + }] + }) + } } + // Aggregate Office projections need to refresh for background sessions as + // well as the currently selected one. + setEventVersion(v => v + 1) + // Deliver to pending if session matches (ref is always current). // Skip if a session switch is pending — useLayoutEffect will flush // from the session buffer once the simulation state is swapped. const selected = selectedSessionIdRef.current if (selected && event.sessionId === selected && !sessionSwitchPendingRef.current) { pendingEventsRef.current.push(simEvent) - setEventVersion(v => v + 1) } else if (event.sessionId && event.sessionId !== selected) { // Track background activity for unselected sessions setSessionsWithActivity(prev => { @@ -177,6 +228,8 @@ export function useVSCodeBridge(): BridgeHookResult { selectedSessionIdRef.current = null pendingEventsRef.current.length = 0 sessionEventsRef.current.clear() + lastSequenceRef.current.clear() + lastSessionTouchRef.current.clear() setSessionsWithActivity(new Set()) dismissedSessionsRef.current.clear() setEventVersion(v => v + 1) @@ -279,6 +332,14 @@ export function useVSCodeBridge(): BridgeHookResult { return sessionEventsRef.current.get(sessionId)?.length ?? 0 }, []) + const sessionEvents = useMemo(() => { + const snapshot = new Map() + for (const [sessionId, events] of sessionEventsRef.current) { + snapshot.set(sessionId, events.slice()) + } + return snapshot + }, [eventVersion, sessions, sessionsWithActivity]) + const dismissedSessionsRef = useRef>(new Map()) const removeSession = useCallback((sessionId: string) => { @@ -313,6 +374,7 @@ export function useVSCodeBridge(): BridgeHookResult { selectSession, flushSessionEvents, getSessionEventCount, + sessionEvents, sessionsWithActivity, removeSession, } diff --git a/web/lib/agent-role.test.ts b/web/lib/agent-role.test.ts new file mode 100644 index 00000000..3634b1c9 --- /dev/null +++ b/web/lib/agent-role.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + agentWorkRoleLabel, + formatAgentWorkLabel, + inferAgentWorkRole, + modelFamilyLabel, +} from './agent-role' + +test('infers a stable role from bounded work hints', () => { + assert.equal(inferAgentWorkRole('Run security audit of auth flow'), 'security') + assert.equal(inferAgentWorkRole('Write regression tests for the parser'), 'validator') + assert.equal(inferAgentWorkRole('Explore the API documentation'), 'researcher') + assert.equal(inferAgentWorkRole('something unclassified'), 'specialist') +}) + +test('formats a readable role/model label without retaining the hint', () => { + const label = formatAgentWorkLabel(inferAgentWorkRole('Implement the dashboard'), 'gpt-5.6-luna') + assert.equal(label, 'Implementador · Luna') + assert.equal(label.includes('dashboard'), false) + assert.equal(agentWorkRoleLabel('security'), 'Seguridad') + assert.equal(modelFamilyLabel('vendor-future-v1'), undefined) +}) diff --git a/web/lib/agent-role.ts b/web/lib/agent-role.ts new file mode 100644 index 00000000..f680dab4 --- /dev/null +++ b/web/lib/agent-role.ts @@ -0,0 +1,54 @@ +export const AGENT_WORK_ROLES = ['orchestrator', 'security', 'validator', 'researcher', 'implementer', 'documenter', 'designer', 'integrator', 'analyst', 'specialist'] as const +export type AgentWorkRole = typeof AGENT_WORK_ROLES[number] + +const ROLE_LABELS: Record = { + orchestrator: 'Orquestador', security: 'Seguridad', validator: 'Validador', researcher: 'Investigador', + implementer: 'Implementador', documenter: 'Documentador', designer: 'Diseñador', integrator: 'Integrador', + analyst: 'Analista', specialist: 'Especialista', +} + +const ROLE_MATCHERS: ReadonlyArray = [ + ['orchestrator', /orchestrat|coordina|supervis|dispatch|delegat|manager/], + ['security', /security|seguridad|secure|vulnerab|auth|permission|secret|threat|audit/], + ['validator', /test|qa|validat|verif|check|lint|review|regression|quality|prueba/], + ['researcher', /research|investig|search|browse|explor|discover|documentacion|docs?\b|read|grep|find/], + ['implementer', /implement|build|code|develop|edit|write|patch|fix|refactor|feature|program/], + ['documenter', /document|readme|guide|manual|release notes|changelog/], + ['designer', /design|diseñ|visual|ui|ux|frontend|css|layout/], + ['integrator', /deploy|release|publish|github|merge|integrat|pr\b|pipeline|ship/], + ['analyst', /analys|analiz|inspect|diagnos|metrics|kpi|report/], +] + +function normaliseHint(value: unknown): string { + return typeof value === 'string' + ? value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().slice(0, 256) + : '' +} + +export function isAgentWorkRole(value: unknown): value is AgentWorkRole { + return typeof value === 'string' && (AGENT_WORK_ROLES as readonly string[]).includes(value) +} + +/** Classifies bounded hints; the original task text is never returned. */ +export function inferAgentWorkRole(...hints: unknown[]): AgentWorkRole { + const haystack = hints.map(normaliseHint).filter(Boolean).join(' ') + for (const [role, matcher] of ROLE_MATCHERS) if (matcher.test(haystack)) return role + return 'specialist' +} + +export function agentWorkRoleLabel(role: AgentWorkRole): string { return ROLE_LABELS[role] } + +export function modelFamilyLabel(model?: string): string | undefined { + const value = normaliseHint(model) + if (!value) return undefined + if (value.includes('terra')) return 'Terra' + if (value.includes('luna')) return 'Luna' + if (value.includes('codex') || value.includes('gpt')) return 'Codex' + if (value.includes('claude')) return 'Claude' + return undefined +} + +export function formatAgentWorkLabel(role: AgentWorkRole, model?: string): string { + const family = modelFamilyLabel(model) + return family ? `${agentWorkRoleLabel(role)} · ${family}` : agentWorkRoleLabel(role) +} diff --git a/web/lib/agent-types.ts b/web/lib/agent-types.ts index 233937c8..8059f581 100644 --- a/web/lib/agent-types.ts +++ b/web/lib/agent-types.ts @@ -1,5 +1,6 @@ // Agent Visualizer Types — Holographic Edition v2 // Now with actual information visibility +import type { AgentWorkRole } from './agent-role' export type AgentState = 'idle' | 'thinking' | 'tool_calling' | 'complete' | 'error' | 'paused' | 'waiting_permission' @@ -15,6 +16,10 @@ export interface ContextBreakdown { export interface Agent { id: string name: string + /** Fixed-vocabulary role inferred from bounded work hints. */ + workRole?: AgentWorkRole + /** Human-readable role/model label; source task text is never included. */ + workLabel?: string state: AgentState parentId: string | null tokensUsed: number @@ -163,6 +168,12 @@ export interface SimulationEvent { | 'permission_requested' payload: Record sessionId?: string + /** Origin metadata is preserved so aggregate Office views can distinguish + * local/VPS sessions without exposing transcript or prompt content. */ + source?: 'local' | 'vps' | 'unknown' + hostId?: string + runtime?: 'claude' | 'codex' | 'unknown' + sequence?: number } export interface DepthParticle { diff --git a/web/lib/bridge-types.ts b/web/lib/bridge-types.ts index 60f7f26e..980ce1c5 100644 --- a/web/lib/bridge-types.ts +++ b/web/lib/bridge-types.ts @@ -11,6 +11,10 @@ export interface AgentEvent { type: string payload: Record sessionId?: string + source?: 'local' | 'vps' | 'unknown' + hostId?: string + runtime?: 'claude' | 'codex' | 'unknown' + sequence?: number } export interface SessionInfo { @@ -19,6 +23,9 @@ export interface SessionInfo { status: 'active' | 'completed' startTime: number lastActivityTime: number + source?: 'local' | 'vps' | 'unknown' + hostId?: string + runtime?: 'claude' | 'codex' | 'unknown' } export type ConnectionStatus = 'connected' | 'disconnected' | 'watching' diff --git a/web/lib/office/index.ts b/web/lib/office/index.ts new file mode 100644 index 00000000..8f9385c9 --- /dev/null +++ b/web/lib/office/index.ts @@ -0,0 +1,14 @@ +export { mergeOfficeProjections, officeAgentId, projectOffice, projectOfficeGraph } from './project-office' +export type { + OfficeAgent, + OfficeAgentState, + OfficeAvatar, + OfficeEdge, + OfficeEvent, + OfficeGraphSource, + OfficeProjection, + OfficeProjectionOptions, + OfficeSession, + OfficeConnectionStatus, + OfficeZone, +} from './types' diff --git a/web/lib/office/project-office.test.ts b/web/lib/office/project-office.test.ts new file mode 100644 index 00000000..3330a437 --- /dev/null +++ b/web/lib/office/project-office.test.ts @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mergeOfficeProjections, officeAgentId, projectOffice, projectOfficeGraph } from './project-office' +import type { Agent, Edge } from '../agent-types' +import type { OfficeEvent } from './types' + +const event = (type: string, payload: Record, time = 1, sessionId = 'session-a'): OfficeEvent => ({ + time, + type, + payload, + sessionId, +}) + +test('deduplicates duplicate spawns and keeps stable session-scoped IDs', () => { + const projection = projectOffice([ + event('agent_spawn', { name: 'worker', model: 'gpt-5.6-terra' }), + event('agent_spawn', { name: 'worker', model: 'gpt-5.6-terra' }), + ]) + + assert.equal(projection.agents.size, 1) + assert.ok(projection.agents.has(officeAgentId('session-a', 'worker'))) +}) + +test('registers an unknown agent immediately after the initial projection and remains idempotent', () => { + const sessionId = 'office-contract-session' + const initial = projectOffice([], { now: 100 }) + assert.equal(initial.agents.size, 0) + + const spawn = event( + 'agent_spawn', + { name: 'future-agent', model: 'vendor-future-v1' }, + 101, + sessionId, + ) + const startedAt = performance.now() + const first = projectOffice([spawn]) + const elapsedMs = performance.now() - startedAt + const agentId = officeAgentId(sessionId, 'future-agent') + + assert.ok(elapsedMs < 2_000, `projection took ${elapsedMs.toFixed(1)}ms`) + assert.equal(first.agents.size, 1) + assert.equal(first.agents.get(agentId)?.model, 'vendor-future-v1') + assert.equal(first.agents.get(agentId)?.avatar.family, 'generated') + + const replay = projectOffice([spawn, { ...spawn, time: 102 }]) + assert.equal(replay.agents.size, 1) + assert.deepEqual(replay.agents.get(agentId), first.agents.get(agentId)) +}) + +test('same agent name in separate sessions has separate identities', () => { + const projection = projectOffice([ + event('agent_spawn', { name: 'worker' }, 1, 'session-a'), + event('agent_spawn', { name: 'worker' }, 1, 'session-b'), + ]) + + assert.equal(projection.agents.size, 2) + assert.notEqual(officeAgentId('session-a', 'worker'), officeAgentId('session-b', 'worker')) +}) + +test('merges session projections without collapsing same-name agents', () => { + const merged = mergeOfficeProjections([ + projectOffice([event('agent_spawn', { name: 'worker' }, 1, 'session-a')]), + projectOffice([event('agent_spawn', { name: 'worker' }, 1, 'session-b')]), + ]) + + assert.equal(merged.agents.size, 2) + assert.equal(merged.agents.get(officeAgentId('session-a', 'worker'))?.sessionId, 'session-a') + assert.equal(merged.agents.get(officeAgentId('session-b', 'worker'))?.sessionId, 'session-b') +}) + +test('keeps the origin metadata needed for a hybrid local and VPS office', () => { + const projection = projectOffice([{ + ...event('agent_spawn', { name: 'local-worker', model: 'gpt-5.6-terra' }), + source: 'local', hostId: 'windows-main', runtime: 'codex', + }]) + const worker = projection.agents.get(officeAgentId('session-a', 'local-worker')) + assert.equal(worker?.source, 'local') + assert.equal(worker?.hostId, 'windows-main') + assert.equal(worker?.runtime, 'codex') +}) + +test('hydrates origin metadata when it arrives after the agent was discovered', () => { + const projection = projectOffice([ + event('agent_spawn', { name: 'background-worker' }, 1), + { ...event('agent_idle', { name: 'background-worker' }, 2), source: 'vps', hostId: 'office-vps', runtime: 'claude' }, + ]) + const worker = projection.agents.get(officeAgentId('session-a', 'background-worker')) + assert.equal(worker?.source, 'vps') + assert.equal(worker?.hostId, 'office-vps') + assert.equal(worker?.runtime, 'claude') +}) + +test('does not claim a parent until the parent itself is observed', () => { + const childId = officeAgentId('session-a', 'child') + const parentId = officeAgentId('session-a', 'parent') + const beforeParent = projectOffice([event('agent_spawn', { name: 'child', parent: 'parent' })]) + assert.equal(beforeParent.agents.get(childId)?.parentId, null) + assert.equal(beforeParent.edges.length, 0) + + const afterParent = projectOffice([ + event('agent_spawn', { name: 'child', parent: 'parent' }), + event('agent_spawn', { name: 'parent' }, 2), + ]) + assert.equal(afterParent.agents.get(childId)?.parentId, parentId) + assert.equal(afterParent.edges[0]?.evidence, 'agent_spawn') +}) + +test('keeps Terra and Luna explicit while accepting model IDs outside a catalogue', () => { + const projection = projectOffice([ + event('agent_spawn', { name: 'terra', model: 'gpt-5.6-terra' }), + event('agent_spawn', { name: 'luna', model: 'gpt-5.6-luna' }), + event('agent_spawn', { name: 'future', model: 'acme-next-42' }), + ]) + + assert.equal(projection.agents.get(officeAgentId('session-a', 'terra'))?.avatar.family, 'terra') + assert.equal(projection.agents.get(officeAgentId('session-a', 'luna'))?.avatar.family, 'luna') + assert.deepEqual(projection.agents.get(officeAgentId('session-a', 'future'))?.avatar.family, 'generated') + assert.equal(projection.agents.get(officeAgentId('session-a', 'future'))?.model, 'acme-next-42') +}) + +test('labels agents by bounded work role without copying task text', () => { + const projection = projectOffice([ + event('agent_spawn', { + name: 'worker-1', task: 'Review payment authentication and security controls', model: 'gpt-5.6-terra', + }), + ]) + const worker = projection.agents.get(officeAgentId('session-a', 'worker-1')) + assert.equal(worker?.workRole, 'security') + assert.equal(worker?.workLabel, 'Seguridad · Terra') + assert.equal(worker?.workLabel?.includes('payment'), false) +}) + +test('ignores an unknown event type rather than inventing an agent or state', () => { + const projection = projectOffice([event('relay_future_event', { agent: 'not-created' })]) + assert.equal(projection.agents.size, 0) +}) + +test('hydrates a previously unknown agent without regressing a newer state', () => { + const workerId = officeAgentId('session-a', 'worker') + const projection = projectOffice([ + event('tool_call_start', { agent: 'worker', tool: 'apply_patch' }, 10), + event('agent_spawn', { name: 'worker', model: 'gpt-5.6-terra' }, 2), + ]) + const worker = projection.agents.get(workerId) + + assert.equal(worker?.state, 'editing') + assert.equal(worker?.model, 'gpt-5.6-terra') + assert.equal(worker?.avatar.family, 'terra') +}) + +test('routes each evidenced activity to the corresponding office state', () => { + const workerId = officeAgentId('session-a', 'worker') + const stateAfter = (events: OfficeEvent[]) => projectOffice(events).agents.get(workerId)?.state + const spawn = event('agent_spawn', { name: 'worker' }, 1) + + assert.equal(stateAfter([spawn]), 'planning') + assert.equal(stateAfter([spawn, event('tool_call_start', { agent: 'worker', tool: 'grep' }, 2)]), 'researching') + assert.equal(stateAfter([ + spawn, + event('tool_call_start', { agent: 'worker', tool: 'grep' }, 2), + event('tool_call_end', { agent: 'worker', tool: 'grep' }, 3), + ]), 'idle') + assert.equal(stateAfter([ + spawn, + event('tool_call_start', { agent: 'worker', tool: 'apply_patch' }, 2), + event('context_update', { agent: 'worker', tokens: 42 }, 3), + ]), 'editing') + assert.equal(stateAfter([spawn, event('permission_requested', { agent: 'worker' }, 2)]), 'waiting_approval') + assert.equal(stateAfter([spawn, event('error', { agent: 'worker' }, 2)]), 'blocked') + assert.equal(stateAfter([spawn, event('agent_complete', { name: 'worker' }, 2)]), 'completed') +}) + +test('routes delegated children through planning before they become idle', () => { + const childId = officeAgentId('session-a', 'child') + const projection = projectOffice([ + event('agent_spawn', { name: 'parent', isMain: true }, 1), + event('agent_spawn', { name: 'child' }, 2), + event('subagent_dispatch', { parent: 'parent', child: 'child' }, 3), + event('subagent_return', { parent: 'parent', child: 'child' }, 4), + ]) + assert.equal(projection.agents.get(childId)?.state, 'idle') +}) + +test('adapts graph agents and only joins a parent when a parent-child edge exists', () => { + const parent: Agent = { + id: 'parent', name: 'Parent', state: 'idle', parentId: null, + tokensUsed: 0, tokensMax: 0, contextBreakdown: { systemPrompt: 0, userMessages: 0, toolResults: 0, reasoning: 0, subagentResults: 0 }, + toolCalls: 0, timeAlive: 0, x: 0, y: 0, vx: 0, vy: 0, pinned: false, isMain: true, + spawnTime: 1, opacity: 1, scale: 1, messageBubbles: [], + } + const child: Agent = { ...parent, id: 'child', name: 'Child', parentId: 'parent', isMain: false } + const edge: Edge = { id: 'parent-child', from: 'parent', to: 'child', type: 'parent-child', opacity: 1 } + + const withoutEdge = projectOfficeGraph({ sessionId: 'graph-session', agents: [parent, child], edges: [] }) + assert.equal(withoutEdge.agents.get(officeAgentId('graph-session', 'child'))?.parentId, null) + + const withEdge = projectOfficeGraph({ sessionId: 'graph-session', agents: [parent, child], edges: [edge] }) + assert.equal(withEdge.agents.get(officeAgentId('graph-session', 'child'))?.parentId, officeAgentId('graph-session', 'parent')) +}) + +test('maps graph thinking to the planning room and preserves runtime metadata', () => { + const thinkingAgent: Agent = { + id: 'thinking', name: 'Thinking', state: 'thinking', parentId: null, + tokensUsed: 0, tokensMax: 0, contextBreakdown: { systemPrompt: 0, userMessages: 0, toolResults: 0, reasoning: 0, subagentResults: 0 }, + toolCalls: 0, timeAlive: 0, x: 0, y: 0, vx: 0, vy: 0, pinned: false, isMain: true, + runtime: 'codex', spawnTime: 1, opacity: 1, scale: 1, messageBubbles: [], + } + const projection = projectOfficeGraph({ + sessionId: 'graph-session', source: 'vps', hostId: 'office-vps', runtime: 'codex', + agents: [thinkingAgent], edges: [], + }) + const officeAgent = projection.agents.get(officeAgentId('graph-session', 'thinking')) + assert.equal(officeAgent?.state, 'planning') + assert.equal(officeAgent?.runtime, 'codex') + assert.equal(officeAgent?.source, 'vps') +}) diff --git a/web/lib/office/project-office.ts b/web/lib/office/project-office.ts new file mode 100644 index 00000000..92f1dc9b --- /dev/null +++ b/web/lib/office/project-office.ts @@ -0,0 +1,477 @@ +import type { Agent, Edge, SimulationEvent } from '../agent-types' +import type { + OfficeAgent, + OfficeAgentState, + OfficeAvatar, + OfficeEdge, + OfficeEvent, + OfficeGraphSource, + OfficeProjection, + OfficeProjectionOptions, +} from './types' +import { + formatAgentWorkLabel, + inferAgentWorkRole, + isAgentWorkRole, + type AgentWorkRole, +} from '../agent-role' + +const DEFAULT_SESSION_ID = 'default-session' +const DEFAULT_STALE_AFTER_SECONDS = 120 +const SLOT_COUNT = 12 + +interface MutableOfficeState { + agents: Map + /** Child -> parent evidence. An edge is materialized only after both exist. */ + parentEvidence: Map + /** Origin metadata is kept per session and copied onto projected agents. */ + origins: Map +} + +/** FNV-1a produces deterministic, browser-safe opaque identifiers without a dependency. */ +function hash(value: string, seed = 0x811c9dc5): string { + let result = seed >>> 0 + for (let index = 0; index < value.length; index++) { + result ^= value.charCodeAt(index) + result = Math.imul(result, 0x01000193) >>> 0 + } + return (`00000000${result.toString(16)}`).slice(-8) +} + +function sourceKey(sessionId: string, agentId: string): string { + return `${sessionId.length}:${sessionId}\u0000${agentId.length}:${agentId}` +} + +/** Stable across render/replay, and distinct between different sessions in practice. */ +export function officeAgentId(sessionId: string | undefined, agentId: string): string { + const key = sourceKey(sessionId || DEFAULT_SESSION_ID, agentId) + return `office-${hash(key)}-${hash(key, 0x9e3779b9)}` +} + +function text(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +function eventTime(event: OfficeEvent): number { + return Number.isFinite(event.time) ? event.time : 0 +} + +function displayName(agentId: string): string { + // Do not retain task/message content. Agent names are bounded to avoid making + // an accidental payload-sized identifier part of the projection. + return agentId.replace(/\s+/g, ' ').slice(0, 96) +} + +function roleFromPayload(payload: Record, ...fallbacks: unknown[]): AgentWorkRole { + if (isAgentWorkRole(payload.workRole)) return payload.workRole + return inferAgentWorkRole(payload.task, payload.tool, payload.name, payload.model, ...fallbacks) +} + +function avatarFor(agentId: string, model?: string): OfficeAvatar { + const normalized = model?.toLowerCase() + if (normalized?.includes('terra')) return { family: 'terra', key: 'terra' } + if (normalized?.includes('luna')) return { family: 'luna', key: 'luna' } + + // Unknown and future model IDs are preserved in `model`; their artwork is + // deterministic rather than being rejected or silently relabelled. + return { family: 'generated', key: `generated-${hash(model || agentId)}` } +} + +function slotFor(sessionId: string, agentId: string): number { + return parseInt(hash(sourceKey(sessionId, agentId)).slice(0, 6), 16) % SLOT_COUNT +} + +function ensureAgent(state: MutableOfficeState, sessionId: string, sourceAgentId: string, at: number): OfficeAgent { + const id = officeAgentId(sessionId, sourceAgentId) + const existing = state.agents.get(id) + if (existing) return existing + + const agent: OfficeAgent = { + id, + sessionId, + sourceAgentId, + ...state.origins.get(sessionId), + name: displayName(sourceAgentId), + parentId: null, + state: 'unknown', + zone: 'unknown', + avatar: avatarFor(sourceAgentId), + slot: slotFor(sessionId, sourceAgentId), + lastActivityAt: at, + } + state.agents.set(id, agent) + resolveKnownParent(state, id) + resolveChildrenForParent(state, id) + return agent +} + +function replaceAgent(state: MutableOfficeState, agent: OfficeAgent, changes: Partial): OfficeAgent { + const next = { ...agent, ...changes } + state.agents.set(agent.id, next) + return next +} + +function recordOrigin( + state: MutableOfficeState, + sessionId: string, + origin: { source?: OfficeAgent['source']; hostId?: string; runtime?: OfficeAgent['runtime'] }, +): void { + const previous = state.origins.get(sessionId) + const next = { + source: origin.source ?? previous?.source, + hostId: origin.hostId ?? previous?.hostId, + runtime: origin.runtime ?? previous?.runtime, + } + state.origins.set(sessionId, next) + + // A relay may attach origin metadata to the first non-spawn event. Hydrate + // agents already discovered in that session so background sessions remain + // identifiable in the aggregate Office view too. + for (const agent of state.agents.values()) { + if (agent.sessionId === sessionId) replaceAgent(state, agent, next) + } +} + +function resolveKnownParent(state: MutableOfficeState, childId: string): void { + const relation = state.parentEvidence.get(childId) + const child = state.agents.get(childId) + if (!relation || !child || !state.agents.has(relation.parentId)) return + if (child.parentId !== relation.parentId) replaceAgent(state, child, { parentId: relation.parentId }) +} + +function resolveChildrenForParent(state: MutableOfficeState, parentId: string): void { + for (const [childId, relation] of state.parentEvidence) { + if (relation.parentId === parentId) resolveKnownParent(state, childId) + } +} + +function recordParentEvidence( + state: MutableOfficeState, + parentId: string, + childId: string, + evidence: OfficeEdge['evidence'], +): void { + // This is the only route to a parentId: same-name guesses are never used. + state.parentEvidence.set(childId, { parentId, evidence }) + resolveKnownParent(state, childId) +} + +function setState(state: MutableOfficeState, agent: OfficeAgent, nextState: OfficeAgentState, at: number): void { + // A delayed event can enrich metadata, but it must not roll the visible + // state backwards over a more recent observation. + if (at < agent.lastActivityAt) return + replaceAgent(state, agent, { state: nextState, zone: nextState, lastActivityAt: at }) +} + +function stateForTool(tool: string): OfficeAgentState { + const normalized = tool.toLowerCase() + if (/(todo|plan)/.test(normalized)) return 'planning' + if (/(search|fetch|read|grep|glob|find|research|browse)/.test(normalized)) return 'researching' + if (/(edit|write|patch|replace)/.test(normalized)) return 'editing' + // A tool invocation is direct evidence of work, but no claim is made about + // its domain when the tool is unfamiliar. + return 'executing' +} + +function stateForThinking(content: unknown): OfficeAgentState { + const normalized = text(content)?.toLowerCase() + // A thinking event without text is still evidence that the agent is + // planning; remote privacy filtering intentionally removes the text. + if (!normalized) return 'planning' + if (/(blocked|failed|failure|error)/.test(normalized)) return 'blocked' + if (/(approval|permission|approve)/.test(normalized)) return 'waiting_approval' + if (/(planning|plan |planing)/.test(normalized)) return 'planning' + if (/(research|searching|investigat)/.test(normalized)) return 'researching' + if (/(editing|writing|implementing|patching)/.test(normalized)) return 'editing' + if (/(executing|running|testing|deploying)/.test(normalized)) return 'executing' + return 'unknown' +} + +function actor(payload: Record, field: 'agent' | 'name' | 'parent' | 'child'): string | undefined { + return text(payload[field]) +} + +function eventAgent( + state: MutableOfficeState, + sessionId: string, + agentId: string | undefined, + at: number, +): OfficeAgent | undefined { + return agentId ? ensureAgent(state, sessionId, agentId, at) : undefined +} + +function applyEvent(state: MutableOfficeState, event: OfficeEvent): void { + const sessionId = event.sessionId || DEFAULT_SESSION_ID + const at = eventTime(event) + const payload = event.payload || {} + if (event.source || event.hostId || event.runtime) { + recordOrigin(state, sessionId, { + source: event.source, + hostId: event.hostId, + runtime: event.runtime, + }) + } + + switch (event.type) { + case 'agent_spawn': { + const sourceAgentId = actor(payload, 'name') + const agent = eventAgent(state, sessionId, sourceAgentId, at) + if (!agent || !sourceAgentId) return + + const model = text(payload.model) + const workRole = payload.isMain === true ? 'orchestrator' : roleFromPayload(payload, sourceAgentId) + const changes: Partial = {} + if (model) changes.model = model + if (model) changes.avatar = avatarFor(sourceAgentId, model) + changes.workRole = workRole + changes.workLabel = formatAgentWorkLabel(workRole, model || agent.model) + if (Object.keys(changes).length) replaceAgent(state, agent, changes) + // A newly observed agent is preparing its work before the first tool or + // delegation event gives us a more specific room. + const currentAgent = state.agents.get(agent.id) ?? agent + if (currentAgent.state === 'unknown') setState(state, currentAgent, 'planning', at) + const parentSourceId = actor(payload, 'parent') + if (parentSourceId) { + recordParentEvidence(state, officeAgentId(sessionId, parentSourceId), agent.id, 'agent_spawn') + } + return + } + + case 'agent_complete': { + const agent = eventAgent(state, sessionId, actor(payload, 'name'), at) + if (agent) setState(state, agent, 'completed', at) + return + } + + case 'agent_idle': { + const agent = eventAgent(state, sessionId, actor(payload, 'name'), at) + if (agent) setState(state, agent, 'idle', at) + return + } + + case 'permission_requested': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent') || actor(payload, 'name'), at) + if (agent) setState(state, agent, 'waiting_approval', at) + return + } + + case 'tool_call_start': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + const tool = text(payload.tool) + if (agent) { + if (!agent.workRole) { + const workRole = roleFromPayload(payload, tool) + replaceAgent(state, agent, { workRole, workLabel: formatAgentWorkLabel(workRole, agent.model) }) + } + setState(state, state.agents.get(agent.id) ?? agent, tool ? stateForTool(tool) : 'executing', at) + } + return + } + + case 'tool_call_end': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + if (!agent) return + // Error status is explicit. A successful tool result means the agent is + // available for its next evidenced action, rather than still occupying + // the tool room or becoming an unclassified phantom. + setState(state, agent, payload.isError === true ? 'blocked' : 'idle', at) + return + } + + case 'message': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + if (!agent) return + const next = payload.role === 'thinking' ? stateForThinking(payload.content) : 'unknown' + setState(state, agent, next, at) + return + } + + case 'context_update': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + // Context accounting is activity evidence, not a room assignment. Keep + // the last evidenced work zone while refreshing its timestamp. + if (agent && at >= agent.lastActivityAt) replaceAgent(state, agent, { lastActivityAt: at }) + return + } + + case 'error': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent') || actor(payload, 'name'), at) + if (agent) setState(state, agent, 'blocked', at) + return + } + + case 'model_detected': { + const sourceAgentId = actor(payload, 'agent') || actor(payload, 'name') + const agent = eventAgent(state, sessionId, sourceAgentId, at) + const model = text(payload.model) + if (agent && model && sourceAgentId) { + const workRole = agent.workRole || roleFromPayload(payload, sourceAgentId) + replaceAgent(state, agent, { + model, avatar: avatarFor(sourceAgentId, model), workRole, + workLabel: formatAgentWorkLabel(workRole, model), + }) + } + return + } + + case 'subagent_dispatch': { + const parent = eventAgent(state, sessionId, actor(payload, 'parent'), at) + const child = eventAgent(state, sessionId, actor(payload, 'child'), at) + if (parent) setState(state, parent, 'planning', at) + if (child) setState(state, child, 'planning', at) + if (parent && child) recordParentEvidence(state, parent.id, child.id, 'subagent_dispatch') + return + } + + case 'subagent_return': { + const parent = eventAgent(state, sessionId, actor(payload, 'parent'), at) + const child = eventAgent(state, sessionId, actor(payload, 'child'), at) + if (parent) setState(state, parent, 'planning', at) + if (child) setState(state, child, 'idle', at) + return + } + + default: + // Forward-compatible and deliberately no-op. Unknown relay events are + // not evidence for a particular person, zone, or model. + } +} + +function edgesFor(state: MutableOfficeState): OfficeEdge[] { + const edges: OfficeEdge[] = [] + for (const [childId, relation] of state.parentEvidence) { + const child = state.agents.get(childId) + if (!child || child.parentId !== relation.parentId || !state.agents.has(relation.parentId)) continue + edges.push({ + id: `office-edge-${relation.parentId}-${childId}`, + parentId: relation.parentId, + childId, + evidence: relation.evidence, + }) + } + return edges.sort((a, b) => a.id.localeCompare(b.id)) +} + +function withStaleState(agent: OfficeAgent, now: number, staleAfterSeconds: number): OfficeAgent { + if (!Number.isFinite(staleAfterSeconds) || staleAfterSeconds < 0) return agent + if (agent.state === 'completed' || agent.state === 'blocked' || agent.state === 'waiting_approval') return agent + if (now - agent.lastActivityAt <= staleAfterSeconds) return agent + return { ...agent, state: 'stale', zone: 'stale' } +} + +function stateFromGraphAgent(agent: Agent): OfficeAgentState { + switch (agent.state) { + case 'tool_calling': return 'executing' + case 'complete': return 'completed' + case 'error': return 'blocked' + case 'waiting_permission': return 'waiting_approval' + case 'idle': return 'idle' + // Thinking is direct evidence of planning. Paused has no safe room beyond + // the neutral waiting area until a new event identifies the next action. + case 'thinking': return 'planning' + case 'paused': return 'unknown' + } +} + +function applyGraphAgent(state: MutableOfficeState, sessionId: string, source: Agent): void { + const at = Number.isFinite(source.completeTime) ? source.completeTime! + : Number.isFinite(source.spawnTime) ? source.spawnTime + : 0 + const agent = ensureAgent(state, sessionId, source.id, at) + const changes: Partial = { + name: displayName(source.name), + state: stateFromGraphAgent(source), + zone: stateFromGraphAgent(source), + lastActivityAt: at, + } + if (source.runtime) changes.runtime = source.runtime + const workRole = source.workRole || inferAgentWorkRole(source.task, source.name, source.currentTool, source.model) + changes.workRole = workRole + changes.workLabel = formatAgentWorkLabel(workRole, source.model) + if (source.model) { + changes.model = source.model + changes.avatar = avatarFor(source.id, source.model) + } + replaceAgent(state, agent, changes) +} + +function applyGraphEdge(state: MutableOfficeState, sessionId: string, edge: Edge): void { + if (edge.type !== 'parent-child') return + const parentId = officeAgentId(sessionId, edge.from) + const childId = officeAgentId(sessionId, edge.to) + // Graph edges are direct parent-child evidence, but never cause a phantom + // parent/child record to be invented. + if (state.agents.has(parentId) && state.agents.has(childId)) { + recordParentEvidence(state, parentId, childId, 'agent_spawn') + } +} + +function projectionFromState(state: MutableOfficeState, options: OfficeProjectionOptions, newestEventTime: number): OfficeProjection { + const now = options.now ?? newestEventTime + const staleAfterSeconds = options.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS + const agents = new Map() + for (const [id, agent] of state.agents) agents.set(id, withStaleState(agent, now, staleAfterSeconds)) + return { agents, edges: edgesFor(state) } +} + +/** + * Derive an Office projection from existing Agent/Edge/SimulationEvent inputs. + * The function is pure and does not retain source payloads, making it suitable + * for replay, live updates, and tests alike. + */ +export function projectOffice( + events: readonly (SimulationEvent | OfficeEvent)[], + options: OfficeProjectionOptions = {}, +): OfficeProjection { + const state: MutableOfficeState = { + agents: new Map(), + parentEvidence: new Map(), + origins: new Map(), + } + + let newestEventTime = 0 + for (const event of events) { + newestEventTime = Math.max(newestEventTime, eventTime(event)) + applyEvent(state, event) + } + + return projectionFromState(state, options, newestEventTime) +} + +/** + * Adapt the already materialized Agent/Edge graph. Graph edges, not matching + * names or source `parentId` fields, are the evidence used to connect people. + */ +export function projectOfficeGraph(source: OfficeGraphSource, options: OfficeProjectionOptions = {}): OfficeProjection { + const sessionId = source.sessionId || DEFAULT_SESSION_ID + const state: MutableOfficeState = { + agents: new Map(), + parentEvidence: new Map(), + origins: new Map(), + } + if (source.source || source.hostId || source.runtime) { + state.origins.set(sessionId, { source: source.source, hostId: source.hostId, runtime: source.runtime }) + } + let newestEventTime = 0 + + for (const agent of source.agents) { + applyGraphAgent(state, sessionId, agent) + newestEventTime = Math.max(newestEventTime, state.agents.get(officeAgentId(sessionId, agent.id))?.lastActivityAt ?? 0) + } + for (const edge of source.edges) applyGraphEdge(state, sessionId, edge) + + return projectionFromState(state, options, newestEventTime) +} + +/** Merge independent session projections into one deterministic office view. */ +export function mergeOfficeProjections(projections: readonly OfficeProjection[]): OfficeProjection { + const agents = new Map() + const edges = new Map() + for (const projection of projections) { + for (const [id, agent] of projection.agents) agents.set(id, agent) + for (const edge of projection.edges) edges.set(edge.id, edge) + } + return { agents, edges: [...edges.values()].sort((a, b) => a.id.localeCompare(b.id)) } +} diff --git a/web/lib/office/types.ts b/web/lib/office/types.ts new file mode 100644 index 00000000..8f9757df --- /dev/null +++ b/web/lib/office/types.ts @@ -0,0 +1,118 @@ +import type { AgentWorkRole } from '../agent-role' + +/** + * A deliberately small, UI-agnostic view of a running agent. This is not a + * second source of truth: it is a privacy-conscious projection of the event + * stream already understood by the visualizer. + */ +export type OfficeAgentState = + | 'unknown' + | 'planning' + | 'researching' + | 'editing' + | 'executing' + | 'waiting_approval' + | 'blocked' + | 'completed' + | 'idle' + | 'stale' + +/** The office zone intentionally mirrors the evidence-backed agent state. */ +export type OfficeZone = OfficeAgentState + +export interface OfficeAvatar { + /** A stable presentational key; consumers choose the actual artwork. */ + key: string + /** Known families get a friendly key; every other model gets a generated one. */ + family: 'terra' | 'luna' | 'generated' +} + +export interface OfficeAgent { + /** Opaque, deterministic ID scoped to sessionId + source agent ID. */ + id: string + /** Opaque source session identifier used to keep aggregate views navigable. */ + sessionId?: string + /** Short UI label for the source session; prompt text is never copied here. */ + sessionLabel?: string + /** Source identifier used only to route a selection back to the graph. */ + sourceAgentId?: string + /** Where the session was observed. */ + source?: 'local' | 'vps' | 'unknown' + hostId?: string + runtime?: 'claude' | 'codex' | 'unknown' + /** A short label only. Task text, messages and tool arguments are excluded. */ + name: string + /** Fixed-vocabulary semantic role, never the original prompt. */ + workRole?: AgentWorkRole + /** Localized role label with an optional model family. */ + workLabel?: string + parentId: string | null + state: OfficeAgentState + zone: OfficeZone + /** The original reported model, without imposing a catalogue of valid models. */ + model?: string + avatar: OfficeAvatar + /** A deterministic placement hint for a UI, not persisted layout state. */ + slot: number + /** Event-clock timestamp in seconds, as supplied by SimulationEvent.time. */ + lastActivityAt: number +} + +export interface OfficeEdge { + id: string + parentId: string + childId: string + evidence: 'agent_spawn' | 'subagent_dispatch' +} + +export interface OfficeProjection { + agents: ReadonlyMap + edges: readonly OfficeEdge[] +} + +/** Session metadata used by the Office roster. It may exist before the first + * agent event, so it must not be represented as a fabricated agent. */ +export interface OfficeSession { + id: string + label: string + status: 'active' | 'completed' + startTime: number + lastActivityTime: number + source?: OfficeAgent['source'] + hostId?: string + runtime?: OfficeAgent['runtime'] +} + +export type OfficeConnectionStatus = 'connected' | 'disconnected' | 'watching' + +/** + * A widening of SimulationEvent used at the boundary: a relay can send a + * future event type before this package has learned about it. Such events are + * safely ignored instead of being coerced into a misleading office state. + */ +export interface OfficeEvent { + time: number + type: string + payload: Record + sessionId?: string + source?: 'local' | 'vps' | 'unknown' + hostId?: string + runtime?: 'claude' | 'codex' | 'unknown' +} + +export interface OfficeProjectionOptions { + /** Defaults to the newest event timestamp (so replay output is stable). */ + now?: number + /** In event-clock seconds. Set to Infinity to disable stale decoration. */ + staleAfterSeconds?: number +} + +/** Existing visualizer graph state adapted without exposing UI implementation details. */ +export interface OfficeGraphSource { + sessionId?: string + source?: OfficeAgent['source'] + hostId?: string + runtime?: OfficeAgent['runtime'] + agents: Iterable + edges: Iterable +} diff --git a/web/next.config.mjs b/web/next.config.mjs index b53664af..a26674db 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -1,5 +1,8 @@ /** @type {import('next').NextConfig} */ +const basePath = (process.env.NEXT_PUBLIC_BASE_PATH || '').replace(/\/$/, '') + const nextConfig = { + basePath, images: { unoptimized: true, }, diff --git a/web/package.json b/web/package.json index 7692369a..e55cd757 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "build:webview": "vite build --config vite.config.webview.ts" + "build:webview": "vite build --config vite.config.webview.ts", + "test": "node --import tsx --test \"hooks/**/*.test.ts\" \"lib/**/*.test.ts\"" }, "dependencies": { "d3-force": "^3.0.0", diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 00000000..0fa94612 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,8 @@ +// Deliberately network-only: never cache authenticated HTML, SSE, agent +// transcripts, tool output, or operational data for this internal PWA. +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())) +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET') return + event.respondWith(fetch(event.request)) +})