diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..9cf4a18 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "looperators-p0", + "interface": { + "displayName": "Looperators P0" + }, + "plugins": [ + { + "name": "looperators-agent-loop", + "source": { + "source": "local", + "path": "./plugins/looperators-agent-loop" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_USE" + }, + "category": "Developer" + } + ] +} diff --git a/.gitignore b/.gitignore index db17b81..ec7884d 100644 --- a/.gitignore +++ b/.gitignore @@ -153,14 +153,15 @@ release *.sln *.sw? -# Local project vision and design notes +# Internal project documentation (keep the legacy path blocked) design-docs/ internal_docs/ +# Local browser automation state +.playwright-cli/ + # Local verification screenshots acceptance-screenshots/ -output/demo-screenshots/ -# Headless acceptance run artifacts -output/acceptance/ -output/ui-acceptance/ +# Generated output and acceptance artifacts +/output/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 9b9d6fd..c4e2676 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,10 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": ["dist/**", "dist-electron/**"], + "ignorePatterns": [ + "dist/**", + "dist-electron/**", + "plugins/looperators-agent-loop/ui/assets/**" + ], "plugins": ["react", "typescript", "oxc"], "rules": { "react/rules-of-hooks": "error", diff --git a/agents.md b/agents.md index cf7916c..d776b84 100644 --- a/agents.md +++ b/agents.md @@ -4,16 +4,12 @@ Branding boundary: the customer-facing product name is `looperators`. `Orrery` / remains the internal codename and technical namespace; do not rename code symbols, IPC/MCP names, environment variables, storage paths, CLI files, or historical records solely for rebranding. -For project vision and designs, please check the documentation under the `design-docs/`. +`agents.md` is a protected repository policy file. Modify it only when the user explicitly +authorizes changes to this file while knowing that `agents.md` itself will be edited; general +permission to edit code or documentation is not authorization. -- Vision / initial idea: `design-docs/reactive-agent-session-graph.md` — why looperators manages agents as a graph instead of a session list. -- Design (current source of truth for development): `design-docs/orrery-design.md` — the concrete control model (Skills membrane, Master Agent, scopes/clusters), UI shape, v1 scope, and the §12 Skill API. -- v1 plan: `design-docs/v1-implementation-plan.md` — phased implementation plan (critical path P0–P3 + parallel workstreams A–C), with goals and acceptance criteria. -- Internal implementation roadmap / current post-P4 baseline: `design-docs/internal_docs/plans/master-agent-plan-council-implementation-plan.md` — Master-as-Intent-Compiler/Governor/Replanner, Plan Council as the first high-frequency workflow, reliability convergence, correlation/barriers, dynamic topology, and concurrency/safety. Its rationale and discussion replay live in `design-docs/internal_docs/commit-log/2026-07-12-master-agent-plan-council-direction.md`. This roadmap supersedes the old priority of expanding manual canvas authoring first; existing kernel semantics remain governed by `design-docs/session-graph-kernel.md` until each migration phase lands. - -- Verification: headless-first, three tiers — `npm run test:kernel` (kernel unit tests, fake providers allowed), `npm run acceptance:headless` (real-scenario acceptance on real providers with the cheap model preset, artifacts in `output/acceptance/`), then final UI acceptance. Commands are documented below; scenario-authoring rules live in `design-docs/AGENTS.md`. - -For development and technical planning, follow the design doc and the v1 plan. +For project vision, design decisions, plans, and other internal context, consult the local, +Git-ignored `internal_docs/` directory and read only the documents relevant to the current task. ## Current product surface @@ -126,14 +122,13 @@ npm run acceptance:membrane ``` Read the failed run's artifacts before rerunning. Scenario-authoring contracts, -workspace isolation rules, and evidence requirements are in -`design-docs/AGENTS.md` and `design-docs/headless-acceptance-harness.md`. +workspace isolation rules, and evidence requirements are kept in the local internal documentation. ### 3. Final UI acceptance UI acceptance is the final, low-frequency tier after kernel and headless acceptance pass. Follow the Browser/Computer Use assignment and model rules in -`design-docs/AGENTS.md`; do not turn UI clicking into the daily test loop. +the local internal documentation; do not turn UI clicking into the daily test loop. ## Provider setup notes @@ -169,4 +164,4 @@ npm run cli -- graph ``` The full CLI debugging loop, programmatic orchestration client, and browser -preview setup are documented in `design-docs/AGENTS.md`. +preview setup are documented in the local internal documentation. diff --git a/electron/runtime/sessionManager.ts b/electron/runtime/sessionManager.ts index 4c76166..26daab2 100644 --- a/electron/runtime/sessionManager.ts +++ b/electron/runtime/sessionManager.ts @@ -14,41 +14,6 @@ // - workflow proposals, wakeups, barriers // - resource policy, run queue, launchRun, provider event stream // - checkpoints/diffs, reports, persistence triggers -// -// Split-out knowledge domains (import, don't re-add here): -// runtimeCommon.ts shared value helpers + validation sets -// workspace/gitWorkspace.ts git/worktree/branch/checkpoint-ref/diff -// workspace/workspaceFiles.ts workspace file tree + open-in-app -// workspace/workspaceService.ts public workspace reads + open facade -// providers/providerConfigNormalize.ts provider instance/runtime config -// sessions/sessionInteraction.ts attachments + request/input normalization -// persistence/runtimeStateRecovery.ts state load/normalize/migrate/repair -// terminal/terminalService.ts embedded terminal subsystem -// subscriptionAuthoring.ts author_subscription input validation -// workspace/sessionCheckpoints.ts per-turn git checkpoints + diffs -// providers/providerSetupStatus.ts provider CLI/model-catalog probing -// control/commandRegistry.ts command kind + handler/policy registry -// control/commandExecutor.ts serialized transaction + effect authority -// clusters/clusterControlRuntime.ts Scope/Master/Loop/freeze topology control -// membrane/membraneRequestRuntime.ts sanctioned agent control-surface dispatch -// scheduler/schedulerRuntime.ts fact -> gate -> activation + timer lifecycle -// sessions/sessionRuntimeController.ts admission + provider turn lifecycle -// sessions/sessionCommandRuntime.ts session commands + channel/interaction adapters -// reports/reportFormatting.ts report/prompt render + payload validation -// queries/runtimeQueries.ts read-only state/kernel projections -// external/externalIngestionService.ts source registry, adapters + ingestion -// workflows/workflowKernel.ts the explicit kernel surface below -// workflows/governanceRuntime.ts wakeup + Barrier state machines -// workflows/proposalRuntime.ts Proposal/Patch authoring + commit -// workflows/classicWorkflows.ts draft/handoff/goal/connect + deployments -// workflows/planCouncil.ts plan council orchestration -// workflows/reviewWorkflow.ts review ring composer -// workflows/goalTemplates.ts goal loop + template library -// workflows/workflowShared.ts workflow resource compensation -// -// Growth stopline: new product workflows must not add new knowledge domains -// to this class; register the domain here and put the implementation in its -// own module (see design-docs/session-manager-split-plan.md). import { randomUUID } from 'node:crypto' import fs from 'node:fs' import os from 'node:os' diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/cooperative-mcp-checkpoint.md b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/cooperative-mcp-checkpoint.md new file mode 100644 index 0000000..3b1cd56 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/cooperative-mcp-checkpoint.md @@ -0,0 +1,69 @@ +# Cooperative typed MCP checkpoint + +Date: 2026-08-02 + +## Scope + +This checkpoint removes Hooks and native-worker observation from the default Codex Plugin +surface. The loop is explicitly cooperative: root schedules one logical role at a time and the +worker reports through a typed, action-scoped MCP capability. + +## Product surface + +The default MCP server lists exactly eight tools: + +1. `looperators_preview_loop` +2. `looperators_start_loop` +3. `looperators_pause_loop` +4. `looperators_resume_loop` +5. `looperators_cancel_loop` +6. `looperators_report` +7. `looperators_get_loop` +8. `looperators_get_snapshot` + +Removed default surfaces include Hook collection/readiness, worker prepare/bind tools, probe +tools and inline graph rendering. Calling a removed tool by name returns method-not-found. + +## Capability boundary + +- The token is random and opaque. Its plaintext is never persisted; the shared store contains only + its SHA-256 digest and action identity. +- The durable record binds run, logical role, pending transition and pending action revision, so a + native worker's independent MCP process can validate the root-issued token. +- A token cannot act for the other role or a later action. +- Cancel and terminal state revoke the action. +- A new Governor MCP process rotates the current token on root `get_loop`; the Governor must not + perform that recovery read while a worker is in flight. +- Exact cross-process report retries remain idempotent after the transition advances. + +This is not cryptographic proof of a native Codex subagent identity. That limitation is deliberate +in the default no-Hooks mode and is stated in the Plugin README and skill. + +## Reproduction + +From `/Users/observedobserver/Documents/GitHub/orrery`: + +```sh +node --test --test-concurrency=4 plugins/looperators-agent-loop/tests/*.test.mjs +npm run test:plugin-agent-loop-ui +npm run lint +``` + +Observed result: + +- direct Plugin suite: 60 passed, 0 failed; +- repository Plugin UI build plus suite: 60 passed, 0 failed; +- shared graph package typecheck/library/inline/embed builds passed as part of the repository script; +- lint exited 0 with pre-existing warnings; no new lint error. + +The suite covers no-Hook start, logical bindings, token-plaintext non-persistence, +cross-controller and three-real-MCP-process reporting, implementer-to-reviewer +and reviewer-to-terminal transitions, wrong-role/stale token rejection, pause/resume, cancel, +restart rotation, idempotent and conflicting retries, concurrent reports, issues-to-cap, +verified snapshots, prepared-start crash recovery and real MCP stdio framing/tool dispatch. + +## Deferred by design + +The existing developer sidecar tests remain green, but the MCP-owned product sidecar and the full +shared ReactFlow production embed are the next checkpoint. No Desktop UI acceptance or fresh +Plugin install is claimed here. diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/desktop-interaction-parity.md b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/desktop-interaction-parity.md new file mode 100644 index 0000000..7566b2d --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/desktop-interaction-parity.md @@ -0,0 +1,92 @@ +# Desktop → shared graph interaction parity + +- 日期:2026-08-02 +- Gate:A / shared package independent checkpoint +- 状态:shared package independent checkpoint passed;Plugin integration pending +- 规则:`not-applicable` 必须说明 authority 不存在或 fixed review loop 不需要;不能用它 + 掩盖 shared package 缺失。 + +## Authority sources + +- `src/components/session-graph-panel.tsx`:ReactFlow shell、selection、MiniMap、pan/zoom、 + node/edge routing 与 inspector/loop panel composition。 +- `src/components/canvas.tsx`:Agent node、relationship edge、loop badge 及状态视觉。 +- `src/components/relationship-inspector-panel.tsx`:关系结构、状态、stop action。 +- `src/components/loop-panel.tsx`:lap、issues、recovery、timeline、stop confirmation。 +- `src/hooks/use-canvas.ts`:projection→flow elements、drag、position、selection semantics。 +- `src/lib/graph-view.ts`:portable-ish node/edge/product view model 与 label/routing semantics。 + +`packages/agent-graph-ui` 已按本矩阵扩展并通过独立验收;Desktop 源文件仍是语义和交互 +authority,Gate A/B 完成前不改 Desktop import。 + +## Matrix + +| Desktop behavior | Classification | Shared/Plugin contract | Acceptance | +| --- | --- | --- | --- | +| ReactFlow canvas + dotted background | direct reuse | shared canvas shell | production bundle 可见且无 console error | +| pan / wheel zoom / zoom buttons / fit view | direct reuse | shared canvas shell | mouse 与 controls 自动化 | +| MiniMap pannable/zoomable | direct reuse | shared canvas shell | MiniMap 可见并可操作 | +| node / edge / pane selection | direct reuse | controlled/uncontrolled selection props | click、pane clear、live update 后 selection 稳定 | +| keyboard node/edge selection | direct reuse | focusable node/edge labels | Enter/Space + focus-visible tests | +| presentation node dragging | host adapter | shared emits position change;sidecar 保存在当前 Browser session,不写 projection | drag 后位置变化;snapshot 更新不篡改 authority | +| durable Desktop node positions | not-applicable | Codex Plugin 无 Desktop runtime position authority | sidecar 不调用 store mutation | +| Agent node card hierarchy | direct reuse | shared portable AgentNode view model | role/identity/state/activity/report variants screenshots | +| provider/model/vendor identity | host adapter | optional bounded fields;无可信数据时显示 logical role/runtime,不伪造 native provider | present/absent variants | +| running/master/managed/frozen visual states | direct reuse where modeled | shared state/tone mapping | state fixture matrix | +| latest verdict / issue count / report summary | direct reuse | typed projection adapter | issues/clean/done fixtures | +| relationship edge routing/stroke/dash/arrow | direct reuse | shared portable edge model | four review-loop relationships 无交叠/裁剪 | +| edge label, active/recent/pending, report count | direct reuse | typed relationship status | fixtures + live SSE update | +| Relationship inspector layout | direct reuse | portable `RelationshipInspectorModel` | From/When/Then/Gate/State/Stop condition visible | +| relationship stop button | host adapter | injected capability + callback;default review loop maps to pause/cancel preview | enabled/disabled/pending/error tests | +| Loop badge node | direct reuse | portable loop product summary | headline/lap/status/stop reason visible | +| Loop panel status summary | direct reuse | portable `LoopPanelModel` | responsible role/verdict/stop reason states | +| blocking issues list | direct reuse | typed report issues only | file/line/severity and empty states | +| lap cards + report timeline | direct reuse | bounded typed timeline | issues→clean multiple lap fixture | +| recovery guidance | direct reuse | interrupted/capped recovery model | restart/cap fixtures | +| stop confirmation copy/interaction | direct reuse + host adapter | shared confirmation UI + injected cancel callback | confirm/cancel/action-pending tests | +| open responsible Agent | host adapter | optional callback; hidden with an explicit unavailable explanation when Codex cannot deep-link | capability present/absent tests | +| open final diff/provider settings/workflow builder | not-applicable | sidecar has no stable Desktop runtime/navigation authority | no fake buttons or shell commands | +| freeze participants / cluster controls | not-applicable | fixed cooperative review loop has no cluster freeze authority | absent from Plugin bundle | +| drag-to-connect / create arbitrary Agent | not-applicable | fixed three-role recipe; general graph authoring is out of scope | handles non-connectable; no mutation endpoint | +| source/clock/cluster synthetic nodes | not-applicable | fixed review loop projection contains root/implementer/reviewer only | no placeholder nodes | +| kernel/activity overlay | host adapter | bounded typed projection timeline, never transcript/hook tail | SSE updates typed activity only | +| light/dark theme tokens | direct reuse | shared CSS variables with Desktop-compatible tokens | light/dark screenshots | +| responsive wide/narrow layout | direct reuse | shared deterministic routing/layout | wide/narrow no overlap/crop | +| reduced motion | direct reuse | media query disables nonessential animation | browser media emulation | + +## Independent checkpoint + +Plugin UI may not import the expanded package until 1–5 已通过;第 6 项在 Plugin 接入后、 +Gate A 关闭前执行: + +1. portable component/model source with no Electron/runtime/MCP import; +2. dev server and shipped production bundle; +3. automated interaction coverage for every `direct reuse` row and every applicable adapter row; +4. machine-readable result with zero console errors and request inventory; +5. light/dark + wide/narrow screenshots including MiniMap, Relationship inspector and Loop panel; +6. static audit proving the Plugin does not ship a second node/edge/inspector/loop-panel implementation. + +## Checkpoint result + +- 结果:PASS +- machine-readable evidence:`shared-package-checkpoint.json` +- production build/test:`npm test -w @looperators/agent-graph-ui`,6/6 pass +- dev Browser:真实 Chromium 验证 zoom、drag、keyboard selection、edge selection、 + Relationship inspector、Loop panel/stop confirmation、wide/narrow、dark/light。 +- production Browser:self-contained embed 显示 3 nodes + 4 edges + MiniMap;0 console error, + `performance` resource inventory 为空。 +- 初次验收拦截并修复两项真实缺陷:受控节点丢失 measured state 导致节点隐藏/边不渲染; + 重复 dimension change 导致 `ResizeObserver` warning storm。修复后重启 clean Browser session + 并重复 panel/resize 操作,dev server 无新增 warning。 +- screenshots: + - `output/playwright/agent-graph-ui-shared-checkpoint/graph-wide.png` + - `output/playwright/agent-graph-ui-shared-checkpoint/graph-narrow.png` + - `output/playwright/agent-graph-ui-shared-checkpoint/graph-light.png` + - `output/playwright/agent-graph-ui-shared-checkpoint/loop-panel-wide.png` + - `output/playwright/agent-graph-ui-shared-checkpoint/production-embed.png` + +## Deferred Desktop migration + +During Gate A/B the Desktop authority files remain unchanged. After Plugin acceptance, a separate +change switches Desktop imports to the accepted shared components and deletes the temporary duplicate +implementation. That later migration cannot weaken the Plugin checkpoint retroactively. diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/fable-5-desktop-reuse-review.json b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/fable-5-desktop-reuse-review.json new file mode 100644 index 0000000..c4d4223 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/fable-5-desktop-reuse-review.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "reviewedAt": "2026-08-02", + "reviewer": { + "client": "Claude Code 2.1.218", + "model": "claude-fable-5", + "mode": "read-only", + "subagentsAllowed": false, + "webAllowed": false + }, + "initialVerdict": "GO_WITH_AMENDMENTS", + "blocking": [], + "acceptedAmendments": [ + "The shared package extraction scope is determined by the Desktop interaction parity matrix, not by the sidecar's current minimum needs.", + "The parity matrix is a required artifact with Desktop source references and classification reasons, and the shared package must pass an independent checkpoint before Plugin import." + ], + "finalReview": { + "verdict": "GO", + "amendmentsClosed": true, + "blocking": [], + "consensus": "Desktop graph components and interactions are the authority baseline. Extract and independently accept the shared package first, integrate it into the Plugin second, and change Desktop imports only after Plugin acceptance." + } +} diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/fable-5-plan-review.json b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/fable-5-plan-review.json new file mode 100644 index 0000000..36fcbde --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/fable-5-plan-review.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": 1, + "reviewedAt": "2026-08-02", + "reviewer": { + "client": "Claude Code 2.1.218", + "model": "claude-fable-5", + "mode": "read-only", + "tools": [ + "Read" + ], + "subagentsAllowed": false, + "webAllowed": false + }, + "run": { + "result": "completed", + "verdict": "GO_WITH_AMENDMENTS", + "budgetUsd": 4, + "costUsd": 1.721317, + "durationApiMs": 118095 + }, + "blocking": [], + "acceptedAmendments": [ + "Move interrupted recovery from SessionStart hooks to MCP process/action epochs and root-authorized reconciliation.", + "Specify capability validity and revocation across pause, resume, cancel, cap, and restart recovery.", + "Bind each browser session cookie to exactly one run instead of all runs served by the MCP process.", + "Distinguish host click routing from the ability of the Codex built-in Browser to render the localhost surface.", + "Return a safe actionable expiry page for consumed, expired, or old-process login tickets." + ], + "keep": [ + "Versioned store, canonical JSON, projection digest, typed reports and transitions.", + "Idempotent requestId mutation handling.", + "Platform data-root resolution.", + "The full ReactFlow agent graph package for the localhost surface.", + "The existing loopback, token, cookie, Host and Origin, CSP, bounded SSE, watcher, and backpressure controls.", + "Preview, start, report, pause, resume, and cancel domain semantics." + ], + "removeFromDefaultDistribution": [ + "All seven plugin hook declarations and hook collector entrypoints.", + "Hook readiness and HOOKS_NOT_READY start gates.", + "Stop and SubagentStop continuation governor.", + "Native agent observation and prepare, poll, and bind tools.", + "Identity and shared-root probe tools.", + "Inline render tool from the default MCP surface.", + "Unpublished-version legacy recovery and quarantine tools." + ], + "scopeGuards": [ + "After Gate B, decide whether to delete or retain the existing inline code; do not implement a new inline surface in this plan.", + "Treat a self-contained signed cross-platform runtime as a future release gate; this delivery only validates and documents the macOS Node prerequisite." + ], + "consensusSummary": "The localhost-first, hook-free default architecture is sound and honestly describes cooperative governance. No blocking issue remains after adding MCP-owned restart reconciliation, precise capability revocation, run-scoped browser sessions, Browser acceptance attribution, and expired-ticket behavior.", + "finalConsensusReview": { + "result": "completed", + "verdict": "GO", + "costUsd": 0.774847, + "durationApiMs": 36835, + "allAmendmentsClosed": true, + "blocking": [], + "scopeDrift": [], + "summary": "All five amendments are closed at the specification level. Start implementation with Gate A and Gate B as the only acceptance gates." + } +} diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.json b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.json new file mode 100644 index 0000000..43cc961 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.json @@ -0,0 +1,92 @@ +{ + "schemaVersion": 1, + "capturedAt": "2026-08-03T07:34:24.000Z", + "codex": { + "lane": "desktop-bundled-headless", + "version": "codex-cli 0.146.0-alpha.9.2", + "userConfigLoaded": false, + "pluginsFeatureLoaded": false, + "multiAgentEnabled": true + }, + "plugin": { + "version": "0.3.2+codex.20260803073424", + "defaultHooks": false, + "protocol": "cooperative-typed-mcp", + "reviewSurface": "mcp-owned-127.0.0.1-sidecar" + }, + "run": { + "runId": "run_d19f85e9e12f20e88104fdd9e968a42428b385559863ad83402e1137be30a6b6", + "status": "succeeded", + "revision": 5, + "currentLap": 1, + "lapCap": 2, + "nativeSubagentCount": 2, + "rootThreadId": "019fc685-4648-7df3-aee1-b90d46b49745", + "nativeSubagents": [ + { + "role": "implementer", + "threadId": "019fc685-d665-7351-b9fd-0ad07ec51c4f", + "depth": 1, + "reportCalls": 2 + }, + { + "role": "reviewer", + "threadId": "019fc686-9fdf-7c31-a6ca-b24d7a4d6828", + "depth": 1, + "reportCalls": 2 + } + ], + "reportSequence": [ + { + "role": "implementer", + "type": "info", + "status": "done", + "fromRevision": 1, + "toRevision": 2 + }, + { + "role": "reviewer", + "type": "verdict", + "verdict": "issues", + "issueCount": 1, + "fromRevision": 2, + "toRevision": 3 + }, + { + "role": "implementer", + "type": "info", + "status": "done", + "fromRevision": 3, + "toRevision": 4 + }, + { + "role": "reviewer", + "type": "verdict", + "verdict": "clean", + "issueCount": 0, + "fromRevision": 4, + "toRevision": 5 + } + ], + "fixtureTests": { + "passed": 2, + "failed": 0 + }, + "reviewOrigin": "http://127.0.0.1:58094", + "reviewOriginAliveAfterMcpExit": false + }, + "crossProcessCapability": { + "originalFailure": "root-issued HMAC token was scoped to one MCP process and worker reports were rejected", + "fix": "opaque action token with durable SHA-256 digest and action identity", + "plaintextPersisted": false, + "threeStdioMcpProcessesPassed": true, + "governorRestartRotatesToken": true, + "preRotationTokenRejectedAfterRotation": true + }, + "privacy": { + "capabilityTokensIncluded": false, + "fullPromptsIncluded": false, + "rawRolloutsIncluded": false + }, + "passed": true +} diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.md b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.md new file mode 100644 index 0000000..52c9724 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.md @@ -0,0 +1,58 @@ +# Gate A real native-agent loop + +## Result + +Gate A's core real-agent journey passed with Desktop-bundled Codex +`0.146.0-alpha.9.2`: one root Governor created exactly one native implementer and one native +reviewer. The same two workers were followed up across lap 1. The authoritative typed sequence was +`implementer done → reviewer issues → implementer done → reviewer clean`; revision advanced from 1 +to 5 and terminated as `succeeded`. The final fixture contained two passing tests. + +The localhost surface opened on a random literal `127.0.0.1` port while the MCP process was alive. +The origin in the JSON artifact is evidence only and is intentionally no longer reachable after the +headless MCP process exited. + +## Core failure found and fixed + +The first real run exposed a genuine architecture bug: root and native workers each receive an +independent MCP server process. The old HMAC token used a process-local secret/epoch, so the worker +could never validate the root-issued capability. Re-signing from root only repeated the mismatch. + +The replacement is an opaque 48-byte action token. Only its SHA-256 digest plus +`runId/role/pendingTransitionId/actionRevision` is stored. Independent worker MCP processes validate +against that durable record; plaintext never enters JSON state. A new Governor MCP process rotates +the current token during root `get_loop`, so the skill forbids root polling while a worker is in +flight. + +## Reproduction shape + +The real run used the Desktop-bundled CLI with user config and the Plugin registry disabled. It +enabled native multi-agent support and manually registered only the source MCP server against an +isolated data root: + +```sh +/Applications/ChatGPT.app/Contents/Resources/codex exec \ + --ignore-user-config --disable plugins --enable multi_agent \ + --dangerously-bypass-approvals-and-sandbox --json \ + -C output/acceptance/agent-loop-gate-a-fixture \ + -c 'mcp_servers.looperators_gate_a.command="/usr/local/bin/node"' \ + -c 'mcp_servers.looperators_gate_a.args=["/plugins/looperators-agent-loop/mcp/server.mjs"]' \ + -c 'mcp_servers.looperators_gate_a.env={ LOOPERATORS_DATA_DIR = "" }' \ + '' +``` + +No Hooks, Personal Plugin, Desktop reload, Node protocol helper, commit, or push was used. Shell was +available only to the workers for editing the isolated fixture and running its test. + +## Evidence boundary + +- [gate-a-real-loop.json](./gate-a-real-loop.json) contains sanitized thread lineage, revisions and + typed report sequence. +- Native session metadata confirmed one depth-1 implementer and one depth-1 reviewer under the root. +- Each child rollout contained two direct `looperators_report` completions, matching the four durable + report facts. Rollout text and capability-bearing call arguments were not copied into the repo. +- Automated tests additionally use three simultaneous real stdio MCP processes and a separate + Governor-restart process. They verify token rotation, stale-token rejection and cancel. + +This transcript inspection is acceptance evidence, not a product protocol. Runtime authority remains +the versioned typed store; looperators does not tail transcripts. diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop-right-browser.png b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop-right-browser.png new file mode 100644 index 0000000..274b29d Binary files /dev/null and b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop-right-browser.png differ diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.json b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.json new file mode 100644 index 0000000..1fe56dd --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "gate": "B", + "verdict": "GO", + "date": "2026-08-03", + "environment": { + "codexDesktopVersion": "26.727.51351", + "codexDesktopBuild": "6119", + "codexCliVersion": "0.146.0-alpha.9.2", + "pluginVersion": "0.3.2+codex.20260803073424" + }, + "loop": { + "threadId": "019fc7b9-aa76-77d1-ac72-e4c3855772a8", + "runId": "run_6fc56d7d04232a324a59cd132a18b8246db045adc291eb3a2540f8746f9f22cc", + "projectionDigest": "bef9a9a2541d8c3a60dde9bc3170010dbdf4d8e38aa3870564a3cae385e3ef16", + "status": "succeeded", + "implementerReports": 1, + "reviewerReports": 1, + "reviewerVerdict": "clean" + }, + "browser": { + "surface": "codex-in-app-browser", + "verified": true, + "live": true, + "nodes": ["Governor", "Implementer", "Reviewer"], + "relationships": ["governs", "handoff", "feedback", "verdict"], + "reactFlowControls": true, + "miniMap": true + }, + "controlRoundTrip": { + "threadId": "019fc7c0-3a1f-7733-a0c7-88b01d291053", + "runId": "run_5b9e832b226b7a2312ef9a6d633ec6b4fcd4f72d18932b18503c3e70f9b79bb4", + "projectionDigest": "cee9cc4df8fd12acae291635df4f2f596c4779a9b8e5609fe9192bc63ec11b31", + "action": "pause", + "source": "localhost-ui", + "domStatus": "paused", + "mcpStatus": "paused", + "subagentCount": 0, + "workerReportCount": 0 + }, + "artifacts": { + "browserScreenshot": { + "path": "gate-b-desktop-right-browser.png", + "sha256": "5a1cf867ba74795501868100437b77298c6baf14d49affb1205c8c92fb97b4df" + } + }, + "security": { + "defaultHooks": false, + "loopbackOnly": true, + "ticketPersisted": false, + "capabilityTokenPersisted": false, + "transcriptProtocol": false + } +} diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.md b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.md new file mode 100644 index 0000000..bece097 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.md @@ -0,0 +1,78 @@ +# Gate B:Codex Desktop right Browser 验收 + +- 日期:2026-08-03 +- verdict:`GO` +- Codex Desktop:`26.727.51351`(build `6119`) +- Codex CLI:`0.146.0-alpha.9.2` +- Plugin:`0.3.2+codex.20260803073424` + +## 结论 + +fresh Desktop task 通过 typed MCP 完成一个真实 root + implementer + reviewer loop, +Codex 内置右侧 Browser 显示共享 Desktop-derived ReactFlow 图。独立 run 从页面点击 +`Request pause`,DOM 与 authoritative MCP state 均返回 `paused`。没有默认 Hooks、 +没有 shell/Node 脚本代调 MCP、没有仓库修改或 worker transcript 作为协议。 + +## 实际步骤 + +1. 使用 Desktop-bundled CLI 安装本地 marketplace Plugin,并核对安装版本为 + `0.3.2+codex.20260803073424`。 +2. 通过本地 marketplace Plugin detail 确认 Desktop 显示目标版本、1 个 MCP server、 + 1 个启用 Skill;随后创建 fresh Desktop task。 +3. task 调用 `preview → start → report(done) → report(clean) → get_snapshot`;恰好一个 + implementer 与一个 reviewer 参与。 +4. 调用 `open_review_surface`,只兑换一次短期 ticket;在 Codex 内置 Browser 中读取 + localhost 页面 DOM,并保存右侧 Browser 页面截图。 +5. 创建独立 control run,start 后不派 worker;从 localhost UI 点击唯一 + `Request pause`,随后同时检查 DOM 与 `looperators_get_loop`。 + +## 真实 loop 结果 + +- Desktop task:`019fc7b9-aa76-77d1-ac72-e4c3855772a8` +- run:`run_6fc56d7d04232a324a59cd132a18b8246db045adc291eb3a2540f8746f9f22cc` +- projection digest: + `bef9a9a2541d8c3a60dde9bc3170010dbdf4d8e38aa3870564a3cae385e3ef16` +- status:`succeeded` +- worker reports:implementer `done`,reviewer `clean` +- Browser DOM:`verified`、`succeeded`、`live`;Governor、Implementer、Reviewer; + governs、handoff、feedback、verdict;Controls、MiniMap。 + +## Control round-trip + +- Desktop task:`019fc7c0-3a1f-7733-a0c7-88b01d291053` +- run:`run_5b9e832b226b7a2312ef9a6d633ec6b4fcd4f72d18932b18503c3e70f9b79bb4` +- projection digest: + `cee9cc4df8fd12acae291635df4f2f596c4779a9b8e5609fe9192bc63ec11b31` +- action:右侧 UI 唯一 `Request pause` 按钮点击一次 +- DOM status:`paused` +- MCP status:`paused` +- subagent / worker report:0 / 0 + +## 截图证据 + +- [右侧 Browser 页面截图](gate-b-desktop-right-browser.png):完整 localhost graph viewport。 +- 全屏 Codex Desktop 截图不进入公共仓库,因为它包含与本实验无关的本地 task、用户与 + 系统元数据。公开证据只保留裁定 Gate B 所需的右侧 Browser surface。 + +SHA-256: + +- `gate-b-desktop-right-browser.png`: + `5a1cf867ba74795501868100437b77298c6baf14d49affb1205c8c92fb97b4df` + +## 失败与修正 + +- 已启动 Desktop task 不会热加载外部 CLI 更新的 dynamic tools;必须在 Desktop 识别目标 + Plugin detail/version 后创建 fresh task。 +- 旧 `0.2.0` 需要 `LOOPERATORS_DATA_DIR` / `PLUGIN_DATA`,因此出现 + `DATA_ROOT_UNAVAILABLE`;目标 `0.3.2` 默认使用 looperators-owned platform data path。 +- 同一 one-time ticket 被重复打开时,首次兑换页正常,其余页面显示 + `Review link unavailable`。最终验收严格 mint 一次、导航一次。 +- 初始验收 prompt 为禁止协议脚本而同时禁止了官方 Browser 控制运行时,导致 Agent 只生成 + URL 没有实际导航。修正为:继续禁止 shell/独立 Node/MCP 代调,只允许官方 Browser + skill 的内部控制运行时。 + +## 安全与隐私 + +- 未保存 ticket、cookie、capability token、完整 worker transcript 或私密 prompt。 +- localhost 只监听 `127.0.0.1`;公开截图不含凭据或无关本地 task 元数据。 +- evidence 只记录 run/thread id、projection digest、状态和可复现产品版本。 diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/isolated-install.json b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/isolated-install.json new file mode 100644 index 0000000..aa98b40 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/isolated-install.json @@ -0,0 +1 @@ +{"capturedAt":"2026-08-03T07:35:30.825Z","codex":{"lane":"desktop-bundled","version":"codex-cli 0.146.0-alpha.9.2"},"commands":["env -i CODEX_HOME= --version","env -i CODEX_HOME= plugin marketplace add ","env -i CODEX_HOME= plugin add looperators-agent-loop@looperators-p0 --json","env -i CODEX_HOME= plugin list --json","compare canonical source tree with plugins/cache/looperators-p0/looperators-agent-loop/0.3.2+codex.20260803073424"],"desktopRestarted":false,"globalConfigModified":false,"isolation":{"cleanedAfterProbe":true,"codexHomePathRecorded":false,"environmentAllowlist":["CODEX_HOME","LANG","LC_ALL","LC_CTYPE","PATH","TEMP","TERM","TMP","TMPDIR"],"inheritedCredentialVariables":false,"temporaryCodexHome":true},"marketplace":{"added":true,"name":"looperators-p0"},"passed":true,"plugin":{"addMatches":true,"addReturnedJson":true,"enabled":true,"exactListRecordCount":1,"fileCount":87,"installed":true,"installedTreeDigest":"59b77135857193515340769cc46129748e35c24379b14d8e9067490f2740cb15","listReturnedJson":true,"listed":true,"marketplaceSourceMatches":true,"name":"looperators-agent-loop","pluginId":"looperators-agent-loop@looperators-p0","sourceMatches":true,"sourceMatchesInstalledCache":true,"sourceTreeDigest":"59b77135857193515340769cc46129748e35c24379b14d8e9067490f2740cb15","version":"0.3.2+codex.20260803073424","versionMatches":true},"probe":"p1e-isolated-plugin-install","schemaVersion":1} diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/localhost-sidecar-checkpoint.md b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/localhost-sidecar-checkpoint.md new file mode 100644 index 0000000..82d5a29 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/localhost-sidecar-checkpoint.md @@ -0,0 +1,87 @@ +# MCP-owned localhost live review checkpoint + +Date: 2026-08-02 + +## Result + +PASS for the headless localhost product surface. This is not yet the isolated Codex Desktop +fresh-install Gate B. + +The MCP process now owns one sidecar per open run. The sidecar serves the full shared +Desktop-derived ReactFlow graph and writes pause/resume/cancel to the same authoritative typed +store used by MCP reports. + +## Product protocol + +- Bind address: literal `127.0.0.1` only, random OS-assigned port. +- Route: stable `/runs/` with run-scoped subordinate routes. +- Entry: short-lived one-time ticket; redemption redirects to the ticket-free route. +- Session: independent HttpOnly, SameSite=Strict cookie scoped to the run path. +- Mutation: exact Origin, JSON content type, session cookie and independent CSRF token. +- Live state: bounded SSE snapshots with projection digest IDs, Last-Event-ID support, client cap, + backpressure close and authoritative replay on store changes. +- Lifecycle: MCP `open_review_surface` starts/reuses the process-owned sidecar; + `close_review_surface` closes it; MCP stdin shutdown closes all remaining sidecars. +- Expired, consumed or unauthenticated entry renders a bounded safety page instead of exposing the + graph. + +No Hooks, inline renderer, host-private UI API, external asset server, `0.0.0.0` listener or +transcript tail is involved. + +## Automated reproduction + +From `/Users/observedobserver/Documents/GitHub/orrery`: + +```sh +npm test -w @looperators/agent-graph-ui +npm run test:plugin-agent-loop-ui +npm run lint +``` + +Observed: + +- shared graph package: 6 passed, 0 failed; +- Plugin build and full suite: 58 passed, 0 failed; +- real stdio MCP test opened the live surface, redeemed its ticket, loaded the page, rejected a + replayed ticket and hostile Origin, executed pause/resume, completed the typed loop and closed + the listening port; +- lint exited 0; generated Plugin UI assets are excluded like other build output, while source + warnings remain the repository's pre-existing warnings. + +## Browser acceptance + +The Playwright CLI opened the actual randomized localhost URL in Chromium. Verified DOM state: + +- title `looperators Agent Loop live review`; +- verified/running/live status; +- 3 role nodes and 4 relationship edges; +- ReactFlow zoom controls and MiniMap; +- node selection and inspector state; +- pause control changed the authoritative projection to `paused` through SSE; +- resume control changed it back to `running` through SSE; +- two control POSTs returned HTTP 200; +- console: 0 errors, 0 warnings. + +Evidence: + +- [interactive-resumed-live-graph.png](../../../../output/playwright/agent-loop-localhost-checkpoint/interactive-resumed-live-graph.png) + +The first browser run exposed a real production-only failure: the bundle referenced the Node +`process` global and fell back to semantic HTML. The fix defines `process.env.NODE_ENV=production` +in the Plugin Vite build, which removes the unsafe development branch. The acceptance was then +repeated from a fresh sidecar/browser session and the full graph rendered with no console error. + +The cooperative logical `role:*` bindings are intentionally not projected as native worker IDs; +the UI no longer makes the false claim that a native worker identity was observed. + +## Remaining gates + +1. Gate A: a real root task must create native implementer and reviewer subagents, receive typed + reports, exercise issues-to-clean or cap, and prove cancel/restart behavior against this live + surface. +2. Gate B: install the packaged Plugin from an isolated source and capture a Codex Desktop system + screenshot with this localhost page in the right-side built-in Browser. + +The official Codex manual documents that the Desktop built-in Browser can open local web apps, +share the view beside a task, interact with the page and capture screenshots. That establishes the +intended host surface, but only Gate B can prove the installed Plugin experience on this machine. diff --git a/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/shared-package-checkpoint.json b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/shared-package-checkpoint.json new file mode 100644 index 0000000..65afd32 --- /dev/null +++ b/internal_docs/experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/shared-package-checkpoint.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 1, + "checkpoint": "desktop-to-shared-agent-graph-ui", + "status": "passed", + "date": "2026-08-02", + "package": "@looperators/agent-graph-ui", + "commands": [ + "npm run typecheck -w @looperators/agent-graph-ui", + "npm test -w @looperators/agent-graph-ui", + "npm run dev -w @looperators/agent-graph-ui", + "npm run preview:embed -w @looperators/agent-graph-ui" + ], + "automatedTests": { + "passed": 6, + "failed": 0 + }, + "browser": { + "engine": "Chromium via Playwright CLI", + "dev": { + "nodesVisible": 3, + "edgesVisible": 4, + "miniMapVisible": true, + "zoomChangedViewport": true, + "dragChangedNodePosition": true, + "keyboardSelection": true, + "relationshipInspector": true, + "loopPanel": true, + "stopConfirmation": true, + "wideOrientation": "horizontal", + "narrowOrientation": "vertical", + "darkTheme": true, + "lightTheme": true, + "consoleErrorsAfterFix": 0, + "consoleWarningsAfterFix": 0 + }, + "productionEmbed": { + "nodesVisible": 3, + "edgesVisible": 4, + "miniMapVisible": true, + "consoleErrors": 0, + "consoleWarnings": 0, + "externalResourceRequests": 0 + } + }, + "failuresCaught": [ + { + "code": "CONTROLLED_NODE_MEASUREMENT_DROPPED", + "symptom": "React Flow nodes stayed hidden and edges were absent", + "resolution": "Persist complete NodeChange state instead of positions only" + }, + { + "code": "DUPLICATE_DIMENSION_CHANGE_LOOP", + "symptom": "ResizeObserver warning storm after panel and resize interactions", + "resolution": "Ignore dimension changes identical to the current measured dimensions" + } + ], + "screenshots": [ + "output/playwright/agent-graph-ui-shared-checkpoint/graph-wide.png", + "output/playwright/agent-graph-ui-shared-checkpoint/graph-narrow.png", + "output/playwright/agent-graph-ui-shared-checkpoint/graph-light.png", + "output/playwright/agent-graph-ui-shared-checkpoint/loop-panel-wide.png", + "output/playwright/agent-graph-ui-shared-checkpoint/production-embed.png" + ] +} diff --git a/internal_docs/plans/2026-08-02-codex-desktop-agent-loop-localhost-first.md b/internal_docs/plans/2026-08-02-codex-desktop-agent-loop-localhost-first.md new file mode 100644 index 0000000..64a94b5 --- /dev/null +++ b/internal_docs/plans/2026-08-02-codex-desktop-agent-loop-localhost-first.md @@ -0,0 +1,570 @@ +# looperators Codex Agent Loop:localhost-first 轻量 Plugin 计划 + +- 日期:2026-08-02 +- 分支:`codex/looperators-agent-loop-p1-data-root-fix` +- 状态:实施完成;Gate A / Gate B 均为 `GO` +- 目标:把当前研究型 P1 收敛为普通用户可安装、以右侧 live review surface 为主的轻量 Plugin + +## 1. 已确认的产品决策 + +以下是本计划的约束,不在实施中重新讨论或自行改变: + +1. **localhost sidecar 是主产品 surface,不是 fallback。** looperators 是重图、实时、 + 可交互功能;主要验收对象是 Codex Desktop 右侧 Browser 中的 live graph。 +2. **默认 Plugin 不携带 Hooks。** 普通安装不能要求用户信任 + `SessionStart`、`PreToolUse`、`PostToolUse`、`SubagentStart`、`SubagentStop`、`Stop` + 等命令 hooks。 +3. **默认模式是 cooperative governance。** Governor 与 worker 通过 Skill 和 typed MCP + 遵守协议;默认模式不宣称可以强制阻止任意 Agent 结束,也不宣称能观察每个原生 + tool/subagent lifecycle event。 +4. **Hooks 只可能成为未来单独安装的 advanced governance mode。** 本计划不实现该 + 模式,也不允许它阻塞默认模式。 +5. **Inline 退出核心路径。** 本计划不再以 `::codex-inline-vis` 为完成标准,不继续修补 + inline host。已有 inline 实现只在 localhost 主路径通过后再决定删除或保留为可选 + 静态摘要。 +6. **右侧 surface 的准确名称是 Codex 内置 Browser 中的 localhost Web App。** 当前 + 没有把第三方原生 Artifact 创建能力当作公开稳定依赖;可靠基线是返回可点击 URL, + 并在能力可用时帮助用户在右侧打开。 +7. **looperators Desktop 现有图 UI 是视觉与交互的权威来源。** localhost surface + 禁止从零重做一套“相似的图”。必须先从 Desktop 现有实现抽取可移植组件和交互原语, + Plugin 只提供 projection adapter、控制 callback 和 host glue。 + +### 1.1 Desktop UI 复用边界 + +当前 `@looperators/agent-graph-ui` 是一次简化抽取:它保留 ReactFlow、节点/边选择、 +zoom/fit 和基础详情,但没有完整复用 Desktop 的图产品。以下 Desktop 模块是本次抽取 +的 authority baseline: + +```text +src/components/session-graph-panel.tsx +src/components/canvas.tsx +src/components/loop-panel.tsx +src/components/relationship-inspector-panel.tsx +src/hooks/use-canvas.ts +src/lib/graph-view.ts +``` + +应最大力度直接复用或提取到共享 package: + +- ReactFlow canvas shell、Background、Controls、MiniMap、pan/zoom/fit; +- Agent node 的角色、provider/model、状态、报告/verdict、选中与 activity 视觉; +- relationship edge 的 routing、label、active/recent/pending 状态和选择交互; +- node/edge/pane selection 与 keyboard focus; +- Relationship inspector 的 from/when/then/gate/firing/stop condition 结构; +- Loop panel 的 lap、当前责任角色、latest verdict、blocking issues、recovery、timeline、 + stop confirmation 与终止原因; +- light/dark theme tokens、responsive layout 和 presentation-level node dragging。 + +只有依赖 Desktop runtime authority 的行为允许通过 adapter 替换: + +- `runtimeApi` mutation; +- 打开 Desktop chat、diff、provider settings 或 workflow builder; +- 新建/连接任意 Agent、修改通用拓扑、freeze cluster; +- Desktop 持久化 node position。 + +这些行为不得在共享组件内部硬编码。共享组件暴露 typed callbacks 和 capability flags: +Plugin sidecar 有等价 authority 时接入;没有 authority 时明确隐藏/禁用并解释,不创建 +假的本地行为。节点拖拽可以作为当前 Browser session 的 presentation state,但不能 +改写 authoritative loop projection。 + +实施顺序仍遵守此前约定: + +1. 从 Desktop 现有实现抽取/扩充共享组件并独立验收; +2. Plugin localhost surface 使用共享组件并完成 Gate A / Gate B; +3. Plugin 验收通过前,不修改 Desktop 的 import;Desktop 后续切换到共享 package 是 + 单独工作,不阻塞本次 Plugin 交付。 + +## 2. 为什么改线 + +当前 P1 已证明 typed report、有限 loop、projection、共享图组件、sidecar 安全边界和 +真实 subagents 都能工作,但它作为普通用户 Plugin 是 **NO-GO**: + +- 七个默认 Hooks 带来项目/定义信任、hash 变化后重新信任和跨版本字段耦合; +- Hook readiness、native agent identity binding、prepare/poll/bind 与 continuation + governor 把核心流程变成一条脆弱的宿主生命周期链; +- Plugin source、安装 cache、已启动 MCP process 和 task capability 可能同时处于不同 + 版本; +- inline 是非公开宿主协议上的只读快照,却占用了大量兼容与验收成本; +- 当前 Plugin 暴露 15 个 MCP tools,包含 probe、legacy recovery、worker binding 和 + inline render,普通 Agent 很难稳定选择正确路径。 + +现有成果并非全部推倒重来。继续复用: + +- versioned store、canonical JSON、projection digest 与 typed report schema; +- `@looperators/agent-graph-ui` 的 package/build/test 基础,并用 Desktop authority + baseline 补齐其节点、边、inspector、loop panel 和交互,而不是保留当前简化版作为 + 最终设计; +- sidecar 已有的 `127.0.0.1`、随机 secret、HttpOnly cookie、Host/Origin 校验、CSP、 + bounded SSE、watcher 和 backpressure; +- preview/start/report/pause/resume/cancel 的领域语义。 + +## 3. 目标架构 + +```text +Codex root task (Governor) + │ + ├─ Skill:规定 cooperative review-until-clean 流程 + ├─ typed MCP:唯一 mutation/query 接口 + │ ├─ preview/start/report/control + │ ├─ durable store + verified projection + │ └─ review-surface lifecycle manager + │ + ├─ native implementer/reviewer subagents + │ └─ role-scoped capability → typed report + │ + └─ http://127.0.0.1:/runs/ + ├─ Desktop-derived full ReactFlow graph + ├─ live SSE projection + └─ authenticated pause/resume/cancel intents +``` + +核心 authority 规则: + +- store 和 typed MCP 是业务事实源;Browser UI 不从 transcript 推断状态; +- sidecar 只展示 verified projection,mutation 复用与 MCP 相同的 controller; +- UI 不直接调用 Codex collaboration tools,也不伪装成能唤醒或强制终止原生 Agent; +- native `agent_id` 不再是默认模式的授权基础;默认模式使用 role/run/transition-scoped + capability,store 只保存 capability digest; +- lap cap 在 typed report transition 中结算,不依赖 `Stop`/`SubagentStop` hook; +- pause/cancel 对 looperators 状态和后续 report/transition 立即生效,但不能强杀已经运行 + 的 Codex worker。Governor 在每次 worker 返回后重新读取 authoritative state,禁止继续 + 调度已暂停或取消的 run; +- 每个 pending action 使用随机 opaque capability;共享 data root 只保存 SHA-256 digest + 与 run/role/transition/revision 身份,不保存明文。这样 root 与原生 worker 各自的 MCP + 进程可以跨进程验证同一个 action。Governor MCP 重启后,root `get_loop` 会旋转当前 + token;因此 Governor 不得在 worker in-flight 时轮询 `get_loop`。该恢复路径不依赖 + `SessionStart`、Hooks 或 transcript。 + +## 4. 用户旅程 + +### 4.1 Preview + +1. 用户选择 looperators Plugin 并请求 `review-until-clean`。 +2. Governor 调用 `looperators_preview_loop`;只创建 draft,不启动 subagent。 +3. MCP 同时确保 review surface 已启动,并返回一个短摘要和一次性登录 URL。 +4. 用户点击 URL,在右侧 Browser 看到 draft 图、角色、lap cap 和待确认状态。 + +### 4.2 Confirm and run + +1. 用户在 task 中明确确认;Governor 调用 `looperators_start_loop`。 +2. start 返回 authoritative `nextAction` 和 implementer 的 role-scoped report capability。 +3. Governor 创建原生 implementer,把 bounded work packet 与 capability 交给 worker。 +4. implementer 调用 `looperators_report(done)`;controller 原子消费当前 transition,激活 + reviewer 并签发 reviewer capability。 +5. reviewer 调用 `looperators_report(issues|clean)`: + - `issues` 且未到 cap:产生下一次 implementer action; + - `clean`:进入 `succeeded`; + - `issues` 且到 cap:进入 `capped`,不再签发 worker action。 +6. 每次 report/control 后,sidecar 通过 SSE 更新同一张图。 + +### 4.3 Sidecar controls + +- `pause`:立即把 run 设为 paused,拒绝 report 和新的 worker action;in-flight worker + 可能仍会返回,但其 report 会明确失败。UI 必须显示这一限制。普通 resume 后,尚未 + 被 root recovery read 旋转的当前 capability 可继续使用。 +- `cancel`:立即终止 looperators run,后续 report 明确失败且不得重新激活角色。 +- `resume`:恢复 durable run 状态。普通 pause/resume 不旋转未消费 capability;Governor + MCP 重启后的 root `get_loop` 必须旋转并返回新 capability。localhost 页面不能主动 + 唤醒 Codex task;UI 显示“已恢复,返回 task + 继续”,并提供可复制的短 follow-up。 +- `start`:仍要求在 Codex task 中明确确认;sidecar 不绕过对话中的确认门。 + +## 5. MCP surface 收敛 + +默认用户可见 tools 收敛为: + +```text +looperators_preview_loop +looperators_start_loop +looperators_report +looperators_get_loop +looperators_get_snapshot +looperators_pause_loop +looperators_resume_loop +looperators_cancel_loop +looperators_open_review_surface +looperators_close_review_surface +``` + +从默认 MCP tool list 移除: + +- identity/shared-root probes; +- prepare worker spawn、poll observed worker、native agent bind; +- inline render; +- unpublished-version legacy recovery/quarantine。 + +开发 probe 可以保留在测试目录,但不能继续污染普通 Agent 的 tool selection。 + +`start`/`report` 协议调整: + +- `start` 不再读取 `UserPromptSubmit` hook readiness;显式 user confirmation 由 Skill 与 + Governor 当前 turn 负责; +- capability 至少绑定 `runId + role + pendingTransitionId + action revision`; +- mutation 继续要求 stable `requestId`,重复调用返回同一结果; +- report token 只返回一次或按当前 action 重新签发,durable store 只保存 digest; +- cancel、cap 和 terminal state 立即拒绝全部未消费 capability;旧 capability report + 返回 bounded error 且不改变 run。pause 期间 report 同样被拒绝;普通 resume 后当前 + token 仍可使用,Governor restart recovery read 则旋转 token; +- 默认模式接受“持有 capability 即代表该 role”的安全模型,并在文档中明确 root 理论上 + 也能代 worker 调用 report;这不是强身份证明。 + +## 6. Localhost review surface 产品化 + +当前 `scripts/sidecar-server.mjs` 从 developer script 升级为 MCP 管理的 product service: + +1. MCP process 内维护一个 idempotent sidecar manager;同一进程只启动一个 + `127.0.0.1` listener,多个 run 使用稳定路由 `/runs/`。 +2. 端口由 OS 随机分配;每次 `open` 签发短时、一次性 login ticket,兑换为独立 + `HttpOnly; SameSite=Strict` session cookie。每个 browser session 只授权 ticket + 指定的 run,cookie path 绑定 `/runs/`;不能枚举或读取同一 MCP process 的 + 其他 run。ticket 不写 store、不进日志。 +3. 保留 exact Host/Origin、CSP、no-store、nosniff、no-referrer、client/backpressure + 上限;所有 mutation 只接受 authenticated same-origin POST,并要求 idempotent + `requestId` 与 CSRF token。 +4. SSE 继续传 verified projection + digest;重连使用 `Last-Event-ID`,projection 不一致 + 时全量重发。 +5. 页面使用从 Desktop authority baseline 抽取的完整 + `@looperators/agent-graph-ui` ReactFlow bundle,不使用 inline 专用 DOM/SVG + renderer,也不另写 Plugin-only node/edge/inspector。 +6. 开发时可以使用独立 Vite dev server/HMR;分发时 sidecar 提供静态 production + bundle,业务更新协议只能是 typed store + SSE,不能把 Vite HMR 当产品协议。 +7. MCP process 退出时 listener 干净关闭;进程重启后 `open` 返回新 URL。旧 Browser + 页面必须显示断线状态,而不是假装仍然 live。已消费、过期或属于旧进程的 ticket + 返回安全的失效页:“会话已失效,请回到 task 重新打开”,不得只显示裸 401,也不得 + 泄露 run、token 或本地路径。 + +借鉴 Lottie 的是:稳定资源路由、文件/状态事实源、live preview、浏览器与 Agent 都能 +观察当前状态。不会照搬它的未鉴权 dev server、Vite HMR 产品依赖、shell/npm 启动或 +通用文件删除接口。 + +## 7. 实施顺序:一个交付,两个 Gate + +本计划不拆成大量独立阶段。所有代码工作围绕一个交付版本完成,只设置两个必须关闭的 +验收 Gate。 + +### Gate A:source-level lean loop + live surface + +实施: + +- 先建立 Desktop → shared package 的 interaction parity matrix;逐项标记 direct reuse、 + host adapter 或明确不适用,禁止用新做的简化替代品冒充复用。matrix 是 Gate A 必交 + artifact;每项必须链接 Desktop authority 源文件并说明分类理由,“不适用”不能只写 + 结论; +- 从 Desktop authority baseline 抽取 canvas/node/edge/relationship inspector/loop panel + 和交互原语,保持 portable props + typed callbacks;独立 package build、dev server 和 + browser interaction tests 通过后,Plugin 才能接入; +- 从默认 Plugin 分发物中物理移除 hooks 声明;不能只加 runtime flag; +- 把 Hook/native-agent binding 改为 role capability 协议; +- 收敛 MCP tool list 和 `review-until-clean` Skill; +- 将 sidecar manager 接入 MCP,完成稳定 run route、ticket/session、SSE 和 controls; +- Plugin sidecar 改用完整 ReactFlow graph;inline 不进入该 Gate; +- 保留 store/projection 数据的版本化与幂等性,必要时为开发中 schema 直接升版,不做 + 未发布版本兼容层。 + +自动验收: + +- **Shared package 独立 checkpoint(先于 Plugin import)**:package build、dev server、 + production bundle 与 parity matrix 中所有 direct reuse/adapter interaction tests 全绿, + 保存 matrix、机器可读结果和关键截图;未通过时 Plugin 禁止 import 新组件; +- Plugin manifest/安装内容中没有默认 hooks;全新 task 不出现 hooks trust UI; +- preview 自动返回 review URL,且不启动 subagent; +- role capability 的合法、错误角色、错误 transition、重复、过期和泄漏日志检查通过; +- real root + implementer + reviewer 完成 `done → issues → done → clean`; +- cap、pause、resume、cancel、被拒绝的 late report 和 MCP restart recovery 通过; +- Browser production bundle 显示三节点四关系,并通过 Desktop parity matrix 中适用的 + selection、keyboard、pan/zoom/fit、MiniMap、node drag、relationship inspector、 + loop/lap/issues/recovery/timeline/details; +- Plugin bundle 不包含第二套 Plugin-only node/edge/inspector/loop panel 实现; +- SSE 实时更新 status/lap/active edge/report;断线和重连状态准确; +- sidecar 只监听 `127.0.0.1`,未认证、错误 Host/Origin、CSRF、重复 control 和慢客户端 + 测试通过; +- 一个 browser session 只能访问其 ticket 指定的 run;跨 run 请求与 cookie 复用被拒绝; +- 已消费、过期、旧进程 ticket 显示明确失效页; +- ordinary Codex task 不触发 looperators 命令、不写 store、不出现 hook failure。 + +Gate A 失败时不安装到用户 Personal Plugins,不请求 Desktop reload。 + +### Gate B:fresh-install + Desktop right-side acceptance + +前置:Gate A 全部通过,source cleanly packaged。 + +验收: + +1. 临时 `CODEX_HOME` 执行 isolated plugin install,安装包中无 hooks、无重复 Plugin、 + source/cache digest 一致; +2. 最小环境启动 MCP,验证 data root、Node runtime prerequisite 和清晰失败信息; +3. fresh headless Codex task 只使用 Plugin typed tools 跑通真实 cooperative loop; +4. 更新当前 Personal Plugin 前先核对已安装版本/cache/process,不通过连续 reload 猜测; +5. 如运行中 Desktop registry 无法加载新分发物,只在所有自动 Gate 完成后请求一次 reload; +6. 新 Desktop root task 创建 preview,用户通过点击 URL 或手动把 URL 打开到 Codex 内置 + Browser,右侧显示 live graph。宿主把普通点击路由到外部浏览器不归因于 Plugin + failure,但 Gate B 仍未通过;只有“Codex 内置 Browser 无法呈现该 localhost 页面” + 才判定 Plugin surface NO-GO; +7. 公开验收截图只保留裁剪或脱敏后的右侧 Browser surface,必须显示三节点关系图; + pause/cancel 至少验证一个真实控制 round-trip;不得提交无关 task、用户或系统元数据; +8. Inline 不属于 Gate B,不得因 inline 未渲染而判定本版本失败。 + +Gate B 完成后,才清理默认 Plugin 中已经没有调用者的 hook/inline/legacy 实现与测试; +清理必须单独验证,不与首次 live surface 跑通混在一起。 + +## 8. 测试层级 + +### 每次改动 + +- contracts/store/controller unit tests; +- sidecar HTTP/security/SSE tests; +- shared graph model/component tests,以及 Desktop interaction parity regression; +- MCP protocol tests; +- schema validation、syntax、lint、`git diff --check`。 + +### Gate A 验收 + +- production sidecar bundle browser automation; +- isolated data root 下的真实 typed MCP server; +- 真实 Codex root + 两个 native subagents; +- restart/cap/cancel 故障旅程。 + +### Gate B 最终验收 + +- isolated install; +- fresh Desktop task; +- 右侧 Browser live graph 与一次 interactive control; +- 裁剪或脱敏后的右侧 Browser surface 截图。 + +不使用 transcript tail 作为协议或通过依据。原始 prompt、完整 worker transcript、token、 +cookie 和 capability 不写入仓库 artifacts。 + +## 9. 明确风险与非承诺 + +### 9.1 无 Hooks 的能力代价 + +默认模式不能保证: + +- 拦截任意 root/worker 的 Stop; +- 观察全部工具调用或 native subagent lifecycle; +- 把 native `agent_id` 强绑定到 logical role; +- 强杀已经在运行的 Codex worker; +- 在用户只操作 sidecar 时主动唤醒空闲 Codex task。 + +这些限制必须出现在 README 和 UI 状态文案中,不能只藏在内部文档。 + +### 9.2 Browser 打开方式 + +支持目标是:返回明确可点击 URL,并在当前 Codex capability 可用时协助打开到右侧 +Browser。Plugin 不承诺第三方原生 Artifact API,也不把自动打开作为数据正确性的 +前提。 + +### 9.3 Node runtime + +当前 `.mcp.json` 以 `node ./mcp/server.mjs` 启动。Codex Plugin 安装本身不等于目标 +机器一定具备可用 Node。Gate B 必须记录真实 prerequisite 行为;在对外发布前,必须 +二选一: + +1. 未来分发自包含、签名/校验的跨平台 runtime;或 +2. 把 Node 版本作为明确 prerequisite,并提供安装前检查与可操作错误。 + +这不是 sidecar 架构问题,但它是普通用户“一次安装成功”的独立 release gate,不能 +继续隐去。本交付只验证当前 macOS/Node 环境的 prerequisite 与清晰错误,不设计跨平台 +runtime 打包/签名;后者进入对外发布前的独立 release plan。本交付不冒充已经完成 +跨平台分发。 + +## 10. 预计修改边界 + +主要修改: + +```text +plugins/looperators-agent-loop/.codex-plugin/plugin.json +plugins/looperators-agent-loop/hooks/** # 从默认分发移除 +plugins/looperators-agent-loop/lib/control.mjs +plugins/looperators-agent-loop/lib/store.mjs +plugins/looperators-agent-loop/lib/projection.mjs +plugins/looperators-agent-loop/lib/identity.mjs # 收缩为 role capability +plugins/looperators-agent-loop/mcp/server.mjs +plugins/looperators-agent-loop/scripts/sidecar-server.mjs +plugins/looperators-agent-loop/skills/review-until-clean/** +plugins/looperators-agent-loop/ui/** +plugins/looperators-agent-loop/tests/** +packages/agent-graph-ui/** # 按 §1.1 parity matrix 抽取/扩充;不以 sidecar 当前最小需求为上限 +internal_docs/** +``` + +Plugin Gate A / Gate B 期间不修改: + +```text +electron/** +src/components/session-graph-panel.tsx +src/components/canvas.tsx +src/components/loop-panel.tsx +src/components/relationship-inspector-panel.tsx +src/hooks/use-canvas.ts +src/lib/graph-view.ts +shared/graph-state.ts +plugins/looperators-agent-loop-p0/** +/Users/observedobserver/Documents/GitHub/lottie/** +``` + +Desktop authority files 在本计划中只作为权威来源;把可移植实现提取/复制到 shared +package,但不修改 Desktop import 或 Desktop 行为。这会产生一段有意、短期的重复代码。 +只有 Plugin 完成 Gate B 后,才另行把 Desktop import 切到已验收的 shared package 并 +删除重复实现。 + +不移植 Desktop sessionManager/workflowKernel/provider adapters,不迁移 package manager, +不开发 MCP App iframe,不创建通用 Canvas editor。 + +## 11. 完成定义 + +本版本只有在以下结果同时成立时才是 GO: + +1. 新用户默认安装不出现 looperators Hooks 信任流程; +2. fresh task 可以 preview,并在右侧 Browser 打开受保护的 live graph; +3. root + implementer + reviewer 通过 typed MCP 完成至少一次 issues→clean; +4. cap、pause、cancel 和 restart 不会产生新的错误 transition; +5. UI 实时反映 verified projection,并至少完成一次 interactive control; +6. 普通非 looperators task 零 hook、零写入、零干扰; +7. 裁剪或脱敏后的 Browser surface 截图证明右侧图真实显示; +8. README 清楚说明 cooperative mode 的能力代价、Browser 打开方式和 runtime prerequisite。 +9. Desktop interaction parity matrix 中所有适用于 fixed review loop 的项目均由共享 + package 提供;Plugin 没有并行维护第二套图组件。 + +若第 2、3 或 7 项失败,本版本 NO-GO;不得用 inline、普通浏览器静态 HTML、mock graph +或单元测试代替。 + +Gate B 后只决定 inline 代码是删除还是保留为可选静态摘要;本计划不实现新的 inline +能力,也不重新打开 inline host 兼容工作。 + +## 12. Fable 5 评审问题 + +请只评审以下高价值问题,不扩展到 Desktop UI、MCP App、Yarn、通用 Canvas 或未来 +advanced Hooks 实现: + +1. 默认无 Hooks 后,role capability + typed transition 是否足以形成诚实、可恢复的 + cooperative loop;是否有被忽略的 authority 混淆? +2. MCP-owned sidecar manager、一次性 ticket、cookie、CSRF、SSE 与 control mutation + 的边界是否足够安全和可测试? +3. 两个 Gate 是否聚焦核心交付,是否仍混入会分散注意力的工作? +4. 哪些现有模块应直接复用,哪些必须从默认分发物中物理移除? +5. Node runtime、Browser 无法唤醒 task、不能强停 in-flight worker 是否已被足够明确地 + 表达为 release/product boundary? + +## 13. Fable 5 评审与最终共识 + +- Claude Code:`2.1.218` +- 模型:`claude-fable-5` +- 模式:只读 `Read`,无 Bash/edit/web/subagent +- verdict:`GO_WITH_AMENDMENTS` +- blocking:无 +- scope drift:无新增实现范围;明确阻止重新实现 inline 和在本交付设计跨平台 runtime +- accepted amendments: + 1. 初始建议由 MCP/action epoch 接管无 Hook 的 interrupted recovery; + 2. 固定 pause/cancel/cap/restart 下的 capability 失效语义; + 3. browser session cookie 绑定单个 run; + 4. 区分“点击被宿主路由到外部浏览器”和“内置 Browser 无法呈现页面”; + 5. 为已消费、过期和旧进程 ticket 提供明确安全失效页。 + +评审证据: +[fable-5-plan-review.json](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/fable-5-plan-review.json)。 + +Gate A 真实原生 subagent 验收随后证明:root 与 worker 使用不同 MCP 进程,进程内签名 +epoch 会误拒绝合法 worker report。实现因此改为“opaque token + shared digest”,并用 +三真实 MCP 进程、Governor restart rotation 和真实 issues→clean loop 重新验收。该修订 +保持 Fable 要求的失效语义,但移除了不成立的同进程假设。 + +修订后由 Fable 5 再次只读复核,最终 verdict 为 **GO**:五项 amendment 均已在规范 +层面关闭,无 blocker、无新增 scope drift。双方共识是:按当前计划开始实施,只使用 +Gate A / Gate B 两个验收门;不得在实施中重新加入 inline、默认 Hooks、Desktop 改造 +或跨平台 runtime 打包。 + +2026-08-02 用户进一步明确:localhost 图必须最大力度复用最早的 looperators Desktop +图组件和交互,不能把当前简化的 shared graph 当成最终设计。本约束已补入 §1.1、§2、 +§6、Gate A 和 DoD。Fable 5 聚焦复核先给出两项措辞修订:shared package 的范围不得 +被 sidecar 当前最小需求限制;parity matrix 必须是带 Desktop 源文件与分类理由的 Gate A +artifact,并在 Plugin import 前设置独立 checkpoint。两项均已合入,最终复核为 **GO**, +无 blocker。 + +## 14. 实施检查点 + +### Checkpoint 1:Desktop 图交互抽取(完成) + +- commit:`b524f481 feat: extract desktop agent graph interactions` +- shared package build、unit test、dev/prod browser acceptance 均通过; +- production embed 显示 3 个节点、4 条边,无 console error/warning、无外部资源请求; +- Desktop authority 源文件保持不变,Plugin 验收前不切换 Desktop import。 + +### Checkpoint 2:无 Hooks cooperative typed MCP(完成) + +- 默认 Plugin 不再携带 `hooks/hooks.json`、hook collector 或 hook readiness gate; +- 默认 MCP surface 收敛为 preview/start/pause/resume/cancel/report/get loop/get snapshot; +- start 返回绑定当前 pending action 的短期 role capability,明文 token 不持久化; +- wrong-role、stale-action 和 cancel/terminal token 均拒绝; +- pause/resume、cancel、restart rotation、跨 MCP 进程 report、并发 report、幂等重试和 + lap cap 均有自动化覆盖; +- 验证记录: + [cooperative-mcp-checkpoint.md](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/cooperative-mcp-checkpoint.md)。 + +下一检查点只做 MCP-owned localhost live review surface,不恢复 inline 或默认 Hooks。 + +### Checkpoint 3:MCP-owned localhost live review surface(完成) + +- MCP 新增 open/close review surface 两个 typed product tools; +- 每个 run 使用 `127.0.0.1` 随机端口、短期一次性 ticket、HttpOnly/SameSite/run-scoped + cookie、同源 + CSRF control; +- 页面直接打包 shared Desktop-derived ReactFlow 组件,不维护第二套 Plugin graph; +- verified projection 通过 SSE 更新,pause/resume/cancel 写回同一个 typed store; +- MCP stdio 集成测试验证 open、ticket redemption、page load、pause/resume、close; +- Playwright 真实浏览器验证 3 节点、4 关系、node selection、pause→SSE paused、 + resume→SSE running,console 0 error / 0 warning; +- 验证记录: + [localhost-sidecar-checkpoint.md](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/localhost-sidecar-checkpoint.md)。 + +下一步进入 Gate A 的真实 root + implementer + reviewer loop。Codex Desktop 右侧 Browser +隐私安全的 Browser surface 截图与 isolated fresh install 仍属于 Gate B,不能由本 +checkpoint 代替。 + +### Checkpoint 4:Gate A 真实 native-agent loop(完成) + +- 首次真实 run 发现并修复跨 MCP 进程 capability bug;进程内 HMAC/epoch 改为 opaque token + + durable digest,明文仍不落盘; +- 三个真实 stdio MCP 进程分别承担 Governor/implementer/reviewer,typed report 通过; +- 新 Governor MCP 进程会旋转当前 token,旧 token 被拒绝; +- Desktop-bundled headless Codex 创建恰好一个 implementer 和一个 reviewer,并复用两者跑通 + `done → issues → done → clean`,revision 5、lap 1、terminal `succeeded`、2/2 tests; +- 全程未加载默认 Hooks、未使用 Personal Plugin、未 reload Desktop、未用 Node 脚本模拟 + 协议; +- 验证记录: + [gate-a-real-loop.md](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.md) / [gate-a-real-loop.json](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-a-real-loop.json)。 + +下一步只进入 Gate B:重新执行 isolated install probe,然后更新 Personal Plugin;只有自动 +安装、版本/cache/digest 核对完成后才允许一次 Desktop reload 与右侧 Browser 验收截图。 + +Gate B isolated preflight 已通过:Desktop-bundled Codex `0.146.0-alpha.9.2` 在临时 +`CODEX_HOME` 中安装 `0.3.2+codex.20260803073424`,只出现一个 looperators record,87 个 +文件,source/cache digest 同为 +`59b77135857193515340769cc46129748e35c24379b14d8e9067490f2740cb15`。临时 HOME 已清理, +未修改全局配置。证据: +[isolated-install.json](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/isolated-install.json)。 + +### Checkpoint 5:Gate B Desktop right Browser(完成) + +- Personal Plugin 最终加载版本:`0.3.2+codex.20260803073424`;Desktop 插件详情显示 + 1 个 MCP server、1 个启用 Skill、无默认 Hooks; +- fresh Desktop task 使用 typed MCP 跑通真实只读 loop:恰好一个 implementer 与一个 + reviewer,reviewer 返回 `clean`,run 进入 `succeeded`; +- 成功 run: + `run_6fc56d7d04232a324a59cd132a18b8246db045adc291eb3a2540f8746f9f22cc`, + projection digest: + `bef9a9a2541d8c3a60dde9bc3170010dbdf4d8e38aa3870564a3cae385e3ef16`; +- Codex 内置 Browser 的 DOM 与实际截图均确认 `verified / succeeded / live`,并显示 + Governor、Implementer、Reviewer 三节点、governs/handoff/feedback/verdict 四关系、 + ReactFlow Controls 与 MiniMap; +- 独立 control run 从右侧 localhost UI 点击唯一 `Request pause` 按钮;DOM 与 + `looperators_get_loop` 均确认 `paused`。该 run 未创建 subagent、未提交 worker report; +- 公开证据保留裁剪后的右侧 Browser live graph,不包含无关 Codex task、用户或系统元数据; +- 安装调试确认:外部 CLI 更新不会热刷新已启动 task 的 dynamic tool registry。可靠开发 + 路径是安装后打开本地 marketplace 的 Plugin detail,确认目标 version/MCP/Skill,再创建 + fresh task。一次性 review ticket 只能导航一次;重复打开应显示安全失效页。 + +证据: + +- [Gate B 记录](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.md) +- [机器可读结果](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop.json) +- [右侧 Browser 页面截图](../experiments/artifacts/2026-08-02-codex-desktop-agent-loop-localhost-first/gate-b-desktop-right-browser.png) diff --git a/output/playwright/agent-graph-ui-shared-checkpoint/graph-light.png b/output/playwright/agent-graph-ui-shared-checkpoint/graph-light.png new file mode 100644 index 0000000..b3606b0 Binary files /dev/null and b/output/playwright/agent-graph-ui-shared-checkpoint/graph-light.png differ diff --git a/output/playwright/agent-graph-ui-shared-checkpoint/graph-narrow.png b/output/playwright/agent-graph-ui-shared-checkpoint/graph-narrow.png new file mode 100644 index 0000000..77ad23e Binary files /dev/null and b/output/playwright/agent-graph-ui-shared-checkpoint/graph-narrow.png differ diff --git a/output/playwright/agent-graph-ui-shared-checkpoint/graph-wide.png b/output/playwright/agent-graph-ui-shared-checkpoint/graph-wide.png new file mode 100644 index 0000000..cdc6127 Binary files /dev/null and b/output/playwright/agent-graph-ui-shared-checkpoint/graph-wide.png differ diff --git a/output/playwright/agent-graph-ui-shared-checkpoint/loop-panel-wide.png b/output/playwright/agent-graph-ui-shared-checkpoint/loop-panel-wide.png new file mode 100644 index 0000000..ad66da3 Binary files /dev/null and b/output/playwright/agent-graph-ui-shared-checkpoint/loop-panel-wide.png differ diff --git a/output/playwright/agent-graph-ui-shared-checkpoint/production-embed.png b/output/playwright/agent-graph-ui-shared-checkpoint/production-embed.png new file mode 100644 index 0000000..8ff7afa Binary files /dev/null and b/output/playwright/agent-graph-ui-shared-checkpoint/production-embed.png differ diff --git a/output/playwright/agent-loop-localhost-checkpoint/interactive-resumed-live-graph.png b/output/playwright/agent-loop-localhost-checkpoint/interactive-resumed-live-graph.png new file mode 100644 index 0000000..2cc03ed Binary files /dev/null and b/output/playwright/agent-loop-localhost-checkpoint/interactive-resumed-live-graph.png differ diff --git a/package-lock.json b/package-lock.json index 3ba6f62..0e11933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "orrery", "version": "0.2.0", + "workspaces": [ + "packages/*" + ], "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.191", "@fontsource-variable/geist": "^5.2.9", @@ -1164,22 +1167,35 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "license": "MIT", "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1381,6 +1397,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@looperators/agent-graph-ui": { + "resolved": "packages/agent-graph-ui", + "link": true + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -4137,6 +4157,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", @@ -13256,6 +13336,25 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/agent-graph-ui": { + "name": "@looperators/agent-graph-ui", + "version": "0.1.0", + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "@xyflow/react": "^12.11.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "typescript": "~6.0.2", + "vite": "^8.1.0" + }, + "peerDependencies": { + "@xyflow/react": "^12.11.1", + "react": "^19.2.7", + "react-dom": "^19.2.7" + } } } } diff --git a/package.json b/package.json index ff40a95..0cdac6b 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "orrery", "private": true, "version": "0.2.0", + "workspaces": [ + "packages/*" + ], "description": "A loop-native desktop tool for agent workflows.", "author": "ObservedObserver", "type": "module", @@ -14,6 +17,11 @@ "dev:app": "npm run build:electron && concurrently -k \"vite --host 127.0.0.1 --port 48273 --strictPort\" \"tsc -p tsconfig.electron.json --watch --preserveWatchOutput\" \"wait-on http://127.0.0.1:48273 dist-electron/electron/main.js && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:48273 electron .\"", "dev:web": "vite --host 127.0.0.1 --port 48273 --strictPort", "dev:web:real": "npm run build:electron && concurrently -k \"tsc -p tsconfig.electron.json --watch --preserveWatchOutput\" \"node dist-electron/electron/runtimeHttpServerCli.js\" \"wait-on http://127.0.0.1:48274/api/runtime/config && cross-env VITE_ORRERY_RUNTIME_URL=http://127.0.0.1:48274 vite --host 127.0.0.1 --port 48273 --strictPort\"", + "dev:graph-ui": "npm run dev -w @looperators/agent-graph-ui", + "build:graph-ui": "npm run build -w @looperators/agent-graph-ui", + "test:graph-ui": "npm run test -w @looperators/agent-graph-ui", + "build:plugin-agent-loop-ui": "npm run build:graph-ui && tsc -p plugins/looperators-agent-loop/ui/tsconfig.json --noEmit && vite build --config plugins/looperators-agent-loop/ui/vite.config.ts", + "test:plugin-agent-loop-ui": "npm run build:plugin-agent-loop-ui && node --test --test-concurrency=4 plugins/looperators-agent-loop/tests/*.test.mjs", "build": "npm run build:electron && tsc -b && vite build", "build:electron": "tsc -p tsconfig.electron.json && node scripts/prune-electron-output.mjs", "check:electron": "tsc -p tsconfig.electron.json --noEmit", diff --git a/packages/agent-graph-ui/index.html b/packages/agent-graph-ui/index.html new file mode 100644 index 0000000..c2cb773 --- /dev/null +++ b/packages/agent-graph-ui/index.html @@ -0,0 +1,14 @@ + + + + + + + + looperators Agent Graph UI + + +
+ + + diff --git a/packages/agent-graph-ui/package.json b/packages/agent-graph-ui/package.json new file mode 100644 index 0000000..d4887a4 --- /dev/null +++ b/packages/agent-graph-ui/package.json @@ -0,0 +1,59 @@ +{ + "name": "@looperators/agent-graph-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "sideEffects": [ + "*.css" + ], + "files": [ + "dist" + ], + "main": "./dist/library/agent-graph-ui.js", + "module": "./dist/library/agent-graph-ui.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/library/agent-graph-ui.js" + }, + "./styles.css": "./dist/library/agent-graph-ui.css", + "./inline": { + "types": "./dist/types/inline.d.ts", + "import": "./dist/inline/agent-graph-ui-inline.js" + }, + "./inline.css": "./dist/inline/agent-graph-ui-inline.css", + "./fixtures": { + "types": "./dist/types/fixtures.d.ts", + "import": "./dist/library/agent-graph-ui.js" + } + }, + "scripts": { + "dev": "vite --host 127.0.0.1 --port 48311 --strictPort", + "build": "npm run clean && npm run typecheck && npm run build:types && npm run build:library && npm run build:inline && npm run build:embed", + "clean": "node scripts/clean.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit", + "build:types": "tsc -p tsconfig.build.json", + "build:library": "vite build --config vite.library.config.ts", + "build:inline": "vite build --config vite.inline.config.ts", + "build:embed": "vite build --config vite.embed.config.ts && node scripts/build-embed-harness.mjs", + "preview:embed": "vite preview --host 127.0.0.1 --port 48312 --strictPort --outDir dist/embed", + "preview": "vite preview --host 127.0.0.1 --port 48312 --strictPort", + "test": "npm run build && node --test tests/*.test.mjs" + }, + "peerDependencies": { + "@xyflow/react": "^12.11.1", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "@xyflow/react": "^12.11.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "typescript": "~6.0.2", + "vite": "^8.1.0" + } +} diff --git a/packages/agent-graph-ui/scripts/build-embed-harness.mjs b/packages/agent-graph-ui/scripts/build-embed-harness.mjs new file mode 100644 index 0000000..1634052 --- /dev/null +++ b/packages/agent-graph-ui/scripts/build-embed-harness.mjs @@ -0,0 +1,51 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +const outputRoot = new URL('../dist/embed/', import.meta.url); +const libraryUrl = new URL( + '../dist/library/agent-graph-ui.js', + import.meta.url, +); +const { desktopParityModel } = await import(libraryUrl.href); + +const [style, script] = await Promise.all([ + readFile(new URL('agent-graph-ui.css', outputRoot), 'utf8'), + readFile(new URL('agent-graph-ui.iife.js', outputRoot), 'utf8'), +]); +const envelope = JSON.stringify({ + model: desktopParityModel, + mode: 'snapshot', + followUpAvailable: false, +}).replaceAll('<', '\\u003c'); +const safeScript = script.replaceAll(' + + + + + + + looperators Agent Graph production embed + + + +
+ + + + +`; + +await writeFile(new URL('index.html', outputRoot), document, { + encoding: 'utf8', + mode: 0o600, +}); diff --git a/packages/agent-graph-ui/scripts/clean.mjs b/packages/agent-graph-ui/scripts/clean.mjs new file mode 100644 index 0000000..8e089a3 --- /dev/null +++ b/packages/agent-graph-ui/scripts/clean.mjs @@ -0,0 +1,6 @@ +import { rm } from 'node:fs/promises'; + +await rm(new URL('../dist', import.meta.url), { + recursive: true, + force: true, +}); diff --git a/packages/agent-graph-ui/src/agent-loop-graph.tsx b/packages/agent-graph-ui/src/agent-loop-graph.tsx new file mode 100644 index 0000000..a43687b --- /dev/null +++ b/packages/agent-graph-ui/src/agent-loop-graph.tsx @@ -0,0 +1,396 @@ +import { + applyNodeChanges, + Background, + BackgroundVariant, + Controls, + MiniMap, + ReactFlow, + type Edge, + type EdgeMouseHandler, + type Node, + type NodeMouseHandler, + type OnSelectionChangeParams, + type NodeChange, +} from '@xyflow/react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import { AgentRoleNode } from './agent-node'; +import { AgentRelationshipEdge } from './relationship-edge'; +import { LoopBadge } from './loop-badge'; +import { LoopPanel } from './loop-panel'; +import { RelationshipInspector } from './relationship-inspector'; +import { LoopStatus } from './loop-status'; +import { + assertAgentGraphModel, + createAgentLoopFlowElements, + orientationForWidth, + type AgentEdgeData, + type AgentGraphFollowUpIntent, + type AgentGraphModel, + type AgentGraphSelection, + type AgentNodeData, +} from './graph-model'; + +const nodeTypes = { + 'agent-role': AgentRoleNode, +}; + +const edgeTypes = { + 'agent-relationship': AgentRelationshipEdge, +}; + +export type AgentLoopGraphProps = { + model: AgentGraphModel; + mode?: 'snapshot' | 'live'; + liveState?: 'connecting' | 'live' | 'retrying'; + followUpAvailable?: boolean; + explainAvailable?: boolean; + controlAvailable?: boolean; + onFollowUp?: (intent: AgentGraphFollowUpIntent) => void | Promise; + className?: string; +}; + +function joinClassNames(...values: Array) { + return values.filter(Boolean).join(' '); +} + +function controlKind(model: AgentGraphModel) { + if (model.status === 'paused' || model.status === 'interrupted') { + return 'resume' as const; + } + return 'pause' as const; +} + +export function AgentLoopGraph({ + model: modelValue, + mode = 'snapshot', + liveState, + followUpAvailable = false, + explainAvailable = followUpAvailable, + controlAvailable = followUpAvailable, + onFollowUp, + className, +}: AgentLoopGraphProps) { + const model = assertAgentGraphModel(modelValue); + const containerRef = useRef(null); + const [width, setWidth] = useState(960); + const [selection, setSelection] = useState(); + const [followUpStatus, setFollowUpStatus] = useState(''); + const [loopPanelOpen, setLoopPanelOpen] = useState(false); + const [stopConfirming, setStopConfirming] = useState(false); + const orientation = orientationForWidth(width); + const flow = useMemo( + () => createAgentLoopFlowElements(model, orientation), + [model, orientation], + ); + const [presentationNodes, setPresentationNodes] = useState(flow.nodes); + const nodes = presentationNodes.map((node) => ({ + ...node, + draggable: true, + selected: selection?.kind === 'node' && selection.id === node.id, + })); + const edges: Edge[] = flow.edges.map((edge) => ({ + ...edge, + selected: selection?.kind === 'edge' && selection.id === edge.id, + data: { + ...(edge.data as AgentEdgeData), + onSelect: (edgeId: string) => setSelection({ kind: 'edge', id: edgeId }), + }, + })); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const updateWidth = () => { + const nextWidth = Math.round(container.getBoundingClientRect().width); + setWidth((currentWidth) => + currentWidth === nextWidth ? currentWidth : nextWidth, + ); + }; + updateWidth(); + if (typeof window === 'undefined') return; + window.addEventListener('resize', updateWidth); + return () => window.removeEventListener('resize', updateWidth); + }, []); + + useEffect(() => { + setPresentationNodes(flow.nodes); + }, [flow.nodes]); + + useEffect(() => { + if ( + selection?.kind === 'node' && + !model.nodes.some((node) => node.id === selection.id) + ) { + setSelection(undefined); + } + if ( + selection?.kind === 'edge' && + !model.edges.some((edge) => edge.id === selection.id) + ) { + setSelection(undefined); + } + }, [model.edges, model.nodes, selection]); + + const selectedNode = + selection?.kind === 'node' + ? model.nodes.find((node) => node.id === selection.id) + : undefined; + const selectedEdge = + selection?.kind === 'edge' + ? model.edges.find((edge) => edge.id === selection.id) + : undefined; + + const onNodeClick: NodeMouseHandler = (_event, node) => { + setSelection({ kind: 'node', id: node.id }); + }; + const onEdgeClick: EdgeMouseHandler = (_event, edge) => { + setSelection({ kind: 'edge', id: edge.id }); + }; + const onSelectionChange = useCallback( + ({ + nodes: selectedNodes, + edges: selectedEdges, + }: OnSelectionChangeParams, Edge>) => { + const selectedNodeValue = selectedNodes.at(-1); + if (selectedNodeValue) { + setSelection({ kind: 'node', id: selectedNodeValue.id }); + return; + } + const selectedEdgeValue = selectedEdges.at(-1); + if (selectedEdgeValue) { + setSelection({ kind: 'edge', id: selectedEdgeValue.id }); + return; + } + setSelection(undefined); + }, + [], + ); + const onNodesChange = useCallback( + (changes: NodeChange>[]) => { + setPresentationNodes((currentNodes) => { + const meaningfulChanges = changes.filter((change) => { + if (change.type !== 'dimensions' || !change.dimensions) return true; + const currentNode = currentNodes.find((node) => node.id === change.id); + return ( + currentNode?.measured?.width !== change.dimensions.width || + currentNode?.measured?.height !== change.dimensions.height + ); + }); + return meaningfulChanges.length === 0 + ? currentNodes + : applyNodeChanges(meaningfulChanges, currentNodes); + }); + }, + [], + ); + const onGraphKeyDownCapture = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const target = event.target; + if (!(target instanceof Element)) return; + const edgeElement = target.closest('.react-flow__edge'); + const nodeElement = target.closest('.react-flow__node'); + if (edgeElement?.dataset.id) { + setSelection({ kind: 'edge', id: edgeElement.dataset.id }); + } else if (nodeElement?.dataset.id) { + setSelection({ kind: 'node', id: nodeElement.dataset.id }); + } + }; + + const requestFollowUp = async ( + kind: AgentGraphFollowUpIntent['kind'], + ) => { + const available = kind === 'explain' ? explainAvailable : controlAvailable; + if (!available || !onFollowUp) { + setFollowUpStatus('Host follow-up is unavailable.'); + return; + } + try { + setFollowUpStatus('Requesting follow-up…'); + await onFollowUp({ + kind, + runId: model.identity.runId, + ...(selection ? { selection } : {}), + }); + setFollowUpStatus('Follow-up requested.'); + } catch { + setFollowUpStatus('Follow-up cancelled or failed.'); + } + }; + + const terminal = ['succeeded', 'capped', 'cancelled', 'failed'].includes( + model.status, + ); + const control = controlKind(model); + const requestStopConfirmation = () => { + setStopConfirming(true); + setLoopPanelOpen(true); + }; + const closeLoopPanel = () => { + setStopConfirming(false); + setLoopPanelOpen(false); + }; + return ( +
+ + {model.needsHuman || model.cancelRequested || model.recovery ? ( +
+ {model.recovery + ? `Recovery: ${model.recovery.reason}` + : model.cancelRequested + ? 'Cancellation requested.' + : 'Human attention is required.'} +
+ ) : null} +
+
+ setLoopPanelOpen(true)} /> +
+ , Edge> + key={orientation} + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + nodesDraggable + nodesConnectable={false} + elementsSelectable + elevateEdgesOnSelect + minZoom={0.45} + maxZoom={1.5} + fitView + fitViewOptions={{ padding: 0.24, maxZoom: 1.05 }} + onNodesChange={onNodesChange} + onNodeClick={onNodeClick} + onEdgeClick={onEdgeClick} + onSelectionChange={onSelectionChange} + onPaneClick={() => setSelection(undefined)} + aria-label="Agent Loop relationship graph" + > + + + + +
+
+ {selectedEdge && !loopPanelOpen ? ( + setSelection(undefined)} + stopAvailable={controlAvailable && !terminal} + onRequestStop={requestStopConfirmation} + /> + ) : null} + {loopPanelOpen ? ( + + ) : null} +
+
+
+

+ {selectedNode + ? 'Selected node' + : selectedEdge + ? 'Selected relationship' + : 'Verified snapshot'} +

+

+ {selectedNode?.label ?? + (selectedEdge + ? `${selectedEdge.source} → ${selectedEdge.target}` + : 'Select a node or relationship')} +

+

+ {selectedNode + ? `${selectedNode.role} · ${selectedNode.state}${selectedNode.nativeAgentId ? ' · native worker bound' : ''}` + : selectedEdge + ? `${selectedEdge.kind} · ${selectedEdge.reportCount} reports${selectedEdge.active ? ' · active' : ''}` + : `revision ${model.identity.revision} · digest ${model.identity.projectionDigest.slice(0, 12)}`} +

+
+
+ + + +
+ {followUpStatus ? ( +

+ {followUpStatus} +

+ ) : null} +
+
+ + Bounded timeline {model.timeline.length} + +
    + {model.timeline.length > 0 ? ( + model.timeline.map((item) => ( +
  1. + + {item.label} + {item.role ? {item.role} : null} +
  2. + )) + ) : ( +
  3. No authoritative timeline entries.
  4. + )} +
+
+
+ ); +} diff --git a/packages/agent-graph-ui/src/agent-node.tsx b/packages/agent-graph-ui/src/agent-node.tsx new file mode 100644 index 0000000..78e1218 --- /dev/null +++ b/packages/agent-graph-ui/src/agent-node.tsx @@ -0,0 +1,101 @@ +import { memo } from 'react'; +import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; +import type { AgentNodeData } from './graph-model'; + +const ports = [ + ['left-in-upper', 'target', Position.Left, 'upper'], + ['left-in-lower', 'target', Position.Left, 'lower'], + ['left-out-upper', 'source', Position.Left, 'upper'], + ['left-out-lower', 'source', Position.Left, 'lower'], + ['right-in-upper', 'target', Position.Right, 'upper'], + ['right-in-lower', 'target', Position.Right, 'lower'], + ['right-out-upper', 'source', Position.Right, 'upper'], + ['right-out-lower', 'source', Position.Right, 'lower'], + ['top-in', 'target', Position.Top, 'center'], + ['top-out', 'source', Position.Top, 'center'], + ['bottom-in', 'target', Position.Bottom, 'center'], + ['bottom-out', 'source', Position.Bottom, 'center'], +] as const; + +function roleGlyph(role: AgentNodeData['node']['role']) { + if (role === 'root') return '◆'; + if (role === 'implementer') return '⌘'; + return '✓'; +} + +function activityTime(value?: string) { + if (!value) return undefined; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +export const AgentRoleNode = memo(function AgentRoleNode({ + data, + selected, +}: NodeProps>) { + const { node, latestReport, pending } = data; + const runtimeLabel = node.runtimeLabel ?? (node.role === 'root' ? 'Governor' : 'Codex worker role'); + const stateLabel = pending ? 'pending' : node.role === 'root' ? 'master' : node.state; + return ( +
+ {ports.map(([id, type, position, lane]) => ( + + ))} +
+ + + {node.label} + {node.model ?? runtimeLabel} + + {stateLabel} +
+ +
+ {latestReport ? ( + <> +
+ + {latestReport.verdict === 'clean' + ? '✓ clean' + : latestReport.verdict === 'issues' + ? `! ${latestReport.issueCount} issue${latestReport.issueCount === 1 ? '' : 's'}` + : 'done'} + + {latestReport.type} +
+ {latestReport.summary ?

{latestReport.summary}

: null} + + ) : ( +

+ {node.description ?? + (node.role === 'root' + ? 'Owns the cooperative transition decision.' + : node.state === 'unbound' + ? 'Waiting for a role capability.' + : 'Participates through typed reports.')} +

+ )} +
+ +
+ {node.activityCount ?? 0} events + {activityTime(node.lastActivityAt) ?? node.id} +
+
+ ); +}); diff --git a/packages/agent-graph-ui/src/demo.css b/packages/agent-graph-ui/src/demo.css new file mode 100644 index 0000000..46fb03f --- /dev/null +++ b/packages/agent-graph-ui/src/demo.css @@ -0,0 +1,60 @@ +:root { + color-scheme: light dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.lag-demo { + min-height: 100vh; + background: #f8fafc; + padding: 24px; +} + +.lag-demo[data-mode="dark"] { + --background: #07090a; + --foreground: #e5e7eb; + --card: #0d120e; + --muted: #0f1511; + --muted-foreground: #9ca3af; + --border: rgba(255, 255, 255, 0.11); + --term-accent-hi: #a3e635; + --term-emerald: #6ee7b7; + --term-cyan: #67e8f9; + --term-amber: #fcd34d; + --term-rose: #fb7185; + background: #07090a; +} + +.lag-demo-toolbar { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + max-width: 1120px; + margin: 0 auto 14px; + color: var(--foreground, #171a1f); + font: 12px/1.4 ui-sans-serif, system-ui, sans-serif; +} + +.lag-demo-toolbar label { + display: flex; + align-items: center; + gap: 6px; +} + +.lag-demo > .looperators-agent-graph { + max-width: 1120px; + margin: 0 auto; +} + +@media (max-width: 719px) { + .lag-demo { + padding: 12px; + } +} diff --git a/packages/agent-graph-ui/src/demo.tsx b/packages/agent-graph-ui/src/demo.tsx new file mode 100644 index 0000000..8384179 --- /dev/null +++ b/packages/agent-graph-ui/src/demo.tsx @@ -0,0 +1,74 @@ +/* oxlint-disable react/only-export-components -- standalone Vite entrypoint */ +import { StrictMode, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AgentLoopGraph } from './agent-loop-graph'; +import { agentGraphScenarios, desktopParityModel } from './fixtures'; +import type { AgentGraphFollowUpIntent } from './graph-model'; +import '@xyflow/react/dist/style.css'; +import './styles.css'; +import './demo.css'; + +function Demo() { + const [scenarioName, setScenarioName] = useState('desktop-parity'); + const [dark, setDark] = useState(true); + const [followUpAvailable, setFollowUpAvailable] = useState(true); + const [lastIntent, setLastIntent] = useState(); + const model = + scenarioName === 'desktop-parity' + ? desktopParityModel + : (agentGraphScenarios.find( + (candidate) => candidate.name === scenarioName, + )?.expectedModel ?? agentGraphScenarios[0].expectedModel); + return ( +
+
+ + + + + {lastIntent + ? `${lastIntent.kind}:${lastIntent.selection?.id ?? 'run'}` + : 'No follow-up'} + +
+ setLastIntent(intent)} + /> +
+ ); +} + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/agent-graph-ui/src/embed.tsx b/packages/agent-graph-ui/src/embed.tsx new file mode 100644 index 0000000..ab02f1b --- /dev/null +++ b/packages/agent-graph-ui/src/embed.tsx @@ -0,0 +1,97 @@ +/* oxlint-disable react/only-export-components -- self-mounting plugin entrypoint */ +import { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AgentLoopGraph } from './agent-loop-graph'; +import { + assertAgentGraphModel, + type AgentGraphFollowUpIntent, + type AgentGraphModel, +} from './graph-model'; +import '@xyflow/react/dist/style.css'; +import './styles.css'; + +type EmbedEnvelope = { + model: AgentGraphModel; + mode?: 'snapshot' | 'live'; + followUpAvailable?: boolean; +}; + +type OpenAIHost = { + sendFollowUpMessage?: (input: { + prompt: string; + title?: string; + }) => Promise; +}; + +declare global { + interface Window { + openai?: OpenAIHost; + looperatorsAgentGraphView?: { + update: (nextModel: unknown) => void; + integrityError: (code: string) => void; + }; + } +} + +const rootElement = document.getElementById('looperators-agent-graph-root'); +const dataElement = document.getElementById('looperators-agent-graph-data'); + +function promptFor(intent: AgentGraphFollowUpIntent) { + const selected = intent.selection?.id; + if (intent.kind === 'explain') { + return `Explain ${selected ? `the selected Agent Loop item ${selected}` : 'this Agent Loop'} for run ${intent.runId}. Separate authoritative projection facts from inference.`; + } + return `Please preview ${intent.kind} for looperators Agent Loop run ${intent.runId}. Do not mutate it until I confirm.`; +} + +if (rootElement && dataElement) { + const envelope = JSON.parse(dataElement.textContent ?? '{}') as EmbedEnvelope; + const initialModel = assertAgentGraphModel(envelope.model); + let setExternalModel: ((next: AgentGraphModel) => void) | undefined; + let setIntegrityError: ((code: string | undefined) => void) | undefined; + + function EmbeddedGraph() { + const [model, setModel] = useState(initialModel); + const [error, setError] = useState(); + setExternalModel = setModel; + setIntegrityError = setError; + const hostAvailable = + envelope.followUpAvailable === true && + typeof window.openai?.sendFollowUpMessage === 'function'; + return ( + <> + {error ? ( +

+ Snapshot unavailable: {error} +

+ ) : null} + + window.openai?.sendFollowUpMessage?.({ + prompt: promptFor(intent), + title: `looperators Agent Loop ${intent.kind}`, + }) + : undefined + } + /> + + ); + } + + createRoot(rootElement).render(); + window.looperatorsAgentGraphView = Object.freeze({ + update(nextModel) { + setIntegrityError?.(undefined); + setExternalModel?.(assertAgentGraphModel(nextModel)); + }, + integrityError(code) { + setIntegrityError?.(String(code).slice(0, 80)); + }, + }); +} diff --git a/packages/agent-graph-ui/src/fixtures.ts b/packages/agent-graph-ui/src/fixtures.ts new file mode 100644 index 0000000..31dc2bd --- /dev/null +++ b/packages/agent-graph-ui/src/fixtures.ts @@ -0,0 +1,434 @@ +import type { + AgentGraphEdge, + AgentGraphModel, + AgentGraphNode, + AgentGraphNodeState, + AgentGraphReport, + AgentGraphStatus, +} from './graph-model'; + +type RawProjection = Record; + +export type AgentGraphScenario = { + name: string; + projection: RawProjection; + expectedModel: AgentGraphModel; +}; + +const edgeDefinitions = [ + { + id: 'root-governs-implementer', + source: 'root', + target: 'role:implementer', + kind: 'governs', + }, + { + id: 'implementer-handoff-reviewer', + source: 'role:implementer', + target: 'role:reviewer', + kind: 'handoff', + }, + { + id: 'reviewer-feedback-implementer', + source: 'role:reviewer', + target: 'role:implementer', + kind: 'feedback', + }, + { + id: 'reviewer-verdict-root', + source: 'role:reviewer', + target: 'root', + kind: 'verdict', + }, +] as const; + +function makeNodes( + states: [AgentGraphNodeState, AgentGraphNodeState, AgentGraphNodeState], + bound = true, +): AgentGraphNode[] { + return [ + { + id: 'root', + kind: 'root', + role: 'root', + label: 'Governor', + state: states[0], + }, + { + id: 'role:implementer', + kind: 'worker-role', + role: 'implementer', + label: 'Implementer', + state: states[1], + ...(bound + ? { + nativeAgentId: 'native-implementer-fixture', + nativeAgentType: 'worker', + } + : {}), + }, + { + id: 'role:reviewer', + kind: 'worker-role', + role: 'reviewer', + label: 'Reviewer', + state: states[2], + ...(bound + ? { + nativeAgentId: 'native-reviewer-fixture', + nativeAgentType: 'worker', + } + : {}), + }, + ]; +} + +function makeEdges( + activeKind?: AgentGraphEdge['kind'], + reports: Partial> = {}, +): AgentGraphEdge[] { + return edgeDefinitions.map((edge) => ({ + ...edge, + active: edge.kind === activeKind, + reportCount: reports[edge.kind] ?? 0, + })); +} + +function rawNodes(nodes: AgentGraphNode[]) { + return nodes.map(({ role, ...node }) => + node.kind === 'root' ? node : { ...node, role }, + ); +} + +function scenario(options: { + name: string; + digestCharacter: string; + status: AgentGraphStatus; + revision: number; + currentLap: number; + lapCap?: number; + lease?: { granted: number; consumed: number }; + states: [AgentGraphNodeState, AgentGraphNodeState, AgentGraphNodeState]; + bound?: boolean; + activeKind?: AgentGraphEdge['kind']; + reports?: Partial>; + latestReport?: AgentGraphReport; + pendingRole?: 'implementer' | 'reviewer'; + needsHuman?: boolean; + cancelRequested?: boolean; + recovery?: { reason: string }; +}): AgentGraphScenario { + const nodes = makeNodes(options.states, options.bound); + const edges = makeEdges(options.activeKind, options.reports); + const digest = options.digestCharacter.repeat(64); + const timeline = + options.revision === 0 + ? [] + : [ + { + id: `transition-${options.name}`, + at: '2026-07-28T18:00:00.000Z', + label: options.activeKind + ? `activate-${options.pendingRole ?? options.activeKind}` + : options.status, + kind: 'transition' as const, + ...(options.pendingRole ? { role: options.pendingRole } : {}), + }, + ...(options.latestReport + ? [ + { + id: `report-${options.name}`, + at: '2026-07-28T18:00:01.000Z', + label: + options.latestReport.verdict === 'issues' + ? 'reviewer verdict: issues' + : options.latestReport.verdict === 'clean' + ? 'reviewer verdict: clean' + : 'implementer info: done', + kind: 'report' as const, + role: options.latestReport.fromRole, + }, + ] + : []), + ]; + const model: AgentGraphModel = { + identity: { + runId: `run-${options.name}`, + revision: options.revision, + projectionDigest: digest, + integrity: 'verified', + }, + status: options.status, + currentLap: options.currentLap, + lapCap: options.lapCap ?? 3, + lease: options.lease ?? { granted: 3, consumed: 0 }, + cancelRequested: options.cancelRequested ?? false, + needsHuman: options.needsHuman ?? false, + nodes, + edges, + ...(options.pendingRole + ? { + pending: { + role: options.pendingRole, + kind: + options.pendingRole === 'implementer' + ? ('activate-implementer' as const) + : ('activate-reviewer' as const), + lap: options.currentLap, + }, + } + : {}), + ...(options.latestReport ? { latestReport: options.latestReport } : {}), + ...(options.recovery ? { recovery: options.recovery } : {}), + timeline, + }; + return { + name: options.name, + expectedModel: model, + projection: { + schemaVersion: 1, + storeVersion: 1, + projectionVersion: 3, + runId: model.identity.runId, + revision: model.identity.revision, + status: model.status, + recipe: 'review-until-clean', + currentLap: model.currentLap, + lapCap: model.lapCap, + continuationLease: model.lease, + cancelRequested: model.cancelRequested, + needsHuman: model.needsHuman, + integrity: { + status: 'verified', + verifiedRevision: model.identity.revision, + }, + eventWatermark: { + eventCount: 0, + }, + nodes: rawNodes(nodes), + edges, + ...(model.pending + ? { + pending: { + transitionId: `transition-${options.name}`, + ...model.pending, + }, + } + : {}), + ...(model.latestReport + ? { + latestReport: { + reportId: `report-${options.name}`, + ...model.latestReport, + }, + } + : {}), + ...(model.recovery + ? { + recovery: { + recoveryId: `recovery_${'d'.repeat(64)}`, + ...model.recovery, + }, + } + : {}), + counts: { + events: 0, + reports: model.latestReport ? 1 : 0, + transitions: model.identity.revision > 0 ? 1 : 0, + operations: model.identity.revision, + governorDecisions: 0, + recoveries: model.recovery ? 1 : 0, + diagnostics: 0, + conflicts: 0, + corrupt: 0, + }, + timeline, + projectionDigest: digest, + }, + }; +} + +export const agentGraphScenarios: AgentGraphScenario[] = [ + scenario({ + name: 'draft', + digestCharacter: 'a', + status: 'draft', + revision: 0, + currentLap: 0, + states: ['active', 'unbound', 'unbound'], + bound: false, + }), + scenario({ + name: 'implementer-active', + digestCharacter: 'b', + status: 'running', + revision: 1, + currentLap: 0, + states: ['waiting', 'active', 'unbound'], + activeKind: 'governs', + pendingRole: 'implementer', + }), + scenario({ + name: 'reviewer-issues', + digestCharacter: 'c', + status: 'running', + revision: 4, + currentLap: 1, + lease: { granted: 3, consumed: 1 }, + states: ['waiting', 'waiting', 'active'], + activeKind: 'feedback', + reports: { handoff: 1, feedback: 1 }, + pendingRole: 'implementer', + latestReport: { + fromRole: 'reviewer', + type: 'verdict', + verdict: 'issues', + issueCount: 2, + }, + }), + scenario({ + name: 'paused-needs-human', + digestCharacter: 'd', + status: 'paused', + revision: 5, + currentLap: 1, + lease: { granted: 3, consumed: 2 }, + states: ['active', 'bound', 'bound'], + reports: { handoff: 1, feedback: 1 }, + needsHuman: true, + }), + scenario({ + name: 'succeeded-clean', + digestCharacter: 'e', + status: 'succeeded', + revision: 7, + currentLap: 2, + lease: { granted: 3, consumed: 2 }, + states: ['terminal', 'terminal', 'terminal'], + activeKind: 'verdict', + reports: { handoff: 2, feedback: 1, verdict: 1 }, + latestReport: { + fromRole: 'reviewer', + type: 'verdict', + verdict: 'clean', + issueCount: 0, + }, + }), + scenario({ + name: 'capped', + digestCharacter: 'f', + status: 'capped', + revision: 6, + currentLap: 3, + lease: { granted: 3, consumed: 3 }, + states: ['terminal', 'terminal', 'terminal'], + reports: { handoff: 2, feedback: 2 }, + needsHuman: true, + }), + scenario({ + name: 'cancelled', + digestCharacter: '1', + status: 'cancelled', + revision: 2, + currentLap: 0, + lease: { granted: 3, consumed: 0 }, + states: ['terminal', 'terminal', 'terminal'], + cancelRequested: true, + }), +]; + +const reviewerIssuesScenario = agentGraphScenarios.find( + (candidate) => candidate.name === 'reviewer-issues', +); + +if (!reviewerIssuesScenario) { + throw new Error('reviewer-issues fixture is unavailable'); +} + +/** + * A UI-only acceptance model derived from the paired projection fixture. + * + * Keep this outside `agentGraphScenarios`: those fixtures prove the Plugin + * projection adapter is lossless, while this model exercises the richer + * Desktop-derived interaction surface before the Plugin imports it. + */ +export const desktopParityModel: AgentGraphModel = { + ...reviewerIssuesScenario.expectedModel, + responsibleRole: 'implementer', + nodes: reviewerIssuesScenario.expectedModel.nodes.map((node) => ({ + ...node, + runtimeLabel: node.role === 'root' ? 'Codex task' : 'Native subagent', + model: node.role === 'root' ? 'Governor' : 'worker', + description: + node.role === 'root' + ? 'Owns the bounded review policy and approves every transition.' + : node.role === 'implementer' + ? 'Addresses the latest blocking findings and reports completion.' + : 'Reviews the implementation and returns a typed verdict.', + activityCount: node.role === 'root' ? 4 : node.role === 'implementer' ? 7 : 5, + lastActivityAt: '2026-08-02T18:00:04.000Z', + })), + edges: reviewerIssuesScenario.expectedModel.edges.map((edge) => ({ + ...edge, + label: + edge.kind === 'governs' + ? 'Governed activation' + : edge.kind === 'handoff' + ? 'Implementation ready' + : edge.kind === 'feedback' + ? 'Blocking feedback' + : 'Clean verdict', + summary: + edge.kind === 'feedback' + ? 'Reviewer found blocking issues, so the Governor grants one bounded implementer continuation.' + : `Typed ${edge.kind} report advances the run only after Governor validation.`, + recent: edge.kind === 'handoff', + pending: edge.kind === 'feedback', + gate: 'Governor validates typed report and lease', + firings: edge.reportCount, + maxFirings: edge.kind === 'governs' ? 1 : 3, + stopWhen: edge.kind === 'feedback' ? 'clean verdict, cancellation, or lap cap' : 'terminal run state', + })), + latestReport: { + ...reviewerIssuesScenario.expectedModel.latestReport!, + summary: 'Two blocking findings remain before the run can finish cleanly.', + issues: [ + { + severity: 'error', + message: 'Cancel must revoke every unconsumed continuation capability.', + file: 'plugins/looperators-agent-loop/mcp/governor.mjs', + line: 214, + }, + { + severity: 'warn', + message: 'Recovery must reject actions issued before the current process epoch.', + file: 'plugins/looperators-agent-loop/mcp/store.mjs', + line: 91, + }, + ], + }, + laps: [ + { + index: 0, + status: 'issues', + at: '2026-08-02T18:00:02.000Z', + implementerSummary: 'Added bounded continuation and typed reviewer reports.', + reviewerSummary: 'Found cancellation and restart recovery gaps.', + verdict: 'issues', + issues: [ + { + severity: 'error', + message: 'Cancel did not revoke a previously issued continuation.', + }, + ], + }, + { + index: 1, + status: 'running', + at: '2026-08-02T18:00:04.000Z', + implementerSummary: 'Applying the two remaining fixes.', + reviewerSummary: 'Waiting for a new typed implementation report.', + issues: [], + }, + ], +}; diff --git a/packages/agent-graph-ui/src/graph-model.ts b/packages/agent-graph-ui/src/graph-model.ts new file mode 100644 index 0000000..1998900 --- /dev/null +++ b/packages/agent-graph-ui/src/graph-model.ts @@ -0,0 +1,496 @@ +import { MarkerType, Position, type Edge, type Node } from '@xyflow/react'; + +export const agentGraphStatuses = [ + 'draft', + 'running', + 'paused', + 'interrupted', + 'succeeded', + 'capped', + 'cancelled', + 'failed', +] as const; + +export const agentGraphNodeStates = [ + 'active', + 'waiting', + 'bound', + 'unbound', + 'terminal', +] as const; + +export const agentGraphEdgeKinds = [ + 'governs', + 'handoff', + 'feedback', + 'verdict', +] as const; + +export type AgentGraphStatus = (typeof agentGraphStatuses)[number]; +export type AgentGraphNodeState = (typeof agentGraphNodeStates)[number]; +export type AgentGraphEdgeKind = (typeof agentGraphEdgeKinds)[number]; +export type AgentGraphRole = 'root' | 'implementer' | 'reviewer'; +export type AgentGraphOrientation = 'horizontal' | 'vertical'; + +export type AgentGraphNode = { + id: string; + kind: 'root' | 'worker-role'; + role: AgentGraphRole; + label: string; + state: AgentGraphNodeState; + nativeAgentId?: string; + nativeAgentType?: string; + runtimeLabel?: string; + model?: string; + description?: string; + activityCount?: number; + lastActivityAt?: string; +}; + +export type AgentGraphEdge = { + id: string; + source: string; + target: string; + kind: AgentGraphEdgeKind; + active: boolean; + reportCount: number; + label?: string; + summary?: string; + recent?: boolean; + pending?: boolean; + gate?: string; + firings?: number; + maxFirings?: number; + stopWhen?: string; +}; + +export type AgentGraphIssue = { + message: string; + severity?: 'info' | 'warn' | 'error'; + file?: string; + line?: number; +}; + +export type AgentGraphReport = { + fromRole: 'implementer' | 'reviewer'; + type: 'info' | 'verdict'; + verdict?: 'issues' | 'clean'; + issueCount: number; + summary?: string; + issues?: AgentGraphIssue[]; +}; + +export type AgentGraphPending = { + role: 'implementer' | 'reviewer'; + kind: 'activate-implementer' | 'activate-reviewer'; + lap: number; +}; + +export type AgentGraphTimelineItem = { + id: string; + at: string; + label: string; + kind: 'event' | 'report' | 'transition' | 'governor-decision' | 'recovery'; + role?: 'implementer' | 'reviewer'; +}; + +export type AgentGraphLap = { + index: number; + status: 'running' | 'issues' | 'clean' | 'capped' | 'interrupted'; + at?: string; + implementerSummary?: string; + reviewerSummary?: string; + verdict?: 'issues' | 'clean'; + issues: AgentGraphIssue[]; +}; + +export type AgentGraphModel = { + identity: { + runId: string; + revision: number; + projectionDigest: string; + integrity: 'verified'; + }; + status: AgentGraphStatus; + currentLap: number; + lapCap: number; + lease: { + granted: number; + consumed: number; + }; + cancelRequested: boolean; + needsHuman: boolean; + nodes: AgentGraphNode[]; + edges: AgentGraphEdge[]; + pending?: AgentGraphPending; + latestReport?: AgentGraphReport; + recovery?: { + reason: string; + guidance?: string; + }; + responsibleRole?: AgentGraphRole; + stopReason?: string; + laps?: AgentGraphLap[]; + timeline: AgentGraphTimelineItem[]; +}; + +export type AgentGraphSelection = + | { kind: 'node'; id: string } + | { kind: 'edge'; id: string }; + +export type AgentGraphFollowUpKind = + | 'explain' + | 'pause' + | 'resume' + | 'cancel'; + +export type AgentGraphFollowUpIntent = { + kind: AgentGraphFollowUpKind; + runId: string; + selection?: AgentGraphSelection; +}; + +export type AgentNodeData = { + node: AgentGraphNode; + latestReport?: AgentGraphReport; + pending: boolean; +} & Record; + +export type AgentEdgeRoute = 'direct' | 'lower' | 'left' | 'right'; + +export type AgentEdgeData = { + edge: AgentGraphEdge; + route: AgentEdgeRoute; + latestReport?: AgentGraphReport; + onSelect?: (edgeId: string) => void; +} & Record; + +export type LoopSummaryData = { + model: AgentGraphModel; + onOpen?: () => void; +} & Record; + +const digestPattern = /^[a-f0-9]{64}$/; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireString( + value: unknown, + path: string, + options: { max?: number; pattern?: RegExp } = {}, +) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > (options.max ?? 256) || + (options.pattern && !options.pattern.test(value)) + ) { + throw new TypeError(`${path} must be a bounded string`); + } +} + +function requireInteger( + value: unknown, + path: string, + options: { min?: number; max?: number } = {}, +) { + if ( + !Number.isSafeInteger(value) || + (options.min !== undefined && Number(value) < options.min) || + (options.max !== undefined && Number(value) > options.max) + ) { + throw new TypeError(`${path} must be a bounded integer`); + } +} + +export function assertAgentGraphModel(value: unknown): AgentGraphModel { + if (!isRecord(value)) { + throw new TypeError('agent graph model must be an object'); + } + if (!isRecord(value.identity)) { + throw new TypeError('agent graph model identity must be an object'); + } + requireString(value.identity.runId, 'identity.runId', { max: 128 }); + requireInteger(value.identity.revision, 'identity.revision', { min: 0 }); + requireString(value.identity.projectionDigest, 'identity.projectionDigest', { + max: 64, + pattern: digestPattern, + }); + if (value.identity.integrity !== 'verified') { + throw new TypeError('identity.integrity must be verified'); + } + if (!agentGraphStatuses.includes(value.status as AgentGraphStatus)) { + throw new TypeError('agent graph model status is invalid'); + } + requireInteger(value.currentLap, 'currentLap', { min: 0, max: 6 }); + requireInteger(value.lapCap, 'lapCap', { min: 1, max: 6 }); + if (Number(value.currentLap) > Number(value.lapCap)) { + throw new TypeError('currentLap cannot exceed lapCap'); + } + if (!isRecord(value.lease)) { + throw new TypeError('agent graph model lease must be an object'); + } + requireInteger(value.lease.granted, 'lease.granted', { min: 0, max: 6 }); + requireInteger(value.lease.consumed, 'lease.consumed', { min: 0, max: 6 }); + if (Number(value.lease.consumed) > Number(value.lease.granted)) { + throw new TypeError('lease.consumed cannot exceed lease.granted'); + } + if ( + typeof value.cancelRequested !== 'boolean' || + typeof value.needsHuman !== 'boolean' + ) { + throw new TypeError('agent graph model flags must be boolean'); + } + if ( + !Array.isArray(value.nodes) || + value.nodes.length !== 3 || + !Array.isArray(value.edges) || + value.edges.length !== 4 || + !Array.isArray(value.timeline) || + value.timeline.length > 32 + ) { + throw new TypeError('agent graph model cardinality is invalid'); + } + const nodeIds = new Set(); + for (const [index, candidate] of value.nodes.entries()) { + if (!isRecord(candidate)) { + throw new TypeError(`nodes[${index}] must be an object`); + } + requireString(candidate.id, `nodes[${index}].id`, { max: 96 }); + requireString(candidate.label, `nodes[${index}].label`, { max: 80 }); + if (!['root', 'worker-role'].includes(String(candidate.kind))) { + throw new TypeError(`nodes[${index}].kind is invalid`); + } + if (!['root', 'implementer', 'reviewer'].includes(String(candidate.role))) { + throw new TypeError(`nodes[${index}].role is invalid`); + } + if (!agentGraphNodeStates.includes(candidate.state as AgentGraphNodeState)) { + throw new TypeError(`nodes[${index}].state is invalid`); + } + if (nodeIds.has(String(candidate.id))) { + throw new TypeError('agent graph node ids must be unique'); + } + nodeIds.add(String(candidate.id)); + for (const key of ['runtimeLabel', 'model', 'description', 'lastActivityAt'] as const) { + if (candidate[key] !== undefined) { + requireString(candidate[key], `nodes[${index}].${key}`, { max: 512 }); + } + } + if (candidate.activityCount !== undefined) { + requireInteger(candidate.activityCount, `nodes[${index}].activityCount`, { + min: 0, + }); + } + } + for (const [index, candidate] of value.edges.entries()) { + if (!isRecord(candidate)) { + throw new TypeError(`edges[${index}] must be an object`); + } + requireString(candidate.id, `edges[${index}].id`, { max: 96 }); + requireString(candidate.source, `edges[${index}].source`, { max: 96 }); + requireString(candidate.target, `edges[${index}].target`, { max: 96 }); + if (!agentGraphEdgeKinds.includes(candidate.kind as AgentGraphEdgeKind)) { + throw new TypeError(`edges[${index}].kind is invalid`); + } + if ( + !nodeIds.has(String(candidate.source)) || + !nodeIds.has(String(candidate.target)) + ) { + throw new TypeError(`edges[${index}] references an unknown node`); + } + if (typeof candidate.active !== 'boolean') { + throw new TypeError(`edges[${index}].active must be boolean`); + } + requireInteger(candidate.reportCount, `edges[${index}].reportCount`, { + min: 0, + }); + for (const key of ['label', 'summary', 'gate', 'stopWhen'] as const) { + if (candidate[key] !== undefined) { + requireString(candidate[key], `edges[${index}].${key}`, { max: 512 }); + } + } + for (const key of ['firings', 'maxFirings'] as const) { + if (candidate[key] !== undefined) { + requireInteger(candidate[key], `edges[${index}].${key}`, { min: 0 }); + } + } + if ( + (candidate.recent !== undefined && typeof candidate.recent !== 'boolean') || + (candidate.pending !== undefined && typeof candidate.pending !== 'boolean') + ) { + throw new TypeError(`edges[${index}] flags must be boolean`); + } + } + if (value.latestReport !== undefined) { + if (!isRecord(value.latestReport)) { + throw new TypeError('latestReport must be an object'); + } + if (value.latestReport.summary !== undefined) { + requireString(value.latestReport.summary, 'latestReport.summary', { max: 1024 }); + } + if (value.latestReport.issues !== undefined) { + if (!Array.isArray(value.latestReport.issues) || value.latestReport.issues.length > 32) { + throw new TypeError('latestReport.issues must be bounded'); + } + for (const [index, issue] of value.latestReport.issues.entries()) { + if (!isRecord(issue)) throw new TypeError(`latestReport.issues[${index}] must be an object`); + requireString(issue.message, `latestReport.issues[${index}].message`, { max: 1024 }); + } + } + } + if (value.laps !== undefined) { + if (!Array.isArray(value.laps) || value.laps.length > 6) { + throw new TypeError('laps must be a bounded array'); + } + for (const [index, lap] of value.laps.entries()) { + if (!isRecord(lap)) throw new TypeError(`laps[${index}] must be an object`); + requireInteger(lap.index, `laps[${index}].index`, { min: 0, max: 6 }); + if (!['running', 'issues', 'clean', 'capped', 'interrupted'].includes(String(lap.status))) { + throw new TypeError(`laps[${index}].status is invalid`); + } + if (!Array.isArray(lap.issues) || lap.issues.length > 32) { + throw new TypeError(`laps[${index}].issues must be bounded`); + } + } + } + return value as AgentGraphModel; +} + +const horizontalPositions: Record = { + root: { x: 30, y: 82 }, + implementer: { x: 370, y: 82 }, + reviewer: { x: 710, y: 82 }, +}; + +const verticalPositions: Record = { + root: { x: 130, y: 22 }, + implementer: { x: 130, y: 232 }, + reviewer: { x: 130, y: 442 }, +}; + +const wideHandles: Record< + AgentGraphEdgeKind, + { sourceHandle: string; targetHandle: string; route: AgentEdgeRoute } +> = { + governs: { + sourceHandle: 'right-out-upper', + targetHandle: 'left-in-upper', + route: 'direct', + }, + handoff: { + sourceHandle: 'right-out-upper', + targetHandle: 'left-in-upper', + route: 'direct', + }, + feedback: { + sourceHandle: 'left-out-lower', + targetHandle: 'right-in-lower', + route: 'lower', + }, + verdict: { + sourceHandle: 'bottom-out', + targetHandle: 'bottom-in', + route: 'lower', + }, +}; + +const narrowHandles: typeof wideHandles = { + governs: { + sourceHandle: 'bottom-out', + targetHandle: 'top-in', + route: 'direct', + }, + handoff: { + sourceHandle: 'bottom-out', + targetHandle: 'top-in', + route: 'direct', + }, + feedback: { + sourceHandle: 'right-out-lower', + targetHandle: 'right-in-lower', + route: 'right', + }, + verdict: { + sourceHandle: 'left-out-lower', + targetHandle: 'left-in-lower', + route: 'left', + }, +}; + +export function createAgentLoopFlowElements( + modelValue: AgentGraphModel, + orientation: AgentGraphOrientation, +): { + nodes: Node[]; + edges: Edge[]; +} { + const model = assertAgentGraphModel(modelValue); + const positions = + orientation === 'horizontal' ? horizontalPositions : verticalPositions; + const handles = orientation === 'horizontal' ? wideHandles : narrowHandles; + return { + nodes: model.nodes.map((node) => ({ + id: node.id, + type: 'agent-role', + position: positions[node.role], + draggable: false, + selectable: true, + data: { + node, + pending: model.pending?.role === node.role, + ...(model.latestReport?.fromRole === node.role + ? { latestReport: model.latestReport } + : {}), + }, + })), + edges: model.edges.map((edge) => ({ + id: edge.id, + type: 'agent-relationship', + source: edge.source, + target: edge.target, + sourceHandle: handles[edge.kind].sourceHandle, + targetHandle: handles[edge.kind].targetHandle, + animated: edge.active, + focusable: true, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 16, + height: 16, + }, + data: { + edge, + route: handles[edge.kind].route, + ...(model.latestReport && + ((edge.kind === 'feedback' && + model.latestReport.verdict === 'issues') || + (edge.kind === 'verdict' && + model.latestReport.verdict === 'clean')) + ? { latestReport: model.latestReport } + : {}), + }, + })), + }; +} + +export function orientationForWidth(width: number): AgentGraphOrientation { + return width < 720 ? 'vertical' : 'horizontal'; +} + +export function statusTone( + status: AgentGraphStatus, +): 'active' | 'success' | 'warning' | 'danger' | 'neutral' { + if (status === 'running') return 'active'; + if (status === 'succeeded') return 'success'; + if (['paused', 'interrupted', 'capped'].includes(status)) return 'warning'; + if (['failed', 'cancelled'].includes(status)) return 'danger'; + return 'neutral'; +} + +export function positionForHandle(handleId: string): Position { + if (handleId.startsWith('left')) return Position.Left; + if (handleId.startsWith('right')) return Position.Right; + if (handleId.startsWith('top')) return Position.Top; + return Position.Bottom; +} diff --git a/packages/agent-graph-ui/src/index.ts b/packages/agent-graph-ui/src/index.ts new file mode 100644 index 0000000..b888caf --- /dev/null +++ b/packages/agent-graph-ui/src/index.ts @@ -0,0 +1,44 @@ +import '@xyflow/react/dist/style.css'; +import './styles.css'; + +export { AgentLoopGraph, type AgentLoopGraphProps } from './agent-loop-graph'; +export { AgentRoleNode } from './agent-node'; +export { AgentRelationshipEdge } from './relationship-edge'; +export { LoopBadge } from './loop-badge'; +export { LoopPanel } from './loop-panel'; +export { RelationshipInspector } from './relationship-inspector'; +export { LoopStatus } from './loop-status'; +export { + agentGraphEdgeKinds, + agentGraphNodeStates, + agentGraphStatuses, + assertAgentGraphModel, + createAgentLoopFlowElements, + orientationForWidth, + statusTone, + type AgentEdgeData, + type AgentEdgeRoute, + type AgentGraphEdge, + type AgentGraphEdgeKind, + type AgentGraphFollowUpIntent, + type AgentGraphFollowUpKind, + type AgentGraphIssue, + type AgentGraphLap, + type AgentGraphModel, + type AgentGraphNode, + type AgentGraphNodeState, + type AgentGraphOrientation, + type AgentGraphPending, + type AgentGraphReport, + type AgentGraphRole, + type AgentGraphSelection, + type AgentGraphStatus, + type AgentGraphTimelineItem, + type AgentNodeData, + type LoopSummaryData, +} from './graph-model'; +export { + agentGraphScenarios, + desktopParityModel, + type AgentGraphScenario, +} from './fixtures'; diff --git a/packages/agent-graph-ui/src/inline-agent-loop-graph.ts b/packages/agent-graph-ui/src/inline-agent-loop-graph.ts new file mode 100644 index 0000000..1fd9274 --- /dev/null +++ b/packages/agent-graph-ui/src/inline-agent-loop-graph.ts @@ -0,0 +1,466 @@ +import type { + AgentGraphFollowUpIntent, + AgentGraphModel, + AgentGraphSelection, +} from './graph-model'; + +export type InlineAgentLoopGraphOptions = { + model: AgentGraphModel; + mode?: 'snapshot' | 'live'; + liveState?: 'connecting' | 'live' | 'retrying'; + driftNote?: string; + followUpAvailable?: boolean; + onFollowUp?: (intent: AgentGraphFollowUpIntent) => void | Promise; +}; + +export type InlineAgentLoopGraphController = { + update: ( + model: AgentGraphModel, + options?: Pick, + ) => void; + integrityError: (code: string) => void; + destroy: () => void; +}; + +type Point = { + x: number; + y: number; +}; + +const nodePositions: Record = { + root: { x: 55, y: 92 }, + implementer: { x: 375, y: 92 }, + reviewer: { x: 695, y: 92 }, +}; + +const edgeGeometry: Record< + AgentGraphModel['edges'][number]['kind'], + { path: string; label: Point } +> = { + governs: { + path: 'M 325 158 C 342 158 358 158 375 158', + label: { x: 350, y: 133 }, + }, + handoff: { + path: 'M 645 158 C 662 158 678 158 695 158', + label: { x: 670, y: 133 }, + }, + feedback: { + path: 'M 695 213 C 670 292 665 292 645 213', + label: { x: 670, y: 284 }, + }, + verdict: { + path: 'M 695 226 C 645 342 375 342 325 226', + label: { x: 510, y: 331 }, + }, +}; + +function escapeHtml(value: unknown) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function compactId(value: string) { + return value.length > 28 + ? `${value.slice(0, 12)}…${value.slice(-9)}` + : value; +} + +function statusTone(status: AgentGraphModel['status']) { + if (status === 'running') return 'active'; + if (status === 'succeeded') return 'success'; + if (['paused', 'interrupted', 'capped'].includes(status)) return 'warning'; + if (['failed', 'cancelled'].includes(status)) return 'danger'; + return 'neutral'; +} + +function nodeDescription( + node: AgentGraphModel['nodes'][number], + model: AgentGraphModel, +) { + if (model.latestReport?.fromRole === node.role) { + if (model.latestReport.verdict === 'clean') return 'Clean verdict'; + if (model.latestReport.verdict === 'issues') { + return `${model.latestReport.issueCount} issue${ + model.latestReport.issueCount === 1 ? '' : 's' + }`; + } + return 'Worker report received'; + } + if (node.role === 'root') return 'Owns bounded continuation decisions'; + if (node.state === 'unbound') return 'Waiting for native worker binding'; + return 'Bound to the task-scoped worker role'; +} + +function renderNode( + node: AgentGraphModel['nodes'][number], + index: number, + model: AgentGraphModel, + selection?: AgentGraphSelection, +) { + const { x, y } = nodePositions[node.role]; + const selected = selection?.kind === 'node' && selection.id === node.id; + const pending = model.pending?.role === node.role; + const glyph = + node.role === 'root' ? 'G' : node.role === 'implementer' ? 'I' : 'R'; + const subtitle = node.role === 'root' ? 'Governor' : 'Native worker role'; + return ` + + + ${glyph} + ${escapeHtml(node.label)} + ${subtitle} + + ${escapeHtml( + pending ? 'pending' : node.state, + )} + + ${escapeHtml( + nodeDescription(node, model), + )} + + ${escapeHtml( + compactId(node.id), + )} + ${escapeHtml( + node.nativeAgentId ? compactId(node.nativeAgentId) : 'not bound', + )} + `; +} + +function renderEdge( + edge: AgentGraphModel['edges'][number], + index: number, + selection?: AgentGraphSelection, +) { + const geometry = edgeGeometry[edge.kind]; + const selected = selection?.kind === 'edge' && selection.id === edge.id; + const label = `${edge.kind} · ${edge.reportCount}`; + const labelWidth = Math.max(78, 21 + label.length * 6.2); + const labelX = geometry.label.x - labelWidth / 2; + return ` + + + + ${escapeHtml(label)} + `; +} + +function selectedCopy(model: AgentGraphModel, selection?: AgentGraphSelection) { + const node = + selection?.kind === 'node' + ? model.nodes.find((candidate) => candidate.id === selection.id) + : undefined; + const edge = + selection?.kind === 'edge' + ? model.edges.find((candidate) => candidate.id === selection.id) + : undefined; + if (node) { + return { + eyebrow: 'Selected node', + title: node.label, + detail: `${node.role} · ${node.state}${ + node.nativeAgentId ? ' · native worker bound' : '' + }`, + }; + } + if (edge) { + return { + eyebrow: 'Selected relationship', + title: `${edge.source} → ${edge.target}`, + detail: `${edge.kind} · ${edge.reportCount} reports${ + edge.active ? ' · active' : '' + }`, + }; + } + return { + eyebrow: 'Verified snapshot', + title: 'Select a node or relationship', + detail: `revision ${model.identity.revision} · digest ${model.identity.projectionDigest.slice( + 0, + 12, + )}`, + }; +} + +function renderTimeline(model: AgentGraphModel) { + const items = + model.timeline.length > 0 + ? model.timeline + .map( + (item) => + `
  • ${escapeHtml(item.label)}${ + item.role ? `${escapeHtml(item.role)}` : '' + }
  • `, + ) + .join('') + : '
  • No authoritative timeline entries.
  • '; + return `
    + Bounded timeline ${model.timeline.length} +
      ${items}
    +
    `; +} + +function renderAlert( + model: AgentGraphModel, + driftNote: string, + integrityError: string, +) { + if (integrityError) { + return `

    Snapshot unavailable: ${escapeHtml( + integrityError, + )}

    `; + } + if (driftNote) { + return `

    ${escapeHtml( + driftNote, + )}

    `; + } + if (model.recovery) { + return `

    Recovery: ${escapeHtml( + model.recovery.reason, + )}

    `; + } + if (model.cancelRequested) { + return '

    Cancellation requested.

    '; + } + if (model.needsHuman) { + return '

    Human attention is required.

    '; + } + return ''; +} + +function renderGraph( + model: AgentGraphModel, + options: InlineAgentLoopGraphOptions, + selection: AgentGraphSelection | undefined, + integrityError: string, + followUpStatus: string, +) { + const selected = selectedCopy(model, selection); + const terminal = ['succeeded', 'capped', 'cancelled', 'failed'].includes( + model.status, + ); + const control = + model.status === 'paused' || model.status === 'interrupted' + ? 'resume' + : 'pause'; + const mode = options.mode ?? 'snapshot'; + const liveLabel = + mode === 'live' ? (options.liveState ?? 'connecting') : 'snapshot'; + return `
    +
    +

    looperators

    Agent Loop

    +
    + verified + ${escapeHtml(model.status)} + lap ${model.currentLap}/${ + model.lapCap + } + lease ${model.lease.consumed}/${ + model.lease.granted + } + ${escapeHtml(liveLabel)} +
    +
    + ${renderAlert( + model, + options.driftNote ?? '', + integrityError, + )} +
    + + + + + + + + + + + ${model.edges + .map((edge, index) => renderEdge(edge, index, selection)) + .join('')} + ${model.nodes + .map((node, index) => renderNode(node, index, model, selection)) + .join('')} + +
    +
    +
    +

    ${escapeHtml(selected.eyebrow)}

    +

    ${escapeHtml(selected.title)}

    +

    ${escapeHtml(selected.detail)}

    +
    +
    + + +
    + ${ + followUpStatus + ? `

    ${escapeHtml( + followUpStatus, + )}

    ` + : '' + } +
    + ${renderTimeline(model)} +
    run ${escapeHtml( + compactId(model.identity.runId), + )} · revision ${model.identity.revision} · SVG runtime
    +
    `; +} + +export function mountInlineAgentLoopGraph( + root: HTMLElement, + initialOptions: InlineAgentLoopGraphOptions, +): InlineAgentLoopGraphController { + let model = initialOptions.model; + let options = { ...initialOptions }; + let selection: AgentGraphSelection | undefined; + let integrityError = ''; + let followUpStatus = ''; + + const render = () => { + root.innerHTML = renderGraph( + model, + options, + selection, + integrityError, + followUpStatus, + ); + }; + + const activateSelection = (element: Element) => { + const nodeElement = element.closest('[data-node-index]'); + const edgeElement = element.closest('[data-edge-index]'); + if (nodeElement) { + const node = model.nodes[Number(nodeElement.dataset.nodeIndex)]; + if (node) selection = { kind: 'node', id: node.id }; + } else if (edgeElement) { + const edge = model.edges[Number(edgeElement.dataset.edgeIndex)]; + if (edge) selection = { kind: 'edge', id: edge.id }; + } else { + selection = undefined; + } + render(); + }; + + const requestFollowUp = async ( + kind: AgentGraphFollowUpIntent['kind'], + ) => { + if (!options.followUpAvailable || !options.onFollowUp) { + followUpStatus = 'Host follow-up is unavailable.'; + render(); + return; + } + followUpStatus = 'Requesting follow-up…'; + render(); + try { + await options.onFollowUp({ + kind, + runId: model.identity.runId, + ...(selection ? { selection } : {}), + }); + followUpStatus = 'Follow-up requested.'; + } catch { + followUpStatus = 'Follow-up cancelled or failed.'; + } + render(); + }; + + const clickHandler = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + const action = target.closest('button[data-action]'); + if (action?.dataset.action) { + void requestFollowUp( + action.dataset.action as AgentGraphFollowUpIntent['kind'], + ); + return; + } + if (target.closest('[data-node-index], [data-edge-index]')) { + activateSelection(target); + } + }; + + const keyHandler = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const target = event.target; + if ( + target instanceof Element && + target.closest('[data-node-index], [data-edge-index]') + ) { + event.preventDefault(); + activateSelection(target); + } + }; + + root.addEventListener('click', clickHandler); + root.addEventListener('keydown', keyHandler); + render(); + + return Object.freeze({ + update( + nextModel: AgentGraphModel, + nextOptions: Pick = {}, + ) { + model = nextModel; + options = { ...options, ...nextOptions }; + integrityError = ''; + if ( + selection?.kind === 'node' && + !model.nodes.some((node) => node.id === selection?.id) + ) { + selection = undefined; + } + if ( + selection?.kind === 'edge' && + !model.edges.some((edge) => edge.id === selection?.id) + ) { + selection = undefined; + } + render(); + }, + integrityError(code: string) { + integrityError = String(code).slice(0, 80); + render(); + }, + destroy() { + root.removeEventListener('click', clickHandler); + root.removeEventListener('keydown', keyHandler); + root.replaceChildren(); + }, + }); +} diff --git a/packages/agent-graph-ui/src/inline.css b/packages/agent-graph-ui/src/inline.css new file mode 100644 index 0000000..ff84e88 --- /dev/null +++ b/packages/agent-graph-ui/src/inline.css @@ -0,0 +1,407 @@ +.looperators-inline-agent-graph, +.looperators-inline-agent-graph * { + box-sizing: border-box; +} + +.looperators-inline-agent-graph { + --lagi-background: var(--background, #f8fafc); + --lagi-card: var(--card, #ffffff); + --lagi-muted-card: var(--muted, #f1f3f5); + --lagi-foreground: var(--foreground, #171a1f); + --lagi-muted: var(--muted-foreground, #606772); + --lagi-border: var(--border, rgba(23, 26, 31, 0.16)); + --lagi-accent: var(--term-accent-hi, #65a30d); + --lagi-governor: var(--term-amber, #b45309); + --lagi-worker: var(--term-cyan, #0e7490); + --lagi-success: var(--term-emerald, #047857); + --lagi-danger: var(--term-rose, #e11d48); + display: grid; + gap: 12px; + width: 100%; + min-width: 0; + color: var(--lagi-foreground); + font: 14px/1.45 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +.lag-inline-header, +.lag-inline-badges, +.lag-inline-actions { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; +} + +.lag-inline-header { + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.lag-inline-header h2, +.lag-inline-inspector h3 { + margin: 0; + font-size: 16px; + letter-spacing: -0.01em; +} + +.lag-inline-eyebrow { + margin: 0 0 2px; + color: var(--lagi-muted); + font: 10px/1.2 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.lag-inline-badges { + justify-content: flex-end; +} + +.lag-inline-badge { + display: inline-flex; + align-items: center; + min-height: 24px; + border: 1px solid var(--lagi-border); + border-radius: 999px; + background: color-mix(in srgb, var(--lagi-card) 88%, transparent); + padding: 2px 8px; + color: var(--lagi-muted); + font: 10px/1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; + white-space: nowrap; +} + +.lag-inline-badge[data-tone="active"] { + border-color: color-mix(in srgb, var(--lagi-accent) 50%, var(--lagi-border)); + color: var(--lagi-accent); +} + +.lag-inline-badge[data-tone="success"], +.lag-inline-verified { + border-color: color-mix(in srgb, var(--lagi-success) 45%, var(--lagi-border)); + color: var(--lagi-success); +} + +.lag-inline-badge[data-tone="warning"] { + border-color: color-mix(in srgb, var(--lagi-governor) 50%, var(--lagi-border)); + color: var(--lagi-governor); +} + +.lag-inline-badge[data-tone="danger"] { + border-color: color-mix(in srgb, var(--lagi-danger) 45%, var(--lagi-border)); + color: var(--lagi-danger); +} + +.lag-inline-alert { + margin: 0; + border: 1px solid + color-mix(in srgb, var(--lagi-governor) 45%, var(--lagi-border)); + border-radius: 10px; + background: color-mix(in srgb, var(--lagi-governor) 8%, var(--lagi-card)); + padding: 8px 10px; + color: var(--lagi-governor); + font-size: 12px; +} + +.lag-inline-error { + border-color: color-mix(in srgb, var(--lagi-danger) 45%, var(--lagi-border)); + color: var(--lagi-danger); +} + +.lag-inline-stage { + width: 100%; + overflow: hidden; + border: 1px solid var(--lagi-border); + border-radius: 14px; + background: var(--lagi-background); +} + +.lag-inline-stage svg { + display: block; + width: 100%; + min-width: 620px; + height: auto; + min-height: 250px; + max-height: 420px; +} + +.lag-inline-grid { + fill: url("#lag-grid"); +} + +.lag-inline-grid-dot { + fill: color-mix(in srgb, var(--lagi-muted) 30%, transparent); +} + +.lag-inline-node, +.lag-inline-edge { + cursor: pointer; + outline: none; +} + +.lag-inline-node-card { + fill: color-mix(in srgb, var(--lagi-card) 96%, transparent); + stroke: var(--lagi-border); + filter: drop-shadow(0 9px 12px rgba(0, 0, 0, 0.08)); +} + +.lag-inline-node[data-role="root"] .lag-inline-node-card { + stroke: color-mix(in srgb, var(--lagi-governor) 55%, var(--lagi-border)); +} + +.lag-inline-node[data-role="implementer"] .lag-inline-node-card, +.lag-inline-node[data-role="reviewer"] .lag-inline-node-card { + stroke: color-mix(in srgb, var(--lagi-worker) 48%, var(--lagi-border)); +} + +.lag-inline-node[data-state="terminal"] { + opacity: 0.72; +} + +.lag-inline-node[data-selected="true"] .lag-inline-node-card, +.lag-inline-node:focus-visible .lag-inline-node-card { + stroke: var(--lagi-accent); + stroke-width: 3; +} + +.lag-inline-glyph-box, +.lag-inline-description-box, +.lag-inline-state-box { + fill: color-mix(in srgb, var(--lagi-muted-card) 76%, transparent); + stroke: var(--lagi-border); +} + +.lag-inline-glyph, +.lag-inline-node-title, +.lag-inline-state, +.lag-inline-node-subtitle, +.lag-inline-description, +.lag-inline-node-id, +.lag-inline-node-binding, +.lag-inline-edge-label { + dominant-baseline: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.lag-inline-glyph { + fill: var(--lagi-worker); + font-size: 13px; + font-weight: 700; +} + +.lag-inline-node[data-role="root"] .lag-inline-glyph { + fill: var(--lagi-governor); +} + +.lag-inline-node-title { + fill: var(--lagi-foreground); + font-size: 12px; + font-weight: 700; +} + +.lag-inline-node-subtitle, +.lag-inline-state, +.lag-inline-description, +.lag-inline-node-id, +.lag-inline-node-binding, +.lag-inline-edge-label { + fill: var(--lagi-muted); +} + +.lag-inline-node-subtitle, +.lag-inline-state { + font-size: 9px; +} + +.lag-inline-description { + font-size: 10px; +} + +.lag-inline-node-id, +.lag-inline-node-binding, +.lag-inline-edge-label { + font-size: 8.5px; +} + +.lag-inline-node-separator { + stroke: var(--lagi-border); +} + +.lag-inline-edge-path { + fill: none; + stroke: var(--lagi-muted); + stroke-width: 2; +} + +.lag-inline-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 18; +} + +.lag-inline-edge[data-kind="governs"] .lag-inline-edge-path { + stroke: var(--lagi-governor); +} + +.lag-inline-edge[data-kind="handoff"] .lag-inline-edge-path { + stroke: var(--lagi-success); +} + +.lag-inline-edge[data-kind="feedback"] .lag-inline-edge-path { + stroke: var(--lagi-governor); + stroke-dasharray: 7 5; +} + +.lag-inline-edge[data-kind="verdict"] .lag-inline-edge-path { + stroke: var(--lagi-worker); + stroke-dasharray: 3 4; +} + +.lag-inline-edge[data-active="false"] { + opacity: 0.62; +} + +.lag-inline-edge[data-selected="true"] .lag-inline-edge-path, +.lag-inline-edge:focus-visible .lag-inline-edge-path { + stroke: var(--lagi-accent); + stroke-width: 4; +} + +.lag-inline-arrow { + fill: var(--lagi-muted); +} + +.lag-inline-edge-label-box { + fill: color-mix(in srgb, var(--lagi-card) 94%, transparent); + stroke: var(--lagi-border); +} + +.lag-inline-edge[data-active="true"] .lag-inline-edge-label-box { + stroke: var(--lagi-accent); +} + +.lag-inline-inspector { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + align-items: center; + gap: 12px 18px; + border: 1px solid var(--lagi-border); + border-radius: 12px; + background: color-mix(in srgb, var(--lagi-card) 92%, transparent); + padding: 11px 12px; +} + +.lag-inline-detail, +.lag-inline-follow-up { + margin: 4px 0 0; + color: var(--lagi-muted); + font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; +} + +.lag-inline-actions { + justify-content: flex-end; +} + +.lag-inline-actions button { + min-height: 31px; + border: 1px solid var(--lagi-border); + border-radius: 9px; + background: var(--lagi-card); + padding: 5px 9px; + color: var(--lagi-foreground); + font: 10px/1 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + cursor: pointer; +} + +.lag-inline-actions button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.lag-inline-actions button:focus-visible, +.lag-inline-timeline summary:focus-visible { + outline: 2px solid var(--lagi-accent); + outline-offset: 2px; +} + +.lag-inline-follow-up { + grid-column: 1 / -1; +} + +.lag-inline-timeline { + border: 1px solid var(--lagi-border); + border-radius: 12px; + background: color-mix(in srgb, var(--lagi-card) 90%, transparent); +} + +.lag-inline-timeline summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + color: var(--lagi-foreground); + font: 11px/1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; + cursor: pointer; +} + +.lag-inline-timeline ol { + display: grid; + gap: 6px; + margin: 0; + padding: 0 12px 12px 32px; +} + +.lag-inline-timeline li, +.lag-inline-footer { + color: var(--lagi-muted); + font-size: 10px; +} + +.lag-inline-timeline li time { + margin-right: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.lag-inline-timeline li small { + margin-left: 6px; + color: var(--lagi-worker); +} + +.lag-inline-footer { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +@media (max-width: 719px) { + .lag-inline-stage { + overflow-x: auto; + } + + .lag-inline-inspector { + grid-template-columns: 1fr; + } + + .lag-inline-actions { + justify-content: flex-start; + } +} + +@media (prefers-color-scheme: dark) { + .looperators-inline-agent-graph { + --lagi-background: var(--background, #07090a); + --lagi-card: var(--card, #0d120e); + --lagi-muted-card: var(--muted, #0f1511); + --lagi-foreground: var(--foreground, #e5e7eb); + --lagi-muted: var(--muted-foreground, #9ca3af); + --lagi-border: var(--border, rgba(255, 255, 255, 0.12)); + --lagi-accent: var(--term-accent-hi, #a3e635); + --lagi-governor: var(--term-amber, #fcd34d); + --lagi-worker: var(--term-cyan, #67e8f9); + --lagi-success: var(--term-emerald, #6ee7b7); + --lagi-danger: var(--term-rose, #fb7185); + } +} diff --git a/packages/agent-graph-ui/src/inline.ts b/packages/agent-graph-ui/src/inline.ts new file mode 100644 index 0000000..b9dafe6 --- /dev/null +++ b/packages/agent-graph-ui/src/inline.ts @@ -0,0 +1,12 @@ +import './inline.css'; + +export { + mountInlineAgentLoopGraph, + type InlineAgentLoopGraphController, + type InlineAgentLoopGraphOptions, +} from './inline-agent-loop-graph'; +export type { + AgentGraphFollowUpIntent, + AgentGraphModel, + AgentGraphSelection, +} from './graph-model'; diff --git a/packages/agent-graph-ui/src/loop-badge.tsx b/packages/agent-graph-ui/src/loop-badge.tsx new file mode 100644 index 0000000..4fd15c9 --- /dev/null +++ b/packages/agent-graph-ui/src/loop-badge.tsx @@ -0,0 +1,27 @@ +import type { AgentGraphModel } from './graph-model'; +import { statusTone } from './graph-model'; + +export function LoopBadge({ + model, + onOpen, +}: { + model: AgentGraphModel; + onOpen: () => void; +}) { + return ( + + ); +} diff --git a/packages/agent-graph-ui/src/loop-panel.tsx b/packages/agent-graph-ui/src/loop-panel.tsx new file mode 100644 index 0000000..16969b1 --- /dev/null +++ b/packages/agent-graph-ui/src/loop-panel.tsx @@ -0,0 +1,123 @@ +import { useState } from 'react'; +import type { AgentGraphFollowUpIntent, AgentGraphIssue, AgentGraphModel } from './graph-model'; +import { statusTone } from './graph-model'; + +function IssueList({ issues }: { issues: AgentGraphIssue[] }) { + return ( +
      + {issues.map((issue, index) => ( +
    • + {issue.severity ?? 'issue'} + {issue.message} + {issue.file ? {issue.file}{issue.line ? `:${issue.line}` : ''} : null} +
    • + ))} +
    + ); +} + +export function LoopPanel({ + model, + onClose, + controlAvailable = false, + onControl, + confirming, + onConfirmingChange, +}: { + model: AgentGraphModel; + onClose: () => void; + controlAvailable?: boolean; + onControl?: (intent: AgentGraphFollowUpIntent) => void | Promise; + confirming?: boolean; + onConfirmingChange?: (confirming: boolean) => void; +}) { + const [internalConfirming, setInternalConfirming] = useState(false); + const [pending, setPending] = useState(false); + const isConfirming = confirming ?? internalConfirming; + const setConfirming = onConfirmingChange ?? setInternalConfirming; + const terminal = ['succeeded', 'capped', 'cancelled', 'failed'].includes(model.status); + const issues = model.latestReport?.issues ?? []; + + const cancel = async () => { + if (!onControl || pending) return; + setPending(true); + try { + await onControl({ kind: 'cancel', runId: model.identity.runId }); + setConfirming(false); + } finally { + setPending(false); + } + }; + + return ( + + ); +} diff --git a/packages/agent-graph-ui/src/loop-status.tsx b/packages/agent-graph-ui/src/loop-status.tsx new file mode 100644 index 0000000..d3c9c52 --- /dev/null +++ b/packages/agent-graph-ui/src/loop-status.tsx @@ -0,0 +1,35 @@ +import { statusTone, type AgentGraphModel } from './graph-model'; + +export function LoopStatus({ + model, + mode, + liveState, +}: { + model: AgentGraphModel; + mode: 'snapshot' | 'live'; + liveState?: 'connecting' | 'live' | 'retrying'; +}) { + return ( +
    +
    +

    looperators

    +

    Agent Loop

    +
    +
    + verified + + {model.status} + + + lap {model.currentLap}/{model.lapCap} + + + lease {model.lease.consumed}/{model.lease.granted} + + + {mode === 'live' ? (liveState ?? 'connecting') : 'snapshot'} + +
    +
    + ); +} diff --git a/packages/agent-graph-ui/src/relationship-edge.tsx b/packages/agent-graph-ui/src/relationship-edge.tsx new file mode 100644 index 0000000..91f6c3e --- /dev/null +++ b/packages/agent-graph-ui/src/relationship-edge.tsx @@ -0,0 +1,160 @@ +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + type Edge, + type EdgeProps, +} from '@xyflow/react'; +import type { AgentEdgeData } from './graph-model'; + +const edgeLabels = { + governs: 'governs', + handoff: 'handoff', + feedback: 'feedback', + verdict: 'verdict', +} as const; + +function routedPath({ + route, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: { + route: AgentEdgeData['route']; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: EdgeProps['sourcePosition']; + targetPosition: EdgeProps['targetPosition']; +}) { + if (route === 'direct') { + const [path, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + curvature: 0.32, + }); + return { path, labelX, labelY }; + } + if (route === 'lower') { + const depth = Math.max(sourceY, targetY) + 88; + return { + path: `M ${sourceX} ${sourceY} C ${sourceX} ${depth}, ${targetX} ${depth}, ${targetX} ${targetY}`, + labelX: (sourceX + targetX) / 2, + labelY: depth - 8, + }; + } + const direction = route === 'right' ? 1 : -1; + const depth = + (route === 'right' + ? Math.max(sourceX, targetX) + : Math.min(sourceX, targetX)) + + direction * 96; + return { + path: `M ${sourceX} ${sourceY} C ${depth} ${sourceY}, ${depth} ${targetY}, ${targetX} ${targetY}`, + labelX: + (route === 'right' + ? Math.max(sourceX, targetX) + : Math.min(sourceX, targetX)) - + direction * 28, + labelY: (sourceY + targetY) / 2, + }; +} + +export function AgentRelationshipEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + selected, + data, +}: EdgeProps>) { + const edgeData = data as AgentEdgeData; + const { edge, route, latestReport } = edgeData; + const { path, labelX, labelY } = routedPath({ + route, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + const reportLabel = + latestReport?.verdict === 'issues' + ? `${latestReport.issueCount} issues` + : latestReport?.verdict === 'clean' + ? 'clean' + : undefined; + const displayLabel = edge.label ?? edgeLabels[edge.kind]; + const tooltip = [ + displayLabel, + edge.summary, + edge.gate ? `gate ${edge.gate}` : undefined, + edge.stopWhen, + edge.pending ? 'pending' : undefined, + ].filter(Boolean).join('\n'); + return ( + <> + + +
    { + event.stopPropagation(); + edgeData.onSelect?.(id); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + event.stopPropagation(); + edgeData.onSelect?.(id); + }} + > + {displayLabel} + {edge.pending ? pending : null} + {reportLabel ? {reportLabel} : null} + {edge.reportCount > 0 ? {edge.reportCount} : null} + {edge.maxFirings !== undefined ? {edge.firings ?? 0}/{edge.maxFirings} : null} +
    +
    + + ); +} diff --git a/packages/agent-graph-ui/src/relationship-inspector.tsx b/packages/agent-graph-ui/src/relationship-inspector.tsx new file mode 100644 index 0000000..0237d45 --- /dev/null +++ b/packages/agent-graph-ui/src/relationship-inspector.tsx @@ -0,0 +1,53 @@ +import type { AgentGraphEdge, AgentGraphNode } from './graph-model'; + +function nodeLabel(nodes: AgentGraphNode[], id: string) { + return nodes.find((node) => node.id === id)?.label ?? id; +} + +export function RelationshipInspector({ + edge, + nodes, + onClose, + onRequestStop, + stopAvailable = false, +}: { + edge: AgentGraphEdge; + nodes: AgentGraphNode[]; + onClose: () => void; + onRequestStop?: () => void; + stopAvailable?: boolean; +}) { + return ( + + ); +} diff --git a/packages/agent-graph-ui/src/styles.css b/packages/agent-graph-ui/src/styles.css new file mode 100644 index 0000000..88fb150 --- /dev/null +++ b/packages/agent-graph-ui/src/styles.css @@ -0,0 +1,813 @@ +.looperators-agent-graph, +.looperators-agent-graph * { + box-sizing: border-box; +} + +.looperators-agent-graph { + --lag-background: var(--background, #f8fafc); + --lag-card: var(--card, #ffffff); + --lag-card-muted: var(--muted, #f1f3f5); + --lag-foreground: var(--foreground, #171a1f); + --lag-muted: var(--muted-foreground, #606772); + --lag-border: var(--border, rgba(23, 26, 31, 0.14)); + --lag-accent: var(--term-accent-hi, #65a30d); + --lag-governor: var(--term-amber, #b45309); + --lag-worker: var(--term-cyan, #0e7490); + --lag-success: var(--term-emerald, #047857); + --lag-danger: var(--term-rose, #e11d48); + --lag-font: + ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --lag-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + display: grid; + gap: 12px; + width: 100%; + min-width: 0; + color: var(--lag-foreground); + font: 14px/1.45 var(--lag-font); +} + +.lag-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.lag-header h2, +.lag-inspector h3 { + margin: 0; + font-size: 16px; + letter-spacing: -0.01em; +} + +.lag-eyebrow { + margin: 0 0 2px; + color: var(--lag-muted); + font: 10px/1.2 var(--lag-mono); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.lag-badges { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; +} + +.lag-badge, +.lag-state-pill { + display: inline-flex; + align-items: center; + min-height: 24px; + border: 1px solid var(--lag-border); + border-radius: 999px; + background: color-mix(in srgb, var(--lag-card) 88%, transparent); + padding: 2px 8px; + color: var(--lag-muted); + font: 10px/1 var(--lag-mono); + white-space: nowrap; +} + +.lag-badge[data-tone="active"] { + border-color: color-mix(in srgb, var(--lag-accent) 50%, var(--lag-border)); + color: var(--lag-accent); +} + +.lag-badge[data-tone="success"], +.lag-verified { + border-color: color-mix(in srgb, var(--lag-success) 45%, var(--lag-border)); + color: var(--lag-success); +} + +.lag-badge[data-tone="warning"] { + border-color: color-mix(in srgb, var(--lag-governor) 50%, var(--lag-border)); + color: var(--lag-governor); +} + +.lag-badge[data-tone="danger"] { + border-color: color-mix(in srgb, var(--lag-danger) 45%, var(--lag-border)); + color: var(--lag-danger); +} + +.lag-alert, +.lag-embed-error { + margin: 0; + border: 1px solid color-mix(in srgb, var(--lag-governor) 45%, var(--lag-border)); + border-radius: 10px; + background: color-mix(in srgb, var(--lag-governor) 8%, var(--lag-card)); + padding: 8px 10px; + color: var(--lag-governor); + font-size: 12px; +} + +.lag-embed-error { + margin-bottom: 10px; + border-color: color-mix(in srgb, var(--lag-danger) 45%, var(--lag-border)); + color: var(--lag-danger); +} + +.lag-flow { + width: 100%; + height: 390px; + min-height: 320px; + overflow: hidden; + border: 1px solid var(--lag-border); + border-radius: 14px; + background: + radial-gradient( + circle at 15% -10%, + color-mix(in srgb, var(--lag-accent) 10%, transparent), + transparent 42% + ), + var(--lag-background); +} + +.lag-workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + min-width: 0; +} + +.lag-workspace[data-panel-open="true"] { + grid-template-columns: minmax(520px, 1fr) 360px; +} + +.lag-canvas-stack { + display: grid; + min-width: 0; + gap: 8px; +} + +.lag-loop-badge { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + width: min(360px, 100%); + border: 1px solid var(--lag-border); + border-radius: 11px; + background: color-mix(in srgb, var(--lag-card) 94%, transparent); + padding: 8px 10px; + color: var(--lag-foreground); + font: 10px/1.25 var(--lag-mono); + text-align: left; + cursor: pointer; + box-shadow: 0 4px 14px color-mix(in srgb, #000 7%, transparent); +} + +.lag-loop-badge:hover, +.lag-loop-badge:focus-visible { + border-color: color-mix(in srgb, var(--lag-accent) 55%, var(--lag-border)); +} + +.lag-loop-badge[data-tone="active"] { color: var(--lag-accent); } +.lag-loop-badge[data-tone="success"] { color: var(--lag-success); } +.lag-loop-badge[data-tone="warning"] { color: var(--lag-governor); } +.lag-loop-badge[data-tone="danger"] { color: var(--lag-danger); } + +.lag-loop-badge-icon { + font-size: 17px; +} + +.lag-loop-badge-copy { + display: grid; + min-width: 0; +} + +.lag-loop-badge-copy strong, +.lag-loop-badge-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lag-loop-badge-copy small { + margin-top: 2px; + color: var(--lag-muted); + font-family: var(--lag-font); +} + +.lag-loop-badge-lap { + border: 1px solid currentColor; + border-radius: 999px; + padding: 3px 7px; + font-weight: 700; +} + +.lag-flow[data-orientation="vertical"] { + height: 720px; +} + +.lag-flow .react-flow__background { + color: color-mix(in srgb, var(--lag-muted) 32%, transparent); +} + +.lag-flow .react-flow__controls { + overflow: hidden; + border: 1px solid var(--lag-border); + border-radius: 10px; + box-shadow: none; +} + +.lag-flow .react-flow__controls-button { + border-bottom-color: var(--lag-border); + background: color-mix(in srgb, var(--lag-card) 94%, transparent); + color: var(--lag-foreground); +} + +.lag-flow .react-flow__controls-button:hover { + background: var(--lag-card-muted); +} + +.lag-flow .react-flow__controls-button svg { + fill: currentColor; +} + +.lag-node { + position: relative; + display: grid; + width: 276px; + min-height: 154px; + overflow: visible; + border: 1px solid var(--lag-border); + border-radius: 14px; + background: color-mix(in srgb, var(--lag-card) 96%, transparent); + box-shadow: 0 10px 24px color-mix(in srgb, #000 7%, transparent); + font-family: var(--lag-mono); + transition: + border-color 140ms ease, + box-shadow 140ms ease, + opacity 140ms ease; +} + +.lag-node[data-role="root"] { + border-color: color-mix(in srgb, var(--lag-governor) 48%, var(--lag-border)); +} + +.lag-node[data-role="implementer"], +.lag-node[data-role="reviewer"] { + border-color: color-mix(in srgb, var(--lag-worker) 42%, var(--lag-border)); +} + +.lag-node[data-state="active"] { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--lag-accent) 50%, transparent), + 0 12px 32px color-mix(in srgb, var(--lag-accent) 14%, transparent); +} + +.lag-node[data-state="terminal"] { + opacity: 0.72; +} + +.lag-node[data-selected="true"] { + border-color: var(--lag-accent); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--lag-accent) 32%, transparent), + 0 12px 32px color-mix(in srgb, var(--lag-accent) 14%, transparent); +} + +.lag-node-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 12px 9px; +} + +.lag-node-glyph { + display: grid; + width: 34px; + height: 34px; + place-items: center; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, currentColor 28%, var(--lag-border)); + border-radius: 10px; + background: color-mix(in srgb, currentColor 8%, var(--lag-card)); + color: var(--lag-worker); + font-size: 13px; + font-weight: 700; +} + +.lag-node[data-role="root"] .lag-node-glyph { + color: var(--lag-governor); +} + +.lag-node-heading { + display: grid; + min-width: 0; + flex: 1; +} + +.lag-node-heading strong { + overflow: hidden; + color: var(--lag-foreground); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lag-node-heading small { + overflow: hidden; + margin-top: 2px; + color: var(--lag-muted); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lag-state-pill { + min-height: 20px; + padding: 2px 7px; + font-size: 9px; +} + +.lag-node-body { + min-height: 60px; + padding: 0 12px 10px; +} + +.lag-node-description { + margin: 0; + border: 1px solid var(--lag-border); + border-radius: 9px; + background: color-mix(in srgb, var(--lag-card-muted) 70%, transparent); + padding: 8px; + color: var(--lag-muted); + font: 10px/1.45 var(--lag-font); +} + +.lag-verdict { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid var(--lag-border); + border-radius: 9px; + background: color-mix(in srgb, var(--lag-card-muted) 70%, transparent); + padding: 8px; + color: var(--lag-muted); + font-size: 10px; +} + +.lag-verdict[data-verdict="issues"] { + border-color: color-mix(in srgb, var(--lag-governor) 40%, var(--lag-border)); + color: var(--lag-governor); +} + +.lag-verdict[data-verdict="clean"] { + border-color: color-mix(in srgb, var(--lag-success) 40%, var(--lag-border)); + color: var(--lag-success); +} + +.lag-node-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border-top: 1px solid var(--lag-border); + padding: 7px 12px; + color: var(--lag-muted); + font-size: 8.5px; +} + +.lag-node-footer span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lag-node-activity { + display: inline-flex; + gap: 4px; +} + +.lag-report-summary { + overflow: hidden; + margin: 6px 0 0; + color: var(--lag-muted); + font: 10px/1.4 var(--lag-font); + text-overflow: ellipsis; + white-space: nowrap; +} + +.lag-handle { + width: 8px !important; + height: 8px !important; + border: 2px solid var(--lag-card) !important; + background: var(--lag-accent) !important; + opacity: 0.86; +} + +.lag-handle-upper:is(.react-flow__handle-left, .react-flow__handle-right) { + top: 38% !important; +} + +.lag-handle-lower:is(.react-flow__handle-left, .react-flow__handle-right) { + top: 68% !important; +} + +.lag-handle-source { + transform: translate(-50%, -50%) scale(0.86); +} + +.lag-edge-path { + stroke: var(--lag-muted); + transition: + stroke 140ms ease, + opacity 140ms ease; +} + +.lag-edge-path[data-kind="governs"] { + stroke: var(--lag-governor); +} + +.lag-edge-path[data-kind="handoff"] { + stroke: var(--lag-success); +} + +.lag-edge-path[data-kind="feedback"] { + stroke: var(--lag-governor); + stroke-dasharray: 7 5; +} + +.lag-edge-path[data-kind="verdict"] { + stroke: var(--lag-worker); + stroke-dasharray: 3 4; +} + +.lag-edge-path[data-active="false"] { + opacity: 0.58; +} + +.lag-edge-path[data-pending="true"] { + animation: lag-edge-pulse 1.8s ease-in-out infinite; +} + +.lag-edge-label[data-recent="true"] { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--lag-accent) 26%, transparent), + 0 4px 12px color-mix(in srgb, #000 7%, transparent); +} + +@keyframes lag-edge-pulse { + 50% { opacity: 0.45; } +} + +.lag-edge-label { + position: absolute; + z-index: 20; + display: flex; + align-items: center; + gap: 5px; + border: 1px solid var(--lag-border); + border-radius: 999px; + background: color-mix(in srgb, var(--lag-card) 92%, transparent); + padding: 4px 7px; + color: var(--lag-muted); + font: 8.5px/1 var(--lag-mono); + box-shadow: 0 4px 12px color-mix(in srgb, #000 7%, transparent); + pointer-events: all; + white-space: nowrap; +} + +.lag-edge-label[data-active="true"] { + border-color: color-mix(in srgb, var(--lag-accent) 50%, var(--lag-border)); + color: var(--lag-accent); +} + +.lag-edge-label[data-selected="true"] { + border-color: var(--lag-accent); + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--lag-accent) 25%, transparent), + 0 4px 12px color-mix(in srgb, #000 7%, transparent); +} + +.lag-edge-label:focus-visible, +.lag-actions button:focus-visible, +.lag-timeline summary:focus-visible { + outline: 2px solid var(--lag-accent); + outline-offset: 2px; +} + +.lag-inspector { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + align-items: center; + gap: 12px 18px; + border: 1px solid var(--lag-border); + border-radius: 12px; + background: color-mix(in srgb, var(--lag-card) 92%, transparent); + padding: 11px 12px; +} + +.lag-inspector-copy p:last-child { + margin: 4px 0 0; + color: var(--lag-muted); + font: 10px/1.4 var(--lag-mono); +} + +.lag-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 7px; + flex-wrap: wrap; +} + +.lag-actions button, +.lag-demo-toolbar select { + min-height: 31px; + border: 1px solid var(--lag-border); + border-radius: 9px; + background: var(--lag-card); + padding: 5px 9px; + color: var(--lag-foreground); + font: 10px/1 var(--lag-font); + cursor: pointer; +} + +.lag-actions button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.lag-follow-up-status { + grid-column: 1 / -1; + margin: 0; + color: var(--lag-muted); + font-size: 10px; +} + +.lag-relationship-inspector, +.lag-loop-panel { + min-width: 0; + overflow: hidden; + border: 1px solid var(--lag-border); + border-radius: 13px; + background: color-mix(in srgb, var(--lag-background) 96%, transparent); + color: var(--lag-foreground); + box-shadow: 0 12px 30px color-mix(in srgb, #000 10%, transparent); +} + +.lag-relationship-inspector > header, +.lag-loop-panel > header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + min-height: 52px; + border-bottom: 1px solid var(--lag-border); + padding: 8px 10px; +} + +.lag-relationship-inspector h3, +.lag-loop-panel h3, +.lag-loop-panel p { + margin: 0; +} + +.lag-loop-panel > header p { + margin-top: 2px; + color: var(--lag-muted); + font: 9px/1.2 var(--lag-mono); +} + +.lag-panel-icon { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--lag-accent) 35%, var(--lag-border)); + border-radius: 9px; + color: var(--lag-accent); +} + +.lag-panel-icon[data-active="true"] { + animation: lag-spin 3s linear infinite; +} + +@keyframes lag-spin { to { transform: rotate(360deg); } } + +.lag-icon-button { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--lag-muted); + font-size: 18px; + cursor: pointer; +} + +.lag-icon-button:hover { background: var(--lag-card-muted); } + +.lag-relationship-grid { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 7px 10px; + margin: 10px; + border: 1px solid var(--lag-border); + border-radius: 10px; + background: var(--lag-card); + padding: 10px; + font-size: 10px; +} + +.lag-relationship-grid dt { color: var(--lag-muted); } +.lag-relationship-grid dd { margin: 0; overflow-wrap: anywhere; } + +.lag-panel-note { + margin: 0 10px 10px; + border: 1px solid var(--lag-border); + border-radius: 9px; + background: var(--lag-card-muted); + padding: 9px; + color: var(--lag-muted); + font-size: 10px; +} + +.lag-danger-button { + width: calc(100% - 20px); + margin: 0 10px 10px; + border: 1px solid color-mix(in srgb, var(--lag-danger) 45%, var(--lag-border)); + border-radius: 9px; + background: color-mix(in srgb, var(--lag-danger) 8%, var(--lag-card)); + padding: 8px; + color: var(--lag-danger); + font-size: 10px; + cursor: pointer; +} + +.lag-danger-button:disabled { cursor: not-allowed; opacity: 0.45; } + +.lag-loop-panel-body { + display: grid; + max-height: 640px; + gap: 10px; + overflow-y: auto; + padding: 10px; +} + +.lag-stop-confirm, +.lag-loop-summary, +.lag-blocking-issues, +.lag-lap-card, +.lag-diagnostics { + border: 1px solid var(--lag-border); + border-radius: 10px; + background: var(--lag-card); + padding: 10px; +} + +.lag-stop-confirm { + border-color: color-mix(in srgb, var(--lag-governor) 45%, var(--lag-border)); + background: color-mix(in srgb, var(--lag-governor) 8%, var(--lag-card)); + color: var(--lag-governor); + font-size: 10px; +} + +.lag-stop-confirm p { margin-top: 5px; color: var(--lag-foreground); } +.lag-stop-confirm > div { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; } + +.lag-stop-confirm button, +.lag-loop-summary button { + border: 1px solid var(--lag-border); + border-radius: 8px; + background: var(--lag-card); + padding: 6px 8px; + color: var(--lag-foreground); + font-size: 9px; + cursor: pointer; +} + +.lag-loop-summary h4, +.lag-blocking-issues h4 { + margin: 0; + font-size: 11px; +} + +.lag-loop-summary > p { + margin-top: 5px; + color: var(--lag-muted); + font-size: 10px; +} + +.lag-loop-summary dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + margin: 9px 0; + border-top: 1px solid var(--lag-border); + padding-top: 8px; +} + +.lag-loop-summary dl div { min-width: 0; } +.lag-loop-summary dt { color: var(--lag-muted); font-size: 8px; } +.lag-loop-summary dd { overflow-wrap: anywhere; margin: 2px 0 0; font: 9px/1.3 var(--lag-mono); } +.lag-loop-summary > small { display: block; margin-top: 8px; color: var(--lag-muted); } + +.lag-issue-list, +.lag-lap-list { + display: grid; + gap: 7px; + margin: 7px 0 0; + padding: 0; + list-style: none; +} + +.lag-issue-list li { display: grid; gap: 2px; font-size: 9px; } +.lag-issue-list strong { color: var(--lag-danger); text-transform: uppercase; } +.lag-issue-list small { color: var(--lag-muted); font-family: var(--lag-mono); } + +.lag-lap-card > header { display: flex; justify-content: space-between; gap: 8px; } +.lag-lap-card p { display: grid; gap: 2px; margin-top: 7px; color: var(--lag-muted); font-size: 9px; } +.lag-lap-card p b { color: var(--lag-foreground); } +.lag-lap-card time { display: block; margin-top: 7px; color: var(--lag-muted); font: 8px/1 var(--lag-mono); } + +.lag-diagnostics { color: var(--lag-muted); font: 8px/1.45 var(--lag-mono); overflow-wrap: anywhere; } +.lag-diagnostics summary { color: var(--lag-foreground); cursor: pointer; } + +.lag-timeline { + border: 1px solid var(--lag-border); + border-radius: 12px; + background: color-mix(in srgb, var(--lag-card) 90%, transparent); +} + +.lag-timeline summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + color: var(--lag-foreground); + font: 11px/1 var(--lag-mono); + cursor: pointer; +} + +.lag-timeline ol { + display: grid; + gap: 6px; + margin: 0; + padding: 0 12px 12px 32px; +} + +.lag-timeline li { + color: var(--lag-muted); + font-size: 10px; +} + +.lag-timeline li time { + margin-right: 8px; + font-family: var(--lag-mono); +} + +.lag-timeline li small { + margin-left: 6px; + color: var(--lag-worker); +} + +@media (max-width: 719px) { + .lag-workspace[data-panel-open="true"] { + grid-template-columns: 1fr; + } + + .lag-inspector { + grid-template-columns: 1fr; + } + + .lag-actions { + justify-content: flex-start; + } +} + +@media (prefers-color-scheme: dark) { + .looperators-agent-graph { + --lag-background: var(--background, #07090a); + --lag-card: var(--card, #0d120e); + --lag-card-muted: var(--muted, #0f1511); + --lag-foreground: var(--foreground, #e5e7eb); + --lag-muted: var(--muted-foreground, #9ca3af); + --lag-border: var(--border, rgba(255, 255, 255, 0.11)); + --lag-accent: var(--term-accent-hi, #a3e635); + --lag-governor: var(--term-amber, #fcd34d); + --lag-worker: var(--term-cyan, #67e8f9); + --lag-success: var(--term-emerald, #6ee7b7); + --lag-danger: var(--term-rose, #fb7185); + } +} + +@media (prefers-reduced-motion: reduce) { + .looperators-agent-graph *, + .looperators-agent-graph *::before, + .looperators-agent-graph *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/packages/agent-graph-ui/tests/embed.test.mjs b/packages/agent-graph-ui/tests/embed.test.mjs new file mode 100644 index 0000000..69fe8f2 --- /dev/null +++ b/packages/agent-graph-ui/tests/embed.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFile, stat } from 'node:fs/promises'; +import test from 'node:test'; + +const embedScript = new URL( + '../dist/embed/agent-graph-ui.iife.js', + import.meta.url, +); +const embedStyle = new URL('../dist/embed/agent-graph-ui.css', import.meta.url); +const libraryScript = new URL( + '../dist/library/agent-graph-ui.js', + import.meta.url, +); +const inlineScript = new URL( + '../dist/inline/agent-graph-ui-inline.js', + import.meta.url, +); +const inlineStyle = new URL( + '../dist/inline/agent-graph-ui-inline.css', + import.meta.url, +); + +test('plugin-target assets leave at least 512 KiB of inline fragment headroom', async () => { + const [scriptStat, styleStat] = await Promise.all([ + stat(embedScript), + stat(embedStyle), + ]); + const total = scriptStat.size + styleStat.size; + assert.ok(total < 1.5 * 1024 * 1024, `embed assets are ${total} bytes`); +}); + +test('shipped assets contain no remote or plugin runtime dependency', async () => { + const [script, style, library, inline, inlineCss] = await Promise.all([ + readFile(embedScript, 'utf8'), + readFile(embedStyle, 'utf8'), + readFile(libraryScript, 'utf8'), + readFile(inlineScript, 'utf8'), + readFile(inlineStyle, 'utf8'), + ]); + for (const source of [script, style]) { + // Bundled React and SVG metadata contain inert namespace, help, and + // attribution URLs. Network isolation is asserted in the browser + // acceptance; this static guard rejects executable network primitives. + assert.doesNotMatch(source, /\b(?:window|globalThis)\.fetch\s*\(/u); + assert.doesNotMatch(source, /\bXMLHttpRequest\b/u); + assert.doesNotMatch(source, /\bWebSocket\b/u); + assert.doesNotMatch(source, /\bEventSource\b/u); + } + assert.doesNotMatch(library, /electron|sessionManager|workflowKernel/u); + assert.doesNotMatch(library, /from\s*["']@\//u); + assert.match(script, /looperatorsAgentGraphView/u); + assert.match(inline, /mountInlineAgentLoopGraph/u); + assert.match(inline, /Agent Loop relationship graph/u); + assert.match(inlineCss, /looperators-inline-agent-graph/u); + assert.doesNotMatch(inline, /\bResizeObserver\b/u); + assert.doesNotMatch(inline, /\bcreateRoot\b/u); + assert.doesNotMatch(inline, /react-flow__node/u); +}); diff --git a/packages/agent-graph-ui/tests/model.test.mjs b/packages/agent-graph-ui/tests/model.test.mjs new file mode 100644 index 0000000..179bbdb --- /dev/null +++ b/packages/agent-graph-ui/tests/model.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + agentGraphScenarios, + assertAgentGraphModel, + createAgentLoopFlowElements, + desktopParityModel, + orientationForWidth, +} from '../dist/library/agent-graph-ui.js'; + +test('all paired fixtures contain an independently valid portable model', () => { + assert.equal(agentGraphScenarios.length, 7); + for (const fixture of agentGraphScenarios) { + assert.equal( + assertAgentGraphModel(fixture.expectedModel), + fixture.expectedModel, + ); + assert.equal( + fixture.projection.projectionDigest, + fixture.expectedModel.identity.projectionDigest, + ); + assert.equal(fixture.projection.integrity.status, 'verified'); + } +}); + +test('Desktop parity model exercises the rich shared interaction surface', () => { + assert.equal(assertAgentGraphModel(desktopParityModel), desktopParityModel); + assert.equal(desktopParityModel.nodes.every((node) => node.description), true); + assert.equal(desktopParityModel.edges.some((edge) => edge.pending), true); + assert.equal(desktopParityModel.latestReport.issues.length, 2); + assert.equal(desktopParityModel.laps.length, 2); +}); + +test('wide and narrow layouts keep three stable nodes and four relationships', () => { + const model = agentGraphScenarios.find( + (fixture) => fixture.name === 'reviewer-issues', + ).expectedModel; + for (const orientation of ['horizontal', 'vertical']) { + const flow = createAgentLoopFlowElements(model, orientation); + assert.deepEqual( + flow.nodes.map((node) => node.id), + ['root', 'role:implementer', 'role:reviewer'], + ); + assert.deepEqual( + flow.edges.map((edge) => edge.data.edge.kind), + ['governs', 'handoff', 'feedback', 'verdict'], + ); + assert.equal( + flow.edges.filter((edge) => edge.animated).map((edge) => edge.id)[0], + 'reviewer-feedback-implementer', + ); + } + assert.equal(orientationForWidth(719), 'vertical'); + assert.equal(orientationForWidth(720), 'horizontal'); +}); + +test('model validation rejects unverified, invalid, and unknown-node input', () => { + const model = structuredClone(agentGraphScenarios[0].expectedModel); + assert.throws( + () => + assertAgentGraphModel({ + ...model, + identity: { ...model.identity, integrity: 'unknown' }, + }), + /verified/, + ); + assert.throws( + () => + assertAgentGraphModel({ + ...model, + identity: { ...model.identity, projectionDigest: 'short' }, + }), + /bounded string/, + ); + const edges = structuredClone(model.edges); + edges[0].target = 'missing'; + assert.throws(() => assertAgentGraphModel({ ...model, edges }), /unknown node/); +}); diff --git a/packages/agent-graph-ui/tsconfig.build.json b/packages/agent-graph-ui/tsconfig.build.json new file mode 100644 index 0000000..03a199c --- /dev/null +++ b/packages/agent-graph-ui/tsconfig.build.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": false, + "rootDir": "./src", + "outDir": "./dist/types" + }, + "include": [ + "src/index.ts", + "src/graph-model.ts", + "src/fixtures.ts", + "src/agent-node.tsx", + "src/relationship-edge.tsx", + "src/loop-status.tsx", + "src/agent-loop-graph.tsx", + "src/inline.ts", + "src/inline-agent-loop-graph.ts" + ] +} diff --git a/packages/agent-graph-ui/tsconfig.json b/packages/agent-graph-ui/tsconfig.json new file mode 100644 index 0000000..dc70fdb --- /dev/null +++ b/packages/agent-graph-ui/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": [ + "ES2023", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "types": [ + "vite/client" + ] + }, + "include": [ + "src", + "vite.config.ts", + "vite.library.config.ts", + "vite.embed.config.ts", + "vite.inline.config.ts" + ] +} diff --git a/packages/agent-graph-ui/vite.config.ts b/packages/agent-graph-ui/vite.config.ts new file mode 100644 index 0000000..c38592b --- /dev/null +++ b/packages/agent-graph-ui/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + host: '127.0.0.1', + port: 48311, + strictPort: true, + }, +}); diff --git a/packages/agent-graph-ui/vite.embed.config.ts b/packages/agent-graph-ui/vite.embed.config.ts new file mode 100644 index 0000000..39166bf --- /dev/null +++ b/packages/agent-graph-ui/vite.embed.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'node:path'; + +export default defineConfig({ + plugins: [react()], + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + build: { + outDir: 'dist/embed', + emptyOutDir: false, + cssCodeSplit: false, + assetsInlineLimit: Number.MAX_SAFE_INTEGER, + target: 'es2022', + minify: 'oxc', + sourcemap: false, + lib: { + entry: resolve(import.meta.dirname, 'src/embed.tsx'), + name: 'LooperatorsAgentGraphUI', + formats: ['iife'], + fileName: () => 'agent-graph-ui.iife.js', + cssFileName: 'agent-graph-ui', + }, + }, +}); diff --git a/packages/agent-graph-ui/vite.inline.config.ts b/packages/agent-graph-ui/vite.inline.config.ts new file mode 100644 index 0000000..5914bb7 --- /dev/null +++ b/packages/agent-graph-ui/vite.inline.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'node:path'; + +export default defineConfig({ + build: { + outDir: 'dist/inline', + emptyOutDir: false, + cssCodeSplit: false, + target: 'es2022', + minify: 'oxc', + sourcemap: false, + lib: { + entry: resolve(import.meta.dirname, 'src/inline.ts'), + formats: ['es'], + fileName: () => 'agent-graph-ui-inline.js', + cssFileName: 'agent-graph-ui-inline', + }, + }, +}); diff --git a/packages/agent-graph-ui/vite.library.config.ts b/packages/agent-graph-ui/vite.library.config.ts new file mode 100644 index 0000000..c634b35 --- /dev/null +++ b/packages/agent-graph-ui/vite.library.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'node:path'; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist/library', + emptyOutDir: false, + cssCodeSplit: false, + lib: { + entry: resolve(import.meta.dirname, 'src/index.ts'), + formats: ['es'], + fileName: () => 'agent-graph-ui.js', + cssFileName: 'agent-graph-ui', + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', '@xyflow/react'], + }, + }, +}); diff --git a/plugins/looperators-agent-loop-p0/.codex-plugin/plugin.json b/plugins/looperators-agent-loop-p0/.codex-plugin/plugin.json new file mode 100644 index 0000000..e3574f1 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/.codex-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "looperators-agent-loop-p0", + "version": "0.1.0+codex.20260726175228", + "description": "P0 compatibility probe for looperators Agent Loop hooks, inline views, and MCP Apps.", + "author": { + "name": "looperators" + }, + "skills": "./skills/", + "interface": { + "displayName": "Looperators Agent Loop P0", + "shortDescription": "Probe and visualize a local Agent Loop.", + "longDescription": "A deliberately small compatibility experiment for collecting Codex hook events, rendering a thread-scoped inline graph, and exposing the same graph through an MCP App.", + "developerName": "looperators", + "category": "Productivity", + "capabilities": [ + "Agent Loop hook collection", + "Thread-scoped inline visualization", + "MCP App graph resource" + ], + "defaultPrompt": "Use $agent-loop-visualization to normalize the local P0 hook log and render the looperators Agent Loop graph." + }, + "mcpServers": "./.mcp.json" +} diff --git a/plugins/looperators-agent-loop-p0/.mcp.json b/plugins/looperators-agent-loop-p0/.mcp.json new file mode 100644 index 0000000..504a3db --- /dev/null +++ b/plugins/looperators-agent-loop-p0/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "looperators-agent-loop-p0": { + "command": "node", + "args": [ + "./mcp/server.mjs" + ], + "cwd": ".", + "env_vars": [ + "LOOPERATORS_P0_ARTIFACT_DIR" + ] + } + } +} diff --git a/plugins/looperators-agent-loop-p0/README.md b/plugins/looperators-agent-loop-p0/README.md new file mode 100644 index 0000000..43a0789 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/README.md @@ -0,0 +1,21 @@ +# looperators Agent Loop P0 plugin + +This is a compatibility probe, not a production orchestration runtime. It keeps +four boundaries intentionally small: + +- Codex hook inputs are collected as one atomic file per delivery. +- `scripts/normalize-events.mjs` derives a deterministic event stream and graph. +- `scripts/generate-inline-visualization.mjs` projects the real root and + subagents into an interactive, thread-scoped native HTML fragment. +- `mcp/server.mjs` exposes that graph as both structured tool output and an + MCP App resource. +- `scripts/sidecar-server.mjs` is a token-protected, loopback-only SSE fallback. + +No looperators Desktop runtime, provider adapter, workflow kernel, or transcript +tailing is included. + +## Local checks + +```sh +node --test plugins/looperators-agent-loop-p0/tests/*.test.mjs +``` diff --git a/plugins/looperators-agent-loop-p0/hooks/collect-event.mjs b/plugins/looperators-agent-loop-p0/hooks/collect-event.mjs new file mode 100644 index 0000000..b82c819 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/hooks/collect-event.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node + +import path from "node:path"; +import process from "node:process"; +import { + atomicWriteJson, + deliveryEventKey, + redactHookInput, + semanticEventKey, + sha256, + stableJson, +} from "../lib/event-utils.mjs"; + +function argument(name, fallback) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function continuationOutput(input, stateCreated) { + if (!["Stop", "SubagentStop"].includes(input.hook_event_name)) { + return null; + } + if (process.env.LOOPERATORS_P0_CONTINUATION_MODE !== "lap") { + return null; + } + if (input.stop_hook_active === true || !stateCreated) { + return { continue: true }; + } + const scope = + input.hook_event_name === "SubagentStop" + ? `subagent ${input.agent_id ?? "unknown"}` + : "root agent"; + return { + decision: "block", + reason: + `looperators P0 continuation lap 1/1 for ${scope}. ` + + "Acknowledge the continuation with the exact marker P0_LAP_CONTINUED, " + + "do not call tools, and then stop. The next Stop hook invocation must be allowed.", + }; +} + +async function main() { + const startedWall = new Date().toISOString(); + const startedMonotonicNs = process.hrtime.bigint(); + const lane = argument("lane", "primary"); + const delayMs = Number(argument("delay-ms", "0")); + const artifactDir = + process.env.LOOPERATORS_P0_ARTIFACT_DIR ?? + path.join(process.cwd(), "output", "looperators-agent-loop-p0"); + const rawText = await readStdin(); + let input; + try { + input = JSON.parse(rawText); + } catch { + input = { + hook_event_name: "ParseError", + malformed_input: { + sha256: sha256(rawText), + bytes: Buffer.byteLength(rawText), + }, + }; + } + + const redactedInput = redactHookInput(input); + const semanticKey = semanticEventKey(input); + const deliveryKey = deliveryEventKey(input, lane); + const identityHash = sha256( + stableJson({ + deliveryKey, + redactedInput, + }), + ); + const stateKey = sha256( + stableJson({ + session_id: input.session_id ?? null, + turn_id: input.turn_id ?? null, + event: input.hook_event_name ?? null, + agent_id: input.agent_id ?? null, + }), + ); + const statePath = path.join(artifactDir, "hook-state", `${stateKey}.json`); + let stateCreated = false; + if ( + lane === "primary" && + process.env.LOOPERATORS_P0_CONTINUATION_MODE === "lap" && + ["Stop", "SubagentStop"].includes(input.hook_event_name) && + input.stop_hook_active !== true + ) { + try { + await atomicWriteJson( + statePath, + { + semantic_key: semanticKey, + first_blocked_at: startedWall, + lap_cap: 1, + }, + { exclusive: true }, + ); + stateCreated = true; + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + } + } + + if (Number.isFinite(delayMs) && delayMs > 0 && delayMs <= 500) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + const finishedMonotonicNs = process.hrtime.bigint(); + const record = { + schema_version: 1, + semantic_key: semanticKey, + delivery_key: deliveryKey, + identity_hash: identityHash, + lane, + process_id: process.pid, + captured_at: startedWall, + started_monotonic_ns: startedMonotonicNs.toString(), + finished_monotonic_ns: finishedMonotonicNs.toString(), + duration_ms: Number(finishedMonotonicNs - startedMonotonicNs) / 1e6, + input: redactedInput, + }; + const eventPath = path.join( + artifactDir, + "hook-events", + `${identityHash}.json`, + ); + try { + await atomicWriteJson(eventPath, record, { exclusive: true }); + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + } + + const output = lane === "primary" ? continuationOutput(input, stateCreated) : null; + if (output) { + process.stdout.write(JSON.stringify(output)); + } +} + +main().catch(async (error) => { + const fallback = { + continue: true, + systemMessage: `looperators P0 collector failed safely: ${error?.code ?? "error"}`, + }; + process.stdout.write(JSON.stringify(fallback)); + process.exitCode = 0; +}); diff --git a/plugins/looperators-agent-loop-p0/hooks/hooks.json b/plugins/looperators-agent-loop-p0/hooks/hooks.json new file mode 100644 index 0000000..dc43536 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/hooks/hooks.json @@ -0,0 +1,90 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/collect-event.mjs\" --lane primary", + "timeout": 5, + "statusMessage": "Recording looperators session start" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/collect-event.mjs\" --lane primary", + "timeout": 5, + "statusMessage": "Recording looperators prompt boundary" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/collect-event.mjs\" --lane primary", + "timeout": 5, + "statusMessage": "Recording looperators tool start" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/collect-event.mjs\" --lane primary", + "timeout": 5, + "statusMessage": "Recording looperators tool result" + } + ] + } + ], + "SubagentStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/collect-event.mjs\" --lane primary", + "timeout": 5, + "statusMessage": "Recording looperators subagent start" + } + ] + } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/collect-event.mjs\" --lane primary", + "timeout": 5, + "statusMessage": "Applying looperators subagent lap policy" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/hooks/collect-event.mjs\" --lane primary", + "timeout": 5, + "statusMessage": "Applying looperators root lap policy" + } + ] + } + ] + } +} diff --git a/plugins/looperators-agent-loop-p0/lib/event-utils.mjs b/plugins/looperators-agent-loop-p0/lib/event-utils.mjs new file mode 100644 index 0000000..968f8c2 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/lib/event-utils.mjs @@ -0,0 +1,127 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { + link, + mkdir, + readFile, + rename, + unlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +export const P0_EVENTS = [ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "SubagentStart", + "SubagentStop", + "Stop", +]; + +export function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function sensitiveSummary(value, label) { + const serialized = typeof value === "string" ? value : stableJson(value); + return { + _redacted: label, + sha256: sha256(serialized), + bytes: Buffer.byteLength(serialized), + ...(value && typeof value === "object" && !Array.isArray(value) + ? { keys: Object.keys(value).sort() } + : {}), + }; +} + +export function redactHookInput(input) { + const sensitiveKeys = new Set([ + "prompt", + "last_assistant_message", + "tool_input", + "tool_response", + ]); + const walk = (value, key = "") => { + if (sensitiveKeys.has(key)) { + return sensitiveSummary(value, key); + } + if (Array.isArray(value)) { + return value.map((item) => walk(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([childKey, childValue]) => [ + childKey, + walk(childValue, childKey), + ]), + ); + } + return value; + }; + return walk(input); +} + +export function semanticEventKey(input) { + return [ + input.session_id ?? "-", + input.turn_id ?? "-", + input.hook_event_name ?? "-", + input.tool_use_id ?? input.agent_id ?? "-", + input.stop_hook_active === true ? "continuation" : "initial", + ].join(":"); +} + +export function deliveryEventKey(input, lane) { + return `${semanticEventKey(input)}:${lane}`; +} + +export async function atomicWriteJson(filePath, value, options = {}) { + await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + const body = `${JSON.stringify(value, null, 2)}\n`; + const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + if (options.exclusive) { + await writeFile(temporaryPath, body, { flag: "wx", mode: 0o600 }); + try { + await link(temporaryPath, filePath); + } finally { + await unlink(temporaryPath).catch(() => {}); + } + return; + } + await writeFile(temporaryPath, body, { mode: 0o600 }); + await rename(temporaryPath, filePath); +} + +export async function readJsonIfPresent(filePath) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") { + return null; + } + throw error; + } +} + +export function safeTokenEqual(actual, expected) { + const actualBuffer = Buffer.from(actual ?? ""); + const expectedBuffer = Buffer.from(expected ?? ""); + return ( + actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer) + ); +} diff --git a/plugins/looperators-agent-loop-p0/mcp/server.mjs b/plugins/looperators-agent-loop-p0/mcp/server.mjs new file mode 100644 index 0000000..870fdc8 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/mcp/server.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node + +import { createInterface } from "node:readline"; +import path from "node:path"; +import process from "node:process"; +import { atomicWriteJson, sha256, stableJson } from "../lib/event-utils.mjs"; + +const RESOURCE_URI = "ui://looperators/agent-loop-p0.html"; +const MIME_TYPE = "text/html;profile=mcp-app"; +const artifactDir = process.env.LOOPERATORS_P0_ARTIFACT_DIR; +let protocolSequence = 0; + +const widgetHtml = String.raw` + + + + + looperators Agent Loop MCP App + + + +

    looperators Agent Loop

    MCP App P0
    +
    Rootwaiting for structuredContent
    + initializing… + + +`; + +function graphPayload() { + return { + graph: { + nodes: [ + { id: "root", kind: "root", label: "Root turn" }, + { id: "agent-a", kind: "subagent", label: "Subagent A" }, + { id: "agent-b", kind: "subagent", label: "Subagent B" }, + ], + edges: [ + { source: "root", target: "agent-a", kind: "spawned" }, + { source: "root", target: "agent-b", kind: "spawned" }, + ], + }, + source: "looperators-agent-loop-p0", + }; +} + +async function logProtocol(direction, message) { + if (!artifactDir) { + return; + } + protocolSequence += 1; + const redacted = { + direction, + method: message.method ?? null, + id: message.id ?? null, + sequence: protocolSequence, + captured_at: new Date().toISOString(), + payload_sha256: sha256(stableJson(message)), + }; + const fileName = `${String(protocolSequence).padStart(4, "0")}-${direction}-${redacted.method ?? "response"}.json` + .replaceAll("/", "-") + .replaceAll(":", "-"); + await atomicWriteJson(path.join(artifactDir, "mcp-protocol", fileName), redacted); +} + +function toolDescriptor(name, description, inputSchema, outputSchema, meta = undefined) { + return { + name, + title: name + .split("_") + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(" "), + description, + inputSchema, + outputSchema, + annotations: { readOnlyHint: name !== "loop_ping" }, + ...(meta ? { _meta: meta } : {}), + }; +} + +function toolsList() { + const outputSchema = { + type: "object", + properties: { + graph: { type: "object" }, + source: { type: "string" }, + }, + required: ["graph", "source"], + additionalProperties: true, + }; + return [ + toolDescriptor( + "loop_snapshot", + "Return the normalized looperators Agent Loop graph as structured content.", + { type: "object", properties: {}, additionalProperties: false }, + outputSchema, + ), + toolDescriptor( + "loop_widget", + "Return the graph and ask a compatible host to render the MCP App.", + { type: "object", properties: {}, additionalProperties: false }, + outputSchema, + { + ui: { resourceUri: RESOURCE_URI }, + "ui/resourceUri": RESOURCE_URI, + "openai/outputTemplate": RESOURCE_URI, + }, + ), + toolDescriptor( + "loop_ping", + "A deterministic component-to-server tools/call probe.", + { + type: "object", + properties: { message: { type: "string" } }, + additionalProperties: false, + }, + { + type: "object", + properties: { ack: { type: "string" } }, + required: ["ack"], + additionalProperties: false, + }, + ), + ]; +} + +async function dispatch(message) { + switch (message.method) { + case "initialize": + return { + protocolVersion: message.params?.protocolVersion ?? "2025-06-18", + capabilities: { + tools: { listChanged: false }, + resources: { subscribe: false, listChanged: false }, + }, + serverInfo: { name: "looperators-agent-loop-p0", version: "0.1.0" }, + }; + case "ping": + return {}; + case "tools/list": + return { tools: toolsList() }; + case "resources/list": + return { + resources: [ + { + uri: RESOURCE_URI, + name: "looperators Agent Loop P0", + title: "looperators Agent Loop P0", + description: "Minimal MCP App graph for the compatibility probe.", + mimeType: MIME_TYPE, + }, + ], + }; + case "resources/templates/list": + return { resourceTemplates: [] }; + case "resources/read": + if (message.params?.uri !== RESOURCE_URI) { + throw Object.assign(new Error("Unknown resource"), { code: -32002 }); + } + return { + contents: [ + { + uri: RESOURCE_URI, + mimeType: MIME_TYPE, + text: widgetHtml, + }, + ], + }; + case "tools/call": { + const name = message.params?.name; + if (name === "loop_snapshot" || name === "loop_widget") { + const structuredContent = graphPayload(); + return { + content: [ + { + type: "text", + text: "looperators Agent Loop graph is available in structuredContent.", + }, + ], + structuredContent, + }; + } + if (name === "loop_ping") { + const messageText = message.params?.arguments?.message ?? "ping"; + return { + content: [{ type: "text", text: `ack:${messageText}` }], + structuredContent: { ack: `ack:${messageText}` }, + }; + } + throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32602 }); + } + default: + if (message.method?.startsWith("notifications/")) { + return undefined; + } + throw Object.assign(new Error(`Method not found: ${message.method}`), { + code: -32601, + }); + } +} + +async function handle(message) { + await logProtocol("in", message); + if (message.id === undefined || message.id === null) { + await dispatch(message); + return; + } + let response; + try { + response = { + jsonrpc: "2.0", + id: message.id, + result: await dispatch(message), + }; + } catch (error) { + response = { + jsonrpc: "2.0", + id: message.id, + error: { + code: Number.isInteger(error?.code) ? error.code : -32603, + message: error?.message ?? "Internal error", + }, + }; + } + await logProtocol("out", response); + process.stdout.write(`${JSON.stringify(response)}\n`); +} + +const input = createInterface({ input: process.stdin, crlfDelay: Infinity }); +let chain = Promise.resolve(); +input.on("line", (line) => { + if (!line.trim()) { + return; + } + chain = chain + .then(() => handle(JSON.parse(line))) + .catch((error) => { + process.stderr.write(`looperators P0 MCP error: ${error?.message ?? error}\n`); + }); +}); diff --git a/plugins/looperators-agent-loop-p0/scripts/capture-environment.mjs b/plugins/looperators-agent-loop-p0/scripts/capture-environment.mjs new file mode 100644 index 0000000..d01d3b2 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/capture-environment.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { open, stat } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { atomicWriteJson } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +function run(command, args, cwd = process.cwd()) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + timeout: 15_000, + }); + return { + command: [command, ...args], + status: result.status, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +} + +async function filePrefixSha256(filePath, prefixBytes) { + const handle = await open(filePath, "r"); + const buffer = Buffer.alloc(prefixBytes); + const { bytesRead } = await handle.read(buffer, 0, prefixBytes, 0); + await handle.close(); + const hash = createHash("sha256"); + hash.update(buffer.subarray(0, bytesRead)); + return { bytes_read: bytesRead, sha256: hash.digest("hex") }; +} + +const outputValue = argument("output"); +if (!outputValue) { + throw new Error("--output is required"); +} +const output = path.resolve(outputValue); +const repo = process.cwd(); +const appRoot = "/Applications/ChatGPT.app"; +const embeddedCodex = path.join(appRoot, "Contents", "Resources", "codex"); +const infoPlist = path.join(appRoot, "Contents", "Info.plist"); +const appAsar = path.join(appRoot, "Contents", "Resources", "app.asar"); +const appAsarStat = await stat(appAsar); +const evidence = { + schema_version: 1, + captured_at: new Date().toISOString(), + repo, + platform: { + arch: process.arch, + node: process.version, + sw_vers: run("/usr/bin/sw_vers", []), + }, + git: { + head: run("/usr/bin/git", ["rev-parse", "HEAD"], repo), + status: run("/usr/bin/git", ["status", "--short"], repo), + }, + codex: { + path_cli: run("codex", ["--version"], repo), + embedded_cli: run(embeddedCodex, ["--version"], repo), + embedded_features: run(embeddedCodex, ["features", "list"], repo), + }, + desktop: { + bundle_identifier: run("/usr/bin/plutil", [ + "-extract", + "CFBundleIdentifier", + "raw", + "-o", + "-", + infoPlist, + ]), + short_version: run("/usr/bin/plutil", [ + "-extract", + "CFBundleShortVersionString", + "raw", + "-o", + "-", + infoPlist, + ]), + build_version: run("/usr/bin/plutil", [ + "-extract", + "CFBundleVersion", + "raw", + "-o", + "-", + infoPlist, + ]), + app_asar_size_bytes: appAsarStat.size, + app_asar_prefix: await filePrefixSha256(appAsar, 65_536), + }, +}; +await atomicWriteJson(output, evidence); +process.stdout.write(`${JSON.stringify({ output, captured_at: evidence.captured_at })}\n`); diff --git a/plugins/looperators-agent-loop-p0/scripts/create-hook-probe.mjs b/plugins/looperators-agent-loop-p0/scripts/create-hook-probe.mjs new file mode 100644 index 0000000..cbcaef9 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/create-hook-probe.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { atomicWriteJson } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +const probeDirValue = argument("probe-dir"); +const artifactDirValue = argument("artifact-dir"); +if (!probeDirValue || !artifactDirValue) { + throw new Error("--probe-dir and --artifact-dir are required"); +} + +const probeDir = path.resolve(probeDirValue); +const artifactDir = path.resolve(artifactDirValue); +const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const collector = path.join(pluginRoot, "hooks", "collect-event.mjs"); +const command = (lane, delayMs = 0) => + [ + JSON.stringify(process.execPath), + JSON.stringify(collector), + "--lane", + lane, + ...(delayMs ? ["--delay-ms", String(delayMs)] : []), + ].join(" "); +const handler = (lane, delayMs = 0, statusMessage) => ({ + type: "command", + command: command(lane, delayMs), + timeout: 5, + statusMessage, +}); +const single = (eventName, statusMessage) => ({ + [eventName]: [ + { + hooks: [handler("primary", 0, statusMessage)], + }, + ], +}); +const config = { + hooks: { + ...single("SessionStart", "P0 collecting SessionStart"), + ...single("UserPromptSubmit", "P0 collecting UserPromptSubmit"), + PreToolUse: [ + { + matcher: "*", + hooks: [ + handler("primary", 120, "P0 PreToolUse lane primary"), + handler("parallel", 120, "P0 PreToolUse lane parallel"), + ], + }, + ], + ...single("PostToolUse", "P0 collecting PostToolUse"), + ...single("SubagentStart", "P0 collecting SubagentStart"), + ...single("SubagentStop", "P0 applying SubagentStop lap cap"), + ...single("Stop", "P0 applying Stop lap cap"), + }, +}; + +await mkdir(path.join(probeDir, ".codex"), { recursive: true, mode: 0o700 }); +await mkdir(artifactDir, { recursive: true, mode: 0o700 }); +await writeFile( + path.join(probeDir, ".codex", "hooks.json"), + `${JSON.stringify(config, null, 2)}\n`, + { mode: 0o600 }, +); +await atomicWriteJson(path.join(artifactDir, "probe-hooks.json"), config); +await atomicWriteJson(path.join(artifactDir, "probe-metadata.json"), { + schema_version: 1, + created_at: new Date().toISOString(), + probe_dir: probeDir, + plugin_root: pluginRoot, + artifact_dir: artifactDir, + continuation_mode: "lap", + lap_cap: 1, + parallel_probe_delay_ms: 120, +}); +process.stdout.write( + `${JSON.stringify({ + probe_dir: probeDir, + hooks_file: path.join(probeDir, ".codex", "hooks.json"), + artifact_dir: artifactDir, + })}\n`, +); diff --git a/plugins/looperators-agent-loop-p0/scripts/create-mcp-probe.mjs b/plugins/looperators-agent-loop-p0/scripts/create-mcp-probe.mjs new file mode 100644 index 0000000..64af51c --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/create-mcp-probe.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { atomicWriteJson } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +function tomlString(value) { + return JSON.stringify(value); +} + +const probeDirValue = argument("probe-dir"); +const artifactDirValue = argument("artifact-dir"); +if (!probeDirValue || !artifactDirValue) { + throw new Error("--probe-dir and --artifact-dir are required"); +} +const probeDir = path.resolve(probeDirValue); +const artifactDir = path.resolve(artifactDirValue); +const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const server = path.join(pluginRoot, "mcp", "server.mjs"); +const config = [ + "[mcp_servers.looperators_agent_loop_p0]", + `command = ${tomlString(process.execPath)}`, + `args = [${tomlString(server)}]`, + `env = { LOOPERATORS_P0_ARTIFACT_DIR = ${tomlString(artifactDir)} }`, + "startup_timeout_sec = 10", + "tool_timeout_sec = 10", + "", +].join("\n"); + +await mkdir(path.join(probeDir, ".codex"), { recursive: true, mode: 0o700 }); +await mkdir(artifactDir, { recursive: true, mode: 0o700 }); +await writeFile(path.join(probeDir, ".codex", "config.toml"), config, { + mode: 0o600, +}); +await writeFile(path.join(artifactDir, "project-config.toml"), config, { + mode: 0o600, +}); +await atomicWriteJson(path.join(artifactDir, "probe-metadata.json"), { + schema_version: 1, + created_at: new Date().toISOString(), + probe_dir: probeDir, + plugin_root: pluginRoot, + server, + resource_uri: "ui://looperators/agent-loop-p0.html", + mime_type: "text/html;profile=mcp-app", +}); +process.stdout.write( + `${JSON.stringify({ + probe_dir: probeDir, + config_file: path.join(probeDir, ".codex", "config.toml"), + artifact_dir: artifactDir, + })}\n`, +); diff --git a/plugins/looperators-agent-loop-p0/scripts/extract-desktop-evidence.mjs b/plugins/looperators-agent-loop-p0/scripts/extract-desktop-evidence.mjs new file mode 100644 index 0000000..d797e81 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/extract-desktop-evidence.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { atomicWriteJson } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +async function filesRecursively(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await filesRecursively(entryPath))); + } else if (entry.isFile() && entry.name.endsWith(".js")) { + files.push(entryPath); + } + } + return files; +} + +const asarDirValue = argument("asar-dir"); +const outputValue = argument("output"); +if (!asarDirValue || !outputValue) { + throw new Error("--asar-dir and --output are required"); +} +const asarDir = path.resolve(asarDirValue); +const output = path.resolve(outputValue); +const needles = [ + "var ige=5e6", + "followSymlinks:!1", + "/^[a-z0-9]+(?:-[a-z0-9]+)*\\.html$/", + "Inline visualization file exceeds the 5 MB limit", + "Inline visualizations cannot call tools", + "VPa=`codex-inline-vis`", + "WPa=/^::codex-(?:inline-vis|live-vis)", + "text/html;profile=mcp-app", + "t.data.ui?.resourceUri", + "method:fl(`tools/call`)", +]; +const files = await filesRecursively(asarDir); +const cache = new Map(); +const excerpts = []; +for (const needle of needles) { + let match = null; + for (const file of files) { + let text = cache.get(file); + if (text === undefined) { + text = await readFile(file, "utf8"); + cache.set(file, text); + } + const index = text.indexOf(needle); + if (index >= 0) { + match = { + needle, + relative_file: path.relative(asarDir, file), + byte_offset: Buffer.byteLength(text.slice(0, index)), + excerpt: text.slice(Math.max(0, index - 420), index + needle.length + 820), + source_sha256: createHash("sha256").update(text).digest("hex"), + }; + break; + } + } + excerpts.push(match ?? { needle, missing: true }); +} +await atomicWriteJson(output, { + schema_version: 1, + generated_at: new Date().toISOString(), + asar_directory: asarDir, + excerpts, +}); +process.stdout.write( + `${JSON.stringify({ + output, + matched: excerpts.filter((entry) => !entry.missing).length, + missing: excerpts.filter((entry) => entry.missing).map((entry) => entry.needle), + })}\n`, +); diff --git a/plugins/looperators-agent-loop-p0/scripts/generate-inline-visualization.mjs b/plugins/looperators-agent-loop-p0/scripts/generate-inline-visualization.mjs new file mode 100644 index 0000000..ad18bf8 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/generate-inline-visualization.mjs @@ -0,0 +1,829 @@ +#!/usr/bin/env node + +import { + lstat, + open, + readFile, + rename, + stat, + unlink, +} from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const MAX_FRAGMENT_BYTES = 2 * 1024 * 1024; +const OUTPUT_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.html$/; + +function argument(name) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : null; +} + +async function readJsonIfPresent(filePath, fallback) { + try { + return JSON.parse(await readFile(filePath, "utf8")); + } catch (error) { + if (error?.code === "ENOENT") { + return fallback; + } + throw new Error(`Cannot read ${filePath}: ${error.message}`); + } +} + +async function readJsonLinesIfPresent(filePath) { + let text; + try { + text = await readFile(filePath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw new Error(`Cannot read ${filePath}: ${error.message}`); + } + return text + .split(/\r?\n/u) + .filter(Boolean) + .map((line, index) => { + try { + return JSON.parse(line); + } catch (error) { + throw new Error( + `Cannot parse ${filePath}:${index + 1}: ${error.message}`, + ); + } + }); +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function asObject(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? value + : {}; +} + +function asString(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function asCount(value, fallback = 0) { + return Number.isInteger(value) && value >= 0 ? value : fallback; +} + +function shortIdentifier(value) { + const identifier = asString(value); + if (!identifier) { + return "unknown"; + } + return identifier.length <= 14 + ? identifier + : `${identifier.slice(0, 4)}…${identifier.slice(-6)}`; +} + +function semanticEventsFrom(deliveries) { + const selected = new Map(); + for (const [index, event] of deliveries.entries()) { + const key = + asString(event?.semantic_key) ?? + `delivery:${asString(event?.delivery_key) ?? index}`; + const existing = selected.get(key); + if (!existing || (existing.lane !== "primary" && event?.lane === "primary")) { + selected.set(key, event); + } + } + return [...selected.values()].sort((left, right) => { + const leftSequence = Number.isFinite(left?.sequence) + ? left.sequence + : Number.MAX_SAFE_INTEGER; + const rightSequence = Number.isFinite(right?.sequence) + ? right.sequence + : Number.MAX_SAFE_INTEGER; + if (leftSequence !== rightSequence) { + return leftSequence - rightSequence; + } + return String(left?.captured_at ?? "").localeCompare( + String(right?.captured_at ?? ""), + ); + }); +} + +function countsByEvent(events) { + const counts = new Map(); + for (const event of events) { + const name = asString(event?.event) ?? "Unknown"; + counts.set(name, (counts.get(name) ?? 0) + 1); + } + return Object.fromEntries( + [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function stopObservation(kind, events) { + const stopEvent = kind === "root" ? "Stop" : "SubagentStop"; + const stops = events.filter((event) => event?.event === stopEvent); + const initialStops = stops.filter( + (event) => event?.stop_hook_active === false, + ).length; + const continuations = stops.filter( + (event) => event?.stop_hook_active === true, + ).length; + let status = "Observed"; + if (continuations > 0) { + status = "Continuation completed"; + } else if (initialStops > 0) { + status = "Initial stop observed"; + } else if ( + events.some((event) => + kind === "root" + ? ["SessionStart", "UserPromptSubmit"].includes(event?.event) + : event?.event === "SubagentStart", + ) + ) { + status = "Started"; + } else if (events.length === 0) { + status = "Unknown"; + } + return { status, initialStops, continuations }; +} + +function timelineEntry(event) { + const details = []; + if (asString(event?.tool_name)) { + details.push(event.tool_name); + } + if (event?.stop_hook_active === false) { + details.push("initial stop"); + } else if (event?.stop_hook_active === true) { + details.push("continuation stop"); + } + if (asString(event?.turn_id)) { + details.push(`turn ${shortIdentifier(event.turn_id)}`); + } + return { + sequence: Number.isFinite(event?.sequence) ? event.sequence : null, + event: asString(event?.event) ?? "Unknown", + capturedAt: asString(event?.captured_at), + details, + }; +} + +function nodeView(node, semanticEvents) { + const kind = node.kind; + const rawIdentifier = + kind === "root" + ? asString(node.session_id) ?? + asString(node.id)?.replace(/^session:/u, "") ?? + null + : asString(node.agent_id) ?? + asString(node.id)?.replace(/^agent:/u, "") ?? + null; + const stableIdentifier = + asString(node.id) ?? + (rawIdentifier + ? `${kind === "root" ? "session" : "agent"}:${rawIdentifier}` + : null); + const relatedEvents = semanticEvents.filter((event) => { + if (kind === "root") { + return ( + !asString(event?.agent_id) && + (!rawIdentifier || event?.session_id === rawIdentifier) + ); + } + return rawIdentifier && event?.agent_id === rawIdentifier; + }); + const stop = stopObservation(kind, relatedEvents); + return { + id: stableIdentifier, + rawId: rawIdentifier, + shortId: shortIdentifier(rawIdentifier ?? stableIdentifier), + kind, + label: + asString(node.label) ?? (kind === "root" ? "Root session" : "Subagent"), + status: stop.status, + eventCount: relatedEvents.length, + initialStops: stop.initialStops, + continuations: stop.continuations, + eventCounts: countsByEvent(relatedEvents), + timeline: relatedEvents.map(timelineEntry), + }; +} + +export async function loadNormalizedArtifactData(artifactDir) { + const directory = path.resolve(artifactDir); + const metadata = await stat(directory); + if (!metadata.isDirectory()) { + throw new Error(`Artifact path is not a directory: ${directory}`); + } + const [graph, events, summary] = await Promise.all([ + readJsonIfPresent(path.join(directory, "normalized-graph.json"), { + nodes: [], + edges: [], + }), + readJsonLinesIfPresent(path.join(directory, "normalized-events.jsonl")), + readJsonIfPresent(path.join(directory, "normalized-summary.json"), {}), + ]); + return { artifactDir: directory, graph, events, summary }; +} + +export function buildLoopViewModel({ graph, events, summary }) { + const graphObject = asObject(graph); + const deliveries = asArray(events).filter( + (event) => event && typeof event === "object", + ); + const semanticEvents = semanticEventsFrom(deliveries); + const sourceNodes = asArray(graphObject.nodes).filter( + (node) => + node && + typeof node === "object" && + ["root", "subagent"].includes(node.kind), + ); + const nodes = sourceNodes.map((node) => nodeView(node, semanticEvents)); + const nodeIds = new Set(nodes.map((node) => node.id).filter(Boolean)); + const edges = asArray(graphObject.edges) + .filter( + (edge) => + edge && + typeof edge === "object" && + edge.kind === "spawned" && + nodeIds.has(edge.source) && + nodeIds.has(edge.target), + ) + .map((edge) => ({ + id: asString(edge.id), + source: asString(edge.source), + target: asString(edge.target), + kind: "spawned", + })); + const summaryObject = asObject(summary); + const warnings = []; + if (asArray(graphObject.nodes).length === 0) { + warnings.push("normalized-graph.json has no nodes"); + } + if (deliveries.length === 0) { + warnings.push("normalized-events.jsonl has no events"); + } + return { + schemaVersion: 1, + counts: { + deliveries: asCount(summaryObject.total_deliveries, deliveries.length), + semanticEvents: asCount( + summaryObject.total_semantic_events, + semanticEvents.length, + ), + graphNodes: asArray(graphObject.nodes).length, + graphEdges: asArray(graphObject.edges).length, + agentNodes: nodes.length, + agentEdges: edges.length, + }, + deliveryEventCounts: asObject(summaryObject.deliveries_by_event), + semanticEventCounts: countsByEvent(semanticEvents), + nodes, + edges, + warnings, + }; +} + +function jsonForHtml(value) { + return JSON.stringify(value) + .replaceAll("&", "\\u0026") + .replaceAll("<", "\\u003c") + .replaceAll(">", "\\u003e") + .replaceAll("\u2028", "\\u2028") + .replaceAll("\u2029", "\\u2029"); +} + +function htmlEscape(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function nodeButtonMarkup(node) { + const identifier = asString(node.id); + const dataIdentifier = identifier + ? ` data-node-id="${htmlEscape(identifier)}"` + : ""; + const disabled = identifier ? "" : " disabled"; + const label = `${node.label}, ${node.kind}, ${node.shortId}`; + return ``; +} + +export function renderInlineVisualization(viewModel) { + const data = jsonForHtml(viewModel); + const rootNodes = viewModel.nodes + .filter((node) => node.kind === "root") + .map(nodeButtonMarkup) + .join("\n"); + const subagentNodes = viewModel.nodes + .filter((node) => node.kind === "subagent") + .map(nodeButtonMarkup) + .join("\n"); + return `
    +
    + + + + +
    + +
    + +
    + + 100% + + +
    +
    + +
    +
    +
    +${rootNodes} +
    +
    +
    +${subagentNodes} +
    + +
    +
    + +
    +
    + Select a node + No selection +
    +

    No stable identifier selected

    +

    Select a recorded root or subagent to inspect its events.

    +
    +
      +
      + + Select a node first. +
      +
      + +
      +
      + P0.6 acceptance + JS ready: pending + node selected: pending + details updated: pending + zoom changed/reset: pending + filter changed: pending + follow-up requested: pending +
      + + +
      + + + +
      + + + + +`; +} + +async function assertSafeOutput(outputPath) { + const output = path.resolve(outputPath); + if (!OUTPUT_NAME_PATTERN.test(path.basename(output))) { + throw new Error( + `Output filename must be lowercase ASCII hyphenated HTML: ${output}`, + ); + } + const parent = path.dirname(output); + const parentMetadata = await lstat(parent); + if (parentMetadata.isSymbolicLink()) { + throw new Error(`Refusing to write through output directory symlink: ${parent}`); + } + if (!parentMetadata.isDirectory()) { + throw new Error(`Output parent is not a directory: ${parent}`); + } + let metadata = null; + try { + metadata = await lstat(output); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + if (metadata?.isSymbolicLink()) { + throw new Error(`Refusing to replace output symlink: ${output}`); + } + if (metadata && !metadata.isFile()) { + throw new Error(`Output is not a regular file: ${output}`); + } + return output; +} + +export async function writeInlineVisualization(outputPath, fragment) { + const output = await assertSafeOutput(outputPath); + const bytes = Buffer.byteLength(fragment); + if (bytes >= MAX_FRAGMENT_BYTES) { + throw new Error( + `Inline visualization is ${bytes} bytes; limit is ${MAX_FRAGMENT_BYTES - 1}`, + ); + } + const temporary = path.join( + path.dirname(output), + `.${path.basename(output)}.${process.pid}.${Date.now()}.tmp`, + ); + let handle; + try { + handle = await open(temporary, "wx", 0o600); + await handle.writeFile(fragment, "utf8"); + await handle.sync(); + await handle.close(); + handle = null; + await rename(temporary, output); + } finally { + if (handle) { + await handle.close(); + } + await unlink(temporary).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + } + return { output, bytes }; +} + +export async function generateInlineVisualization({ artifactDir, outputPath }) { + const source = await loadNormalizedArtifactData(artifactDir); + const viewModel = buildLoopViewModel(source); + const fragment = renderInlineVisualization(viewModel); + const written = await writeInlineVisualization(outputPath, fragment); + return { ...written, viewModel }; +} + +async function main() { + const artifactDir = argument("artifact-dir"); + const outputPath = argument("output"); + if (!artifactDir || !outputPath) { + throw new Error( + "Usage: generate-inline-visualization.mjs --artifact-dir --output ", + ); + } + const result = await generateInlineVisualization({ artifactDir, outputPath }); + process.stdout.write( + `${JSON.stringify({ + output: result.output, + bytes: result.bytes, + counts: result.viewModel.counts, + warnings: result.viewModel.warnings, + })}\n`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((error) => { + process.stderr.write(`${error.stack ?? error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/plugins/looperators-agent-loop-p0/scripts/normalize-events.mjs b/plugins/looperators-agent-loop-p0/scripts/normalize-events.mjs new file mode 100644 index 0000000..9a405a6 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/normalize-events.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import { readdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { atomicWriteJson, P0_EVENTS } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +async function readRecords(directory) { + let names = []; + try { + names = await readdir(directory); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } + const records = []; + for (const name of names.filter((entry) => entry.endsWith(".json")).sort()) { + const record = JSON.parse(await readFile(path.join(directory, name), "utf8")); + records.push({ ...record, evidence_file: `hook-events/${name}` }); + } + return records.sort((left, right) => { + const wall = left.captured_at.localeCompare(right.captured_at); + if (wall !== 0) { + return wall; + } + return BigInt(left.started_monotonic_ns) < BigInt(right.started_monotonic_ns) + ? -1 + : 1; + }); +} + +function normalizedRecord(record, sequence) { + const input = record.input ?? {}; + return { + schema_version: 1, + sequence, + event: input.hook_event_name ?? "Unknown", + session_id: input.session_id ?? null, + turn_id: input.turn_id ?? null, + tool_use_id: input.tool_use_id ?? null, + tool_name: input.tool_name ?? null, + agent_id: input.agent_id ?? null, + agent_type: input.agent_type ?? null, + stop_hook_active: input.stop_hook_active ?? null, + source: input.source ?? null, + lane: record.lane, + semantic_key: record.semantic_key, + delivery_key: record.delivery_key, + captured_at: record.captured_at, + duration_ms: record.duration_ms, + input_fields: Object.keys(input).sort(), + evidence_file: record.evidence_file, + }; +} + +function buildGraph(events) { + const nodeMap = new Map(); + const edgeMap = new Map(); + const addNode = (id, kind, label, fields = {}) => { + if (!nodeMap.has(id)) { + nodeMap.set(id, { id, kind, label, ...fields }); + } + }; + const addEdge = (source, target, kind, fields = {}) => { + const id = `${source}->${target}:${kind}`; + if (!edgeMap.has(id)) { + edgeMap.set(id, { id, source, target, kind, ...fields }); + } + }; + + for (const event of events) { + if (!event.session_id) { + continue; + } + const sessionId = `session:${event.session_id}`; + addNode(sessionId, "root", "Root session", { session_id: event.session_id }); + if (event.agent_id) { + const agentId = `agent:${event.agent_id}`; + addNode(agentId, "subagent", event.agent_type ?? "Subagent", { + agent_id: event.agent_id, + }); + addEdge(sessionId, agentId, "spawned"); + } + if (event.tool_use_id) { + const toolId = `tool:${event.tool_use_id}`; + addNode(toolId, "tool", event.tool_name ?? "Tool", { + tool_use_id: event.tool_use_id, + }); + addEdge(sessionId, toolId, "used"); + } + } + return { + schema_version: 1, + nodes: [...nodeMap.values()], + edges: [...edgeMap.values()], + }; +} + +function analyzeConcurrency(records) { + const groups = new Map(); + for (const record of records) { + const list = groups.get(record.semantic_key) ?? []; + list.push(record); + groups.set(record.semantic_key, list); + } + const probes = []; + for (const [semanticKey, group] of groups) { + if (new Set(group.map((entry) => entry.lane)).size < 2) { + continue; + } + const intervals = group.map((entry) => ({ + lane: entry.lane, + start: BigInt(entry.started_monotonic_ns), + finish: BigInt(entry.finished_monotonic_ns), + })); + let overlaps = false; + for (let left = 0; left < intervals.length; left += 1) { + for (let right = left + 1; right < intervals.length; right += 1) { + overlaps ||= ( + intervals[left].start < intervals[right].finish && + intervals[right].start < intervals[left].finish + ); + } + } + probes.push({ + semantic_key: semanticKey, + lanes: intervals.map((entry) => entry.lane).sort(), + overlaps, + start_spread_ms: + Number( + intervals.reduce( + (maximum, entry) => (entry.start > maximum ? entry.start : maximum), + intervals[0].start, + ) - + intervals.reduce( + (minimum, entry) => (entry.start < minimum ? entry.start : minimum), + intervals[0].start, + ), + ) / 1e6, + }); + } + return probes; +} + +function analyzeSubagentOverlap(events) { + const lifetimes = new Map(); + for (const event of events) { + if (!event.agent_id) { + continue; + } + const lifetime = lifetimes.get(event.agent_id) ?? { + agent_id: event.agent_id, + start: null, + initial_stop: null, + continuation_stop: null, + }; + if (event.event === "SubagentStart") { + lifetime.start = Date.parse(event.captured_at); + } else if (event.event === "SubagentStop" && event.stop_hook_active === true) { + lifetime.continuation_stop = Date.parse(event.captured_at); + } else if (event.event === "SubagentStop") { + lifetime.initial_stop = Date.parse(event.captured_at); + } + lifetimes.set(event.agent_id, lifetime); + } + const completed = [...lifetimes.values()].filter( + (entry) => entry.start !== null && (entry.continuation_stop ?? entry.initial_stop) !== null, + ); + const overlaps = []; + for (let left = 0; left < completed.length; left += 1) { + for (let right = left + 1; right < completed.length; right += 1) { + const leftStop = + completed[left].continuation_stop ?? completed[left].initial_stop; + const rightStop = + completed[right].continuation_stop ?? completed[right].initial_stop; + const overlapMs = + Math.min(leftStop, rightStop) - + Math.max(completed[left].start, completed[right].start); + overlaps.push({ + agents: [completed[left].agent_id, completed[right].agent_id].sort(), + overlaps: overlapMs > 0, + overlap_ms: Math.max(0, overlapMs), + }); + } + } + return { + lifetimes: completed.map((entry) => ({ + agent_id: entry.agent_id, + start: new Date(entry.start).toISOString(), + initial_stop: entry.initial_stop + ? new Date(entry.initial_stop).toISOString() + : null, + continuation_stop: entry.continuation_stop + ? new Date(entry.continuation_stop).toISOString() + : null, + })), + overlaps, + }; +} + +export async function normalizeArtifactDirectory(artifactDir) { + const records = await readRecords(path.join(artifactDir, "hook-events")); + const events = records.map(normalizedRecord); + const deliveriesByEvent = Object.fromEntries( + [...new Set(events.map((event) => event.event))] + .sort() + .map((eventName) => [ + eventName, + events.filter((event) => event.event === eventName).length, + ]), + ); + const semanticCounts = new Map(); + for (const event of events) { + semanticCounts.set( + event.semantic_key, + (semanticCounts.get(event.semantic_key) ?? 0) + 1, + ); + } + const continuationPairs = events + .filter((event) => ["Stop", "SubagentStop"].includes(event.event)) + .reduce((result, event) => { + const key = [ + event.session_id, + event.turn_id, + event.event, + event.agent_id ?? "-", + ].join(":"); + const current = result[key] ?? { initial: 0, continuation: 0 }; + current[event.stop_hook_active === true ? "continuation" : "initial"] += 1; + result[key] = current; + return result; + }, {}); + const summary = { + schema_version: 1, + generated_at: new Date().toISOString(), + total_deliveries: events.length, + total_semantic_events: semanticCounts.size, + deliveries_by_event: deliveriesByEvent, + required_event_coverage: Object.fromEntries( + P0_EVENTS.map((eventName) => [ + eventName, + (deliveriesByEvent[eventName] ?? 0) > 0, + ]), + ), + semantic_duplicate_groups: [...semanticCounts.entries()] + .filter(([, count]) => count > 1) + .map(([semantic_key, count]) => ({ semantic_key, count })), + concurrency_probes: analyzeConcurrency(records), + subagent_concurrency: analyzeSubagentOverlap(events), + continuation_pairs: continuationPairs, + transcript_paths: [ + ...new Set( + records + .map((record) => record.input?.transcript_path) + .filter((entry) => typeof entry === "string"), + ), + ].sort(), + agent_transcript_paths: [ + ...new Set( + records + .map((record) => record.input?.agent_transcript_path) + .filter((entry) => typeof entry === "string"), + ), + ].sort(), + }; + const graph = buildGraph(events); + await atomicWriteJson(path.join(artifactDir, "normalized-summary.json"), summary); + await atomicWriteJson(path.join(artifactDir, "normalized-graph.json"), graph); + await writeFile( + path.join(artifactDir, "normalized-events.jsonl"), + events.map((event) => JSON.stringify(event)).join("\n") + + (events.length ? "\n" : ""), + { mode: 0o600 }, + ); + return { events, graph, summary }; +} + +async function main() { + const artifactDir = path.resolve( + argument( + "artifact-dir", + process.env.LOOPERATORS_P0_ARTIFACT_DIR ?? + path.join(process.cwd(), "output", "looperators-agent-loop-p0"), + ), + ); + const result = await normalizeArtifactDirectory(artifactDir); + process.stdout.write( + `${JSON.stringify({ + artifact_dir: artifactDir, + deliveries: result.events.length, + semantic_events: result.summary.total_semantic_events, + event_coverage: result.summary.required_event_coverage, + })}\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/plugins/looperators-agent-loop-p0/scripts/probe-mcp-contract.mjs b/plugins/looperators-agent-loop-p0/scripts/probe-mcp-contract.mjs new file mode 100644 index 0000000..953a9bf --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/probe-mcp-contract.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { createInterface } from "node:readline"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { atomicWriteJson } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +const outputValue = argument("output"); +if (!outputValue) { + throw new Error("--output is required"); +} +const output = path.resolve(outputValue); +const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const serverPath = path.join(pluginRoot, "mcp", "server.mjs"); +const child = spawn(process.execPath, [serverPath], { + stdio: ["pipe", "pipe", "pipe"], +}); +let stderr = ""; +child.stderr.on("data", (chunk) => { + stderr += chunk; +}); +const pending = new Map(); +const lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); +lines.on("line", (line) => { + const message = JSON.parse(line); + pending.get(message.id)?.(message); + pending.delete(message.id); +}); +let nextId = 1; +const request = (method, params = {}) => + new Promise((resolve, reject) => { + const id = nextId++; + const timer = setTimeout( + () => reject(new Error(`MCP timeout for ${method}: ${stderr}`)), + 3_000, + ); + pending.set(id, (message) => { + clearTimeout(timer); + if (message.error) { + reject(new Error(message.error.message)); + } else { + resolve(message.result); + } + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + }); + +try { + const initialize = await request("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "looperators-p0-contract-probe", version: "1" }, + }); + const toolList = await request("tools/list"); + const resourceList = await request("resources/list"); + const resource = await request("resources/read", { + uri: "ui://looperators/agent-loop-p0.html", + }); + const widget = await request("tools/call", { + name: "loop_widget", + arguments: {}, + }); + const ping = await request("tools/call", { + name: "loop_ping", + arguments: { message: "component-call" }, + }); + const html = resource.contents[0].text; + await atomicWriteJson(output, { + schema_version: 1, + initialize, + tools: toolList.tools, + resources: resourceList.resources, + resource_read: { + uri: resource.contents[0].uri, + mimeType: resource.contents[0].mimeType, + html_sha256: createHash("sha256").update(html).digest("hex"), + html_bytes: Buffer.byteLength(html), + contains_ui_initialize: html.includes("ui/initialize"), + contains_tool_result_notification: html.includes( + "ui/notifications/tool-result", + ), + contains_component_tools_call: html.includes("tools/call"), + }, + widget_result: widget, + component_ping_result: ping, + }); +} finally { + child.stdin.end(); +} +process.stdout.write(`${JSON.stringify({ output })}\n`); diff --git a/plugins/looperators-agent-loop-p0/scripts/run-hook-probe.mjs b/plugins/looperators-agent-loop-p0/scripts/run-hook-probe.mjs new file mode 100644 index 0000000..a64d896 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/run-hook-probe.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { atomicWriteJson } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +const codexBin = path.resolve(argument("codex-bin")); +const probeDir = path.resolve(argument("probe-dir")); +const artifactDir = path.resolve(argument("artifact-dir")); +const runId = argument("run-id", "default"); +const includeUserConfig = process.argv.includes("--include-user-config"); +if (!codexBin || !probeDir || !artifactDir) { + throw new Error("--codex-bin, --probe-dir, and --artifact-dir are required"); +} + +const lastMessagePath = path.join( + artifactDir, + `codex-${runId}-last-message.txt`, +); +const prompt = [ + "Run a synthetic looperators Codex hook compatibility probe. Do not inspect", + "credentials, user configuration, or files outside this temporary project.", + "First call the shell tool exactly once with a harmless command that prints", + "P0_TOOL_OK. Then use the Agent/spawn_agent capability to launch exactly two", + "independent subagents concurrently. Ask subagent A to return only P0_SUBAGENT_A", + "and subagent B to return only P0_SUBAGENT_B; they must not call tools. Wait for", + "both. Report P0_TOOL_OK and both markers, then stop. If a Stop or SubagentStop", + "hook requests one continuation lap, obey it, output P0_LAP_CONTINUED, call no", + "more tools, and stop again.", +].join(" "); +const args = [ + "exec", + "--cd", + probeDir, + "--add-dir", + artifactDir, + "--skip-git-repo-check", + "--sandbox", + "workspace-write", + "--dangerously-bypass-hook-trust", + ...(!includeUserConfig ? ["--ignore-user-config"] : []), + "--config", + 'model_reasoning_effort="high"', + "--config", + "features.multi_agent=true", + "--json", + "--output-last-message", + lastMessagePath, + prompt, +]; + +await mkdir(artifactDir, { recursive: true, mode: 0o700 }); +await atomicWriteJson(path.join(artifactDir, `codex-${runId}-command.json`), { + executable: codexBin, + arguments: args, + cwd: probeDir, + includes_user_config: includeUserConfig, + environment_overrides: { + LOOPERATORS_P0_ARTIFACT_DIR: artifactDir, + LOOPERATORS_P0_CONTINUATION_MODE: "lap", + }, +}); + +const child = spawn(codexBin, args, { + cwd: probeDir, + env: { + ...process.env, + LOOPERATORS_P0_ARTIFACT_DIR: artifactDir, + LOOPERATORS_P0_CONTINUATION_MODE: "lap", + }, + stdio: ["ignore", "pipe", "pipe"], +}); +const stdoutChunks = []; +const stderrChunks = []; +child.stdout.on("data", (chunk) => { + stdoutChunks.push(chunk); + process.stdout.write(chunk); +}); +child.stderr.on("data", (chunk) => { + stderrChunks.push(chunk); + process.stderr.write(chunk); +}); +const exitCode = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); +}); +await writeFile( + path.join(artifactDir, `codex-${runId}.jsonl`), + Buffer.concat(stdoutChunks), + { mode: 0o600 }, +); +await writeFile( + path.join(artifactDir, `codex-${runId}.stderr.txt`), + Buffer.concat(stderrChunks), + { mode: 0o600 }, +); +await atomicWriteJson(path.join(artifactDir, `codex-${runId}-result.json`), { + exit_code: exitCode, + completed_at: new Date().toISOString(), +}); +process.exitCode = exitCode ?? 1; diff --git a/plugins/looperators-agent-loop-p0/scripts/run-mcp-host-probe.mjs b/plugins/looperators-agent-loop-p0/scripts/run-mcp-host-probe.mjs new file mode 100644 index 0000000..6929b82 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/run-mcp-host-probe.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { atomicWriteJson } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +const codexBinValue = argument("codex-bin"); +const probeDirValue = argument("probe-dir"); +const artifactDirValue = argument("artifact-dir"); +if (!codexBinValue || !probeDirValue || !artifactDirValue) { + throw new Error("--codex-bin, --probe-dir, and --artifact-dir are required"); +} +const codexBin = path.resolve(codexBinValue); +const probeDir = path.resolve(probeDirValue); +const artifactDir = path.resolve(artifactDirValue); +const runId = argument("run-id", "project-config"); +const injectConfig = process.argv.includes("--inject-config"); +const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const mcpServer = path.join(pluginRoot, "mcp", "server.mjs"); +const prompt = [ + "Run the synthetic looperators MCP App host probe.", + "Call the MCP tool loop_widget from server looperators_agent_loop_p0 exactly once.", + "Do not call any other tool and do not inspect local files.", + "Report the number of nodes in structuredContent and whether the tool advertises", + "the resource ui://looperators/agent-loop-p0.html, then stop.", +].join(" "); +const args = [ + "exec", + "--cd", + probeDir, + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--enable", + "enable_mcp_apps", + ...(injectConfig + ? [ + "--config", + `mcp_servers.looperators_agent_loop_p0.command=${JSON.stringify(process.execPath)}`, + "--config", + `mcp_servers.looperators_agent_loop_p0.args=${JSON.stringify([mcpServer])}`, + "--config", + `mcp_servers.looperators_agent_loop_p0.env={ LOOPERATORS_P0_ARTIFACT_DIR = ${JSON.stringify(artifactDir)} }`, + ] + : []), + "--config", + 'model_reasoning_effort="high"', + "--json", + "--output-last-message", + path.join(artifactDir, `codex-${runId}-last-message.txt`), + prompt, +]; + +await mkdir(artifactDir, { recursive: true, mode: 0o700 }); +await atomicWriteJson(path.join(artifactDir, `codex-${runId}-command.json`), { + executable: codexBin, + arguments: args, + cwd: probeDir, + injected_config: injectConfig, +}); +const child = spawn(codexBin, args, { + cwd: probeDir, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], +}); +const stdoutChunks = []; +const stderrChunks = []; +child.stdout.on("data", (chunk) => { + stdoutChunks.push(chunk); + process.stdout.write(chunk); +}); +child.stderr.on("data", (chunk) => { + stderrChunks.push(chunk); + process.stderr.write(chunk); +}); +const exitCode = await new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", resolve); +}); +await writeFile( + path.join(artifactDir, `codex-${runId}.jsonl`), + Buffer.concat(stdoutChunks), + { mode: 0o600 }, +); +await writeFile( + path.join(artifactDir, `codex-${runId}.stderr.txt`), + Buffer.concat(stderrChunks), + { mode: 0o600 }, +); +await atomicWriteJson(path.join(artifactDir, `codex-${runId}-result.json`), { + exit_code: exitCode, + completed_at: new Date().toISOString(), +}); +process.exitCode = exitCode ?? 1; diff --git a/plugins/looperators-agent-loop-p0/scripts/sidecar-server.mjs b/plugins/looperators-agent-loop-p0/scripts/sidecar-server.mjs new file mode 100644 index 0000000..d82cc97 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/scripts/sidecar-server.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env node + +import { watch } from "node:fs"; +import { mkdir, readFile, readdir } from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import process from "node:process"; +import { safeTokenEqual } from "../lib/event-utils.mjs"; + +function argument(name, fallback = null) { + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : fallback; +} + +const host = argument("host", "127.0.0.1"); +const port = Number(argument("port", "0")); +const artifactDir = path.resolve( + argument( + "artifact-dir", + process.env.LOOPERATORS_P0_ARTIFACT_DIR ?? + path.join(process.cwd(), "output", "looperators-agent-loop-p0"), + ), +); +const token = + process.env.LOOPERATORS_P0_SIDECAR_TOKEN ?? argument("token", ""); + +if (host !== "127.0.0.1") { + throw new Error("The P0 sidecar only permits --host 127.0.0.1"); +} +if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error("--port must be an integer from 0 through 65535"); +} +if (token.length < 16) { + throw new Error( + "Set LOOPERATORS_P0_SIDECAR_TOKEN to an explicit or random token of at least 16 characters", + ); +} + +const html = ` + + + + + looperators Agent Loop sidecar + + + +

      looperators Agent Loop

      connecting…
      +
      + + + +
      #eventturnagent/tool
      + + +`; + +const clients = new Set(); +let watcher; +let debounceTimer; + +async function loadJson(fileName, fallback) { + try { + return JSON.parse(await readFile(path.join(artifactDir, fileName), "utf8")); + } catch (error) { + if (error?.code === "ENOENT") { + return fallback; + } + throw error; + } +} + +async function loadEvents() { + try { + const body = await readFile( + path.join(artifactDir, "normalized-events.jsonl"), + "utf8", + ); + return body + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + let names = []; + try { + names = await readdir(path.join(artifactDir, "hook-events")); + } catch (nestedError) { + if (nestedError?.code === "ENOENT") { + return []; + } + throw nestedError; + } + return names + .filter((name) => name.endsWith(".json")) + .sort() + .slice(-20) + .map((name, index) => ({ + sequence: index + 1, + event: "un-normalized delivery", + turn_id: null, + agent_id: null, + evidence_file: name, + })); + } +} + +async function snapshot() { + return { + generated_at: new Date().toISOString(), + graph: await loadJson("normalized-graph.json", { nodes: [], edges: [] }), + events: await loadEvents(), + }; +} + +async function broadcast() { + const payload = `event: snapshot\ndata: ${JSON.stringify(await snapshot())}\n\n`; + for (const response of clients) { + response.write(payload); + } +} + +function scheduleBroadcast() { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + broadcast().catch(() => {}); + }, 40); +} + +function parseCookies(header) { + return Object.fromEntries( + String(header ?? "") + .split(";") + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const separator = part.indexOf("="); + return [ + part.slice(0, separator), + decodeURIComponent(part.slice(separator + 1)), + ]; + }), + ); +} + +function authenticated(request) { + const bearer = request.headers.authorization?.startsWith("Bearer ") + ? request.headers.authorization.slice("Bearer ".length) + : ""; + const cookie = parseCookies(request.headers.cookie).looperators_p0; + return safeTokenEqual(bearer || cookie || "", token); +} + +const server = http.createServer(async (request, response) => { + const url = new URL(request.url ?? "/", `http://${host}`); + const queryToken = url.searchParams.get("token") ?? ""; + if (queryToken && safeTokenEqual(queryToken, token)) { + response.writeHead(303, { + Location: url.pathname, + "Set-Cookie": `looperators_p0=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`, + "Cache-Control": "no-store", + }); + response.end(); + return; + } + if (!authenticated(request)) { + response.writeHead(401, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end("Unauthorized\n"); + return; + } + if (url.pathname === "/") { + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + }); + response.end(html); + return; + } + if (url.pathname === "/snapshot") { + response.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(`${JSON.stringify(await snapshot())}\n`); + return; + } + if (url.pathname === "/events") { + response.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-store", + Connection: "keep-alive", + }); + response.write(`event: snapshot\ndata: ${JSON.stringify(await snapshot())}\n\n`); + clients.add(response); + request.on("close", () => clients.delete(response)); + return; + } + response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + response.end("Not found\n"); +}); + +await mkdir(artifactDir, { recursive: true, mode: 0o700 }); +watcher = watch(artifactDir, { recursive: true }, scheduleBroadcast); +server.listen(port, host, () => { + const address = server.address(); + process.stdout.write( + `${JSON.stringify({ + status: "ready", + host, + port: address.port, + base_url: `http://${host}:${address.port}/`, + token_required: true, + artifact_dir: artifactDir, + })}\n`, + ); +}); + +function shutdown() { + watcher?.close(); + for (const response of clients) { + response.end(); + } + server.close(() => process.exit(0)); +} + +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/plugins/looperators-agent-loop-p0/skills/agent-loop-visualization/SKILL.md b/plugins/looperators-agent-loop-p0/skills/agent-loop-visualization/SKILL.md new file mode 100644 index 0000000..1f1446a --- /dev/null +++ b/plugins/looperators-agent-loop-p0/skills/agent-loop-visualization/SKILL.md @@ -0,0 +1,43 @@ +--- +name: agent-loop-visualization +description: Normalize looperators P0 Codex hook events and render a small thread-scoped Agent Loop graph with the native codex-inline-vis directive. Use only when the user asks for the looperators Agent Loop P0 visualization or compatibility probe. +--- + +# looperators Agent Loop visualization + +This skill is an explicit P0 compatibility probe. Do not treat it as a durable +or implicit orchestration protocol. + +1. Require the exact experiment artifact directory from the user's request. + Resolve it to an absolute path and verify that it is a directory. Do not + search other tasks, guess a repository, or infer state by tailing a Codex + transcript. +2. If `/hook-events/` exists, run: + + `node "${PLUGIN_ROOT}/scripts/normalize-events.mjs" --artifact-dir ""` + + If it does not exist, proceed only when `normalized-graph.json` or + `normalized-events.jsonl` already exists. Otherwise report the missing input + and stop. Never generate sample nodes or substitute another artifact set. +3. Use only the thread-scoped visualization directory exposed as a writable + root for the current task. Verify that it is an existing directory. Do not + guess a `CODEX_HOME` path, create a global fallback, or write through a + symlink. +4. Generate the deterministic fragment from the normalized artifacts: + + `node "${PLUGIN_ROOT}/scripts/generate-inline-visualization.mjs" --artifact-dir "" --output "/looperators-agent-loop.html"` + + The generator rejects output symlinks, writes atomically with mode `0600`, + includes only observed data, and rejects fragments at or above 2 MiB. +5. Read the generated file back and verify that it is a regular fragment named + `looperators-agent-loop.html`, contains no full HTML shell, and reports no + source warning unless the input really is partial. +6. On success, emit only this directive, on its own line and outside a code + fence: + + `::codex-inline-vis{file="looperators-agent-loop.html"}` + +If the artifact directory, normalized inputs, or writable thread directory is +unavailable, report that exact limitation and do not emit the directive. +Presentation controls and `sendFollowUpMessage` stay inside the native card; +they do not make live network requests or arbitrary tool calls. diff --git a/plugins/looperators-agent-loop-p0/tests/hooks-and-normalization.test.mjs b/plugins/looperators-agent-loop-p0/tests/hooks-and-normalization.test.mjs new file mode 100644 index 0000000..6cc7141 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/tests/hooks-and-normalization.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { normalizeArtifactDirectory } from "../scripts/normalize-events.mjs"; + +const pluginRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const collector = path.join(pluginRoot, "hooks", "collect-event.mjs"); + +function runCollector(artifactDir, input, extraArgs = []) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [collector, "--lane", "primary", ...extraArgs], { + env: { + ...process.env, + LOOPERATORS_P0_ARTIFACT_DIR: artifactDir, + LOOPERATORS_P0_CONTINUATION_MODE: "lap", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`collector exited ${code}: ${stderr}`)); + return; + } + resolve(stdout ? JSON.parse(stdout) : null); + }); + child.stdin.end(JSON.stringify(input)); + }); +} + +test("collector is idempotent and caps Stop continuation at one lap", async () => { + const artifactDir = await mkdtemp( + path.join(os.tmpdir(), "looperators-p0-hooks-"), + ); + const base = { + session_id: "session-test", + turn_id: "turn-test", + hook_event_name: "Stop", + transcript_path: "/tmp/transcript.jsonl", + stop_hook_active: false, + last_assistant_message: "synthetic private-looking text", + }; + const first = await runCollector(artifactDir, base); + const duplicate = await runCollector(artifactDir, base); + const continuation = await runCollector(artifactDir, { + ...base, + stop_hook_active: true, + last_assistant_message: "P0_LAP_CONTINUED", + }); + assert.equal(first.decision, "block"); + assert.equal(duplicate.continue, true); + assert.equal(continuation.continue, true); + + const result = await normalizeArtifactDirectory(artifactDir); + assert.equal(result.events.length, 2, "duplicate delivery is not written twice"); + assert.equal(result.summary.continuation_pairs[ + "session-test:turn-test:Stop:-" + ].initial, 1); + assert.equal(result.summary.continuation_pairs[ + "session-test:turn-test:Stop:-" + ].continuation, 1); + + const raw = JSON.parse( + await readFile( + path.join(artifactDir, result.events[0].evidence_file), + "utf8", + ), + ); + assert.equal(raw.input.last_assistant_message._redacted, "last_assistant_message"); + assert.equal(raw.input.last_assistant_message.bytes > 0, true); +}); + +test("normalizer reports overlap for parallel matching handlers", async () => { + const artifactDir = await mkdtemp( + path.join(os.tmpdir(), "looperators-p0-concurrency-"), + ); + const input = { + session_id: "session-test", + turn_id: "turn-test", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: "tool-test", + tool_input: { command: "printf synthetic" }, + }; + const runLane = (lane) => + new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [collector, "--lane", lane, "--delay-ms", "100"], + { + env: { + ...process.env, + LOOPERATORS_P0_ARTIFACT_DIR: artifactDir, + }, + stdio: ["pipe", "ignore", "pipe"], + }, + ); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => + code === 0 + ? resolve() + : reject(new Error(`collector exited ${code}: ${stderr}`)), + ); + child.stdin.end(JSON.stringify(input)); + }); + await Promise.all([runLane("parallel-a"), runLane("parallel-b")]); + const result = await normalizeArtifactDirectory(artifactDir); + assert.equal(result.summary.concurrency_probes.length, 1); + assert.equal(result.summary.concurrency_probes[0].overlaps, true); +}); diff --git a/plugins/looperators-agent-loop-p0/tests/inline-browser-contract.test.mjs b/plugins/looperators-agent-loop-p0/tests/inline-browser-contract.test.mjs new file mode 100644 index 0000000..76c8476 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/tests/inline-browser-contract.test.mjs @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { + access, + mkdir, + mkdtemp, + readFile, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import { generateInlineVisualization } from "../scripts/generate-inline-visualization.mjs"; +import { createRecordedArtifactFixture } from "./recorded-artifact-fixture.mjs"; + +const execFileAsync = promisify(execFile); + +async function loadPlaywright() { + const moduleDirectory = process.env.LOOPERATORS_P0_PLAYWRIGHT_MODULE_DIR; + if (moduleDirectory) { + return import( + pathToFileURL(path.join(moduleDirectory, "playwright", "index.mjs")).href + ); + } + return import("playwright"); +} + +async function findFragmentFrame(page) { + for (let attempt = 0; attempt < 100; attempt += 1) { + for (const frame of page.frames()) { + if ( + frame !== page.mainFrame() && + (await frame.locator("#looperators-agent-loop-p06").count()) === 1 + ) { + return frame; + } + } + await page.waitForTimeout(25); + } + throw new Error("Official preview did not expose the inline fragment frame"); +} + +test("official preview executes selection, details, zoom, filter, follow-up, and narrow layout", async (t) => { + let playwright; + try { + playwright = await loadPlaywright(); + } catch (error) { + t.skip(`Playwright is unavailable: ${error.message}`); + return; + } + const renderScript = process.env.LOOPERATORS_P0_RENDER_PY; + if (!renderScript) { + t.skip("LOOPERATORS_P0_RENDER_PY is not set"); + return; + } + await access(renderScript); + + const temporary = await mkdtemp( + path.join(os.tmpdir(), "looperators-p0-browser-contract-"), + ); + const recordedArtifactDir = await createRecordedArtifactFixture(); + const fragmentPath = path.join(temporary, "looperators-agent-loop.html"); + const previewPath = path.join(temporary, "looperators-agent-loop-preview.html"); + await generateInlineVisualization({ + artifactDir: recordedArtifactDir, + outputPath: fragmentPath, + }); + await execFileAsync(process.env.PYTHON ?? "python3", [ + renderScript, + fragmentPath, + previewPath, + ]); + + const browser = await playwright.chromium.launch({ headless: true }); + t.after(() => browser.close()); + const context = await browser.newContext({ + viewport: { width: 736, height: 1000 }, + }); + await context.addInitScript(() => { + window.__looperatorsFollowUps = []; + window.openai = { + sendFollowUpMessage: async (payload) => { + window.__looperatorsFollowUps.push(payload); + return { accepted: true }; + }, + }; + }); + const page = await context.newPage(); + await page.goto(pathToFileURL(previewPath).href); + const frame = await findFragmentFrame(page); + const root = frame.locator("#looperators-agent-loop-p06"); + await root.waitFor(); + + const summary = frame.locator("#loop-acceptance-summary"); + assert.match(await summary.inputValue(), /JS ready=pass/u); + const followUp = frame.locator("#loop-follow-up"); + assert.equal(await followUp.isDisabled(), true); + + const rootButton = frame.locator('[data-kind="root"]'); + await frame.locator("#loop-zoom-reset").focus(); + await page.keyboard.press("Tab"); + assert.equal( + await frame.evaluate( + () => document.activeElement?.getAttribute("data-kind"), + ), + "root", + ); + await page.keyboard.press("Enter"); + assert.equal(await rootButton.getAttribute("aria-pressed"), "true"); + assert.match( + await frame.locator("#loop-detail-id").textContent(), + /session:019f9a38-9f26-71d0-b908-df3626a5d40c/u, + ); + assert.match( + await frame.locator("#loop-timeline").textContent(), + /SessionStart[\s\S]*Stop/u, + ); + + const subagents = frame.locator('[data-kind="subagent"]'); + await page.keyboard.press("Tab"); + assert.equal( + await frame.evaluate( + () => document.activeElement?.getAttribute("data-node-id"), + ), + "agent:019f9a39-0485-75f0-b6cf-8beba2cc59cd", + ); + await page.keyboard.press("Enter"); + await page.keyboard.press("Tab"); + assert.equal( + await frame.evaluate( + () => document.activeElement?.getAttribute("data-node-id"), + ), + "agent:019f9a39-1681-7b90-98e1-55ac153248dd", + ); + await page.keyboard.press("Space"); + assert.equal(await subagents.nth(1).getAttribute("aria-pressed"), "true"); + assert.match( + await frame.locator("#loop-detail-id").textContent(), + /agent:019f9a39-1681-7b90-98e1-55ac153248dd/u, + ); + assert.match( + await frame.locator("#loop-detail-counts").textContent(), + /3 semantic events · initial stops 1 · continuations 1/u, + ); + + await frame.locator("#loop-zoom-in").click(); + assert.equal(await frame.locator("#loop-zoom-value").textContent(), "110%"); + await frame.locator("#loop-zoom-out").click(); + await frame.locator("#loop-zoom-out").click(); + assert.equal(await frame.locator("#loop-zoom-value").textContent(), "90%"); + await frame.locator("#loop-zoom-reset").click(); + assert.equal(await frame.locator("#loop-zoom-value").textContent(), "100%"); + + const filter = frame.locator("#loop-filter"); + await filter.selectOption("subagent"); + assert.equal(await rootButton.isHidden(), true); + assert.equal(await subagents.nth(0).isVisible(), true); + await filter.selectOption("root"); + assert.equal(await rootButton.isVisible(), true); + assert.equal(await subagents.nth(0).isHidden(), true); + await filter.selectOption("all"); + assert.equal(await rootButton.isVisible(), true); + assert.equal(await subagents.nth(1).isVisible(), true); + + await followUp.click(); + await frame + .locator("#loop-follow-up-status") + .filter({ hasText: "Follow-up requested." }) + .waitFor(); + const followUps = await frame.evaluate(() => window.__looperatorsFollowUps); + assert.equal(followUps.length, 1); + assert.match( + followUps[0].prompt, + /stable identifier "agent:019f9a39-1681-7b90-98e1-55ac153248dd"/u, + ); + assert.match(followUps[0].prompt, /separating recorded facts from inference/u); + assert.equal(followUps[0].title, "Explain loop node 019f…3248dd"); + + const finalSummary = await summary.inputValue(); + for (const check of [ + "JS ready=pass", + "node selected=pass", + "details updated=pass", + "zoom changed/reset=pass", + "filter changed=pass", + "follow-up requested=pass", + ]) { + assert.match(finalSummary, new RegExp(check.replace("/", "\\/"), "u")); + } + + await page.setViewportSize({ width: 320, height: 1600 }); + await page.waitForTimeout(50); + const layout = await frame.evaluate(() => { + const visualization = document.getElementById( + "looperators-agent-loop-p06", + ); + const rootRect = visualization.getBoundingClientRect(); + const visibleControls = [...visualization.querySelectorAll("button, select, textarea")] + .filter((element) => { + const style = getComputedStyle(element); + return !element.hidden && style.display !== "none"; + }) + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + label: + element.getAttribute("aria-label") ?? + element.textContent.trim().slice(0, 40), + left: rect.left, + right: rect.right, + }; + }); + return { + documentClientWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + rootClientWidth: visualization.clientWidth, + rootScrollWidth: visualization.scrollWidth, + rootLeft: rootRect.left, + rootRight: rootRect.right, + stageHeight: document.getElementById("loop-stage").getBoundingClientRect() + .height, + stageViewportHeight: document + .getElementById("loop-stage-viewport") + .getBoundingClientRect().height, + visibleControls, + }; + }); + assert.equal( + layout.documentScrollWidth <= layout.documentClientWidth + 1, + true, + JSON.stringify(layout), + ); + assert.equal( + layout.rootScrollWidth <= layout.rootClientWidth + 1, + true, + JSON.stringify(layout), + ); + assert.equal( + layout.stageViewportHeight >= layout.stageHeight - 1, + true, + JSON.stringify(layout), + ); + for (const control of layout.visibleControls) { + assert.equal( + control.left >= layout.rootLeft - 1 && + control.right <= layout.rootRight + 1, + true, + JSON.stringify({ control, layout }), + ); + } + + const evidenceDir = process.env.LOOPERATORS_P0_BROWSER_EVIDENCE_DIR; + if (evidenceDir) { + await mkdir(evidenceDir, { recursive: true }); + await root.screenshot({ + path: path.join(evidenceDir, "headless-inline-320.png"), + }); + await page.setViewportSize({ width: 736, height: 1000 }); + await page.waitForTimeout(50); + await root.screenshot({ + path: path.join(evidenceDir, "headless-inline-736.png"), + }); + await writeFile( + path.join(evidenceDir, "headless-browser-contract.json"), + `${JSON.stringify( + { + officialRenderScript: renderScript, + fragmentBytes: (await readFile(fragmentPath)).byteLength, + nodeCount: await frame.locator("[data-node-id]").count(), + finalSummary, + followUps, + narrowLayout: layout, + }, + null, + 2, + )}\n`, + "utf8", + ); + } +}); diff --git a/plugins/looperators-agent-loop-p0/tests/inline-visualization.test.mjs b/plugins/looperators-agent-loop-p0/tests/inline-visualization.test.mjs new file mode 100644 index 0000000..0173336 --- /dev/null +++ b/plugins/looperators-agent-loop-p0/tests/inline-visualization.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { + lstat, + mkdtemp, + mkdir, + readFile, + symlink, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + buildLoopViewModel, + generateInlineVisualization, + loadNormalizedArtifactData, + renderInlineVisualization, +} from "../scripts/generate-inline-visualization.mjs"; +import { createRecordedArtifactFixture } from "./recorded-artifact-fixture.mjs"; + +test("generator projects the recorded three agents and two spawned edges", async () => { + const recordedArtifactDir = await createRecordedArtifactFixture(); + const source = await loadNormalizedArtifactData(recordedArtifactDir); + const model = buildLoopViewModel(source); + + assert.deepEqual(model.counts, { + deliveries: 22, + semanticEvents: 18, + graphNodes: 7, + graphEdges: 6, + agentNodes: 3, + agentEdges: 2, + }); + assert.deepEqual( + model.nodes.map((node) => node.id), + [ + "session:019f9a38-9f26-71d0-b908-df3626a5d40c", + "agent:019f9a39-0485-75f0-b6cf-8beba2cc59cd", + "agent:019f9a39-1681-7b90-98e1-55ac153248dd", + ], + ); + assert.equal(model.edges.every((edge) => edge.kind === "spawned"), true); + assert.deepEqual(model.semanticEventCounts, { + PostToolUse: 4, + PreToolUse: 4, + SessionStart: 1, + Stop: 2, + SubagentStart: 2, + SubagentStop: 4, + UserPromptSubmit: 1, + }); + for (const node of model.nodes) { + assert.equal(node.status, "Continuation completed"); + assert.equal(node.initialStops, 1); + assert.equal(node.continuations, 1); + } + assert.equal(model.warnings.length, 0); +}); + +test("generated output is a deterministic, network-free inline fragment with real data", async () => { + const recordedArtifactDir = await createRecordedArtifactFixture(); + const source = await loadNormalizedArtifactData(recordedArtifactDir); + const model = buildLoopViewModel(source); + const first = renderInlineVisualization(model); + const second = renderInlineVisualization(model); + + assert.equal(first, second); + assert.equal(Buffer.byteLength(first) < 2 * 1024 * 1024, true); + assert.doesNotMatch(first, /)|)|)/iu); + assert.doesNotMatch(first, /\bfetch\s*\(|XMLHttpRequest|WebSocket/); + assert.doesNotMatch(first, /expandable|Static thread-scoped|display-only/u); + assert.match( + first, + /Interactive snapshot · local controls · no live network\/tool calls/u, + ); + assert.match(first, /window\.openai\.sendFollowUpMessage/u); + assert.match(first, /session:019f9a38-9f26-71d0-b908-df3626a5d40c/u); + assert.match(first, /agent:019f9a39-0485-75f0-b6cf-8beba2cc59cd/u); + assert.match(first, /agent:019f9a39-1681-7b90-98e1-55ac153248dd/u); + assert.match(first, /"PreToolUse":4/u); + assert.match(first, /"SubagentStop":4/u); + assert.match(first, /