From 896f3c7f3bca5502ccb38c759ef356d799c0a60d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:01:42 -0500 Subject: [PATCH] Add personal Muse operator setup and composer controls (#16450) Add Muse invitation, connection readiness and scoped operator controls. Keep connection state tied to the current company, person, agent and binding. Surface failed reads and invalidate stale cleanup observations; require current authority and exact readback. Preserve Dot/Grok setup, source-trust limits and existing design tokens. Carry actual merged backend e6dcc6b0959bdfc3bc93239c3d95e14c9fc8d457 through a normal forward merge. Preserve its project-write authority fix, existing migration bytes and upstream idle-serving behavior; no new UI behavior is authored by this source carry. Current-head CI and substantive review remain merge gates; final canonical and deployed-provider qualification remain separate. Refs #16450. Co-Authored-By: Paperclip --- doc/SPEC-implementation.md | 14 + doc/SPEC.md | 8 + doc/design/COMPONENT-INVENTORY.md | 18 + doc/evals.md | 7 + doc/muse-personal-agent.md | 117 +++++ ...26-10-09-muse-on-shared-external-runner.md | 12 +- .../2026-10-13-muse-production-readiness.md | 450 ++++++++++++++++++ scripts/storybook-muse-catalog.test.mjs | 68 +++ .../codex-local/config-fields.test.tsx | 9 + ui/src/adapters/codex-local/config-fields.tsx | 20 +- ui/src/adapters/types.ts | 2 + ui/src/api/client.test.ts | 20 + ui/src/api/museInvitations.ts | 23 + ui/src/components/AgentConfigForm.tsx | 42 +- ui/src/components/AgentSetupPrompt.tsx | 10 +- .../components/MuseRunnerConnection.test.tsx | 331 +++++++++++++ ui/src/components/MuseRunnerConnection.tsx | 152 ++++++ ui/src/components/TaskChatThread.test.tsx | 71 +++ ui/src/components/TaskChatThread.tsx | 1 + .../new-agent/ExternalAgentInviteContent.tsx | 130 ++++- .../ExternalAgentInviteDialog.muse.test.tsx | 258 ++++++++++ .../new-agent/ExternalAgentInviteDialog.tsx | 115 ++++- ui/src/components/new-agent/NewAgentSetup.tsx | 3 + .../ComposerRunSettingsPicker.test.tsx | 50 ++ .../task-chat/ComposerRunSettingsPicker.tsx | 10 +- .../task-chat/TaskChatRunnerTurn.test.tsx | 27 ++ .../task-chat/TaskChatRunnerTurn.tsx | 17 +- .../task-chat/composer-run-settings.ts | 3 +- ui/src/hooks/useMuseConnection.ts | 91 ++++ ui/src/lib/muse-invitation-draft.test.ts | 21 + ui/src/lib/muse-invitation-draft.ts | 33 ++ ui/src/lib/new-agent-adapters.ts | 4 +- ui/src/lib/utils.ts | 11 + ui/src/pages/AgentDetail.accounting.test.tsx | 73 +++ ui/src/pages/AgentDetail.tsx | 78 +-- ui/src/pages/AgentOverview.test.tsx | 18 + ui/src/pages/CompanyImport.tsx | 27 +- ui/src/pages/Costs.test.tsx | 30 ++ ui/src/pages/Costs.tsx | 16 + ui/src/pages/DesignGuide.tsx | 8 +- .../InstanceExperimentalSettings.test.tsx | 11 + ui/src/pages/InstanceExperimentalSettings.tsx | 12 + .../external-agent-invite/muse.stories.tsx | 64 +++ .../ExternalAgentInvitePreview.tsx | 54 ++- .../external-agent-invite/muse-fixtures.ts | 47 ++ 45 files changed, 2470 insertions(+), 116 deletions(-) create mode 100644 doc/muse-personal-agent.md create mode 100644 doc/plans/2026-10-13-muse-production-readiness.md create mode 100644 scripts/storybook-muse-catalog.test.mjs create mode 100644 ui/src/api/museInvitations.ts create mode 100644 ui/src/components/MuseRunnerConnection.test.tsx create mode 100644 ui/src/components/MuseRunnerConnection.tsx create mode 100644 ui/src/components/new-agent/ExternalAgentInviteDialog.muse.test.tsx create mode 100644 ui/src/hooks/useMuseConnection.ts create mode 100644 ui/src/lib/muse-invitation-draft.test.ts create mode 100644 ui/src/lib/muse-invitation-draft.ts create mode 100644 ui/src/pages/AgentDetail.accounting.test.tsx create mode 100644 ui/storybook/stories/agents/external-agent-invite/muse.stories.tsx create mode 100644 ui/storybook/support/external-agent-invite/muse-fixtures.ts diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index edcdb86eb8c..af37a22478d 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1400,6 +1400,20 @@ as execution. Codex CLI defaults permit workspace writes and network access for Paperclip coordination without disabling its sandbox; explicit operator restrictions and execution-target network denials remain effective. +### Experimental personal Muse Runner provider + +The `paperclip_runner` adapter can select `provider: "muse"` behind the default-off +Muse and native Runner gates. This connects personal Meta Muse through a +company- and named-agent-bound receiver, distinct from human-authorized MCP. +Pairing, detector contact, and a verified background reply are separate setup +milestones. Idle first-party collaboration retains both agent and authorizing +user permissions; task execution additionally requires the admitted native run. +Only one assignment may execute per connection, and unresolved remote work or +native effects block overlap across cancellation, reconnect, and provider changes. +Remote interruption and external usage remain unconfirmed or unavailable unless +supported by evidence. Release requires the live journeys and bounded cadence +qualification in [Personal Muse receiver](muse-personal-agent.md). + ## 11.2 Process Adapter Config shape: diff --git a/doc/SPEC.md b/doc/SPEC.md index 302ce8c641a..14045a40a1d 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -749,6 +749,14 @@ while preserving agent pairing, normal run admission and task authority. Personal grants cannot authorize Runner operations. See [OpenAI Dot Runner](openai-dot-runner.md) for its supported release boundary. +Personal Meta Muse is another experimental named Runner agent. Its managed +receiver uses short-lived pairing and rotating private credentials; its human +authorizer remains distinct from the agent identity. Paperclip owns admission, +task authority, and native finalization. Detector health alone does not prove +execution, and uncertain remote effects prevent overlapping dispatch. See +[Personal Muse receiver](muse-personal-agent.md) for setup, stopping limits, and +the required live qualification before release. + ### Experimental connection routing A virtual AI connection can rotate new task/agent allocations through an diff --git a/doc/design/COMPONENT-INVENTORY.md b/doc/design/COMPONENT-INVENTORY.md index a92490a6ed9..668b28e2838 100644 --- a/doc/design/COMPONENT-INVENTORY.md +++ b/doc/design/COMPONENT-INVENTORY.md @@ -464,3 +464,21 @@ Independently addressable examples live under `Connections/In-task connections` The footer also appears on `/design-guide`. All stories use offline fixtures; they do not create provider sessions. The viewer fixture intentionally scales a fixed desktop viewport, preserving the current provider embed's sizing limitation. + +## Personal Muse connection — 2026-10-10 + +The shared `new-agent/ExternalAgentInviteDialog.tsx` and +`ExternalAgentInviteContent.tsx` now support personal Muse alongside Dot. They +reuse the agent picker, name and role form, setup instruction, and single-row +footer. The Muse journey distinguishes pairing, detector contact, an independent +background reply, and the existing agent lifecycle gate. Company/operator drafts +contain only the name, role, and agent reference; pairing tickets stay transient. + +`MuseRunnerConnection.tsx` is the agent settings surface for connection health, +verification, repair, disconnection, and attestation of a specific stop boundary. +Its examples appear in the Design Guide. The [guided Muse stories](../../ui/storybook/stories/agents/external-agent-invite/muse.stories.tsx) +use the real invitation and connection details components with offline fixtures, +including setup milestones, recovery, disabled cleanup, and the exact stop boundary. +The [Storybook catalog](../../maps/storybook-map/INDEX.md) registers these under +agent hiring and lifecycle. The examples remain unreviewed for Vault/provider parity +and do not establish provider connectivity or remote stopping. diff --git a/doc/evals.md b/doc/evals.md index 52588da2c49..4104338f0fd 100644 --- a/doc/evals.md +++ b/doc/evals.md @@ -10,6 +10,13 @@ The explicit-only [live provider connection suite](../tests/runner-e2e/PROVIDER- is a Product E2E workflow for fresh subscription/API-key/gateway connections, with attended login and independent artifact checks against local or staging targets. +The bounded [personal Muse receiver probe](muse-personal-agent.md#bounded-24-hour-probe) +uses ordinary task APIs and retained native receipts to measure unattended +receiver behavior. It is a separate manual qualification probe, not a registered +Product E2E campaign. Synthetic receiver tests cannot qualify personal Muse; +real setup, clarification and fresh-conversation collaboration remain separate +release gates. + Paperclip has two live eval families with different questions, owners, and evidence. Choose the family before selecting a model, profile, or case. diff --git a/doc/muse-personal-agent.md b/doc/muse-personal-agent.md new file mode 100644 index 00000000000..8143614643b --- /dev/null +++ b/doc/muse-personal-agent.md @@ -0,0 +1,117 @@ +# Personal Muse receiver + +This experimental integration connects the existing personal Meta Muse to a named Paperclip Runner agent. It is separate from Muse Code. The provider is disabled by default; implementation and synthetic tests do not qualify the personal service. See the [architecture and release gates](plans/2026-10-10-muse-runner-architecture.md). + +## Current compatibility and release gates + +October 13, 2026 read-only probes of personal Muse reconfirmed a five-second +minimum for managed hooks, no secrets in hook scripts, and no script arguments. +The approved asset6 detector uses a no-argument script and a nonsecret binding +header. Its public response contains only an opaque wake reference; an observed +check does not authenticate Muse or establish readiness. + +Production credentials belong in Muse's Secure Vault/authd. The asset6 setup uses +public-client OAuth, dynamic client registration, S256 PKCE and automatic refresh. +Muse's bundled surrogate helper supplies worker HTTP authentication without +reading or persisting raw credentials. The October 13 provider test preserved the pairing query in authd's Connect flow and completed scoped authenticated reads and automatic refresh. Keep the provider disabled +until the current OAuth setup, real work journeys, installed Cloud path and new +24-hour qualification pass. Local and scripted tests are not live qualification. + +## Connection and work + +An operator uses the existing external-agent invitation, selects **Muse — Personal agent**, and supplies the agent's name and role. Paperclip creates or resumes the operator's unfinished hire, respecting approval requirements. Copy the transient setup instruction into the current Muse conversation. Muse downloads versioned, digest-pinned assets and requests a Vault-backed Paperclip connection. The user follows Muse’s Connect link; a ten-minute single-use pairing ticket binds the OAuth grant to the exact company, agent and authorizing operator. No Muse cookie export or manually copied long-lived key is part of setup. + +Asset6 setup records the operator’s standing authorization to receive, validate and autonomously accept ordinary tasks for the exact company, agent and binding. The client installs that nonsecret scope with the verified instructions in a binding-specific `~/workspace/skills/paperclip-muse-/SKILL.md`, discoverable in later conversations. A valid in-scope assignment does not require another acceptance approval. Task text cannot expand this delegation; Muse’s tool, site and safety permissions and Paperclip approval gates still apply. Native acceptance must be observed before any task effect. Each wake carries the workflow forward; canonical answers resume the same admitted work through result publication and native completion. Assets1–5 remain available with their original bytes. + +Approve Paperclip's hostname through Muse's permission UI. Read the permission scope shown by Muse: its standing approval may cover a parent domain and all its subdomains. Paperclip cannot grant this permission on Muse's behalf. Setup distinguishes paired, receiver detected, and an authenticated background reply; the detector alone cannot finish setup. Hiring approval and native lifecycle readiness remain additional gates. + +Muse’s Secure Vault/authd owns access and refresh credentials. The Python client stores only nonsecret connection metadata and stable operation receipts. The no-argument Bash detector reads the public receiver profile and produces opaque wake references. Empty checks and failures must not wake the model. Access expires after fifteen minutes, refresh rotates automatically, and thirty days of authenticated inactivity requires reconnecting. Signal checks do not extend credential activity. + +Assigned work claims an existing native run. Submitted progress, questions, task documents and completion use Paperclip's native authority and receipts. Paperclip displays reported progress; it cannot inspect every private Muse conversation/tool call. When idle, Muse can read permitted company work, create tasks and comment as the named agent. Other Paperclip execution operations require the current assignment. + +Native Muse document writes retain source trust. Higher-trust Muse readers cannot receive quarantined document bodies or revision summaries through direct reads or accepted-plan context. A later rewrite or promotion does not promote older revisions; explicit promotion applies only to the exact current revision. Historical content whose provenance cannot be verified is withheld with an explicit reason. + +Stop revokes run authority immediately. Remote stopping is best effort, and unknown native effects or unconfirmed claimed work prevent overlapping dispatch. Reconnect does not clear those barriers. Disconnect revokes all OAuth authority immediately; a subsequent public410 disables the detector. Authenticated post-disconnect cleanup is unavailable with Muse’s supported single Vault credential, so cleanup remains unconfirmed unless independently observed. Worker-reported quiescence, operator attestation, detector removal and authoritative native effects are separate evidence. Unknown Muse usage/cost remains unavailable. + +## Reproducible verification + +### OAuth exchange diagnostics + +The agent's scoped Audit view records static `muse.oauth_code_exchange_rejected_*` +actions for a rejected exchange attributable to a persisted Muse code, grant and +binding. Each category is recorded once per binding generation, with the first +recorded attempt's handler-entry, decision, authorization and code-deadline times. +The `failedChecks` list contains observed failed predicates; overlapping checks +do not establish one exclusive cause. Public OAuth errors remain generic. A +successful exchange records `muse.oauth_code_exchange_succeeded` atomically with +code consumption and credential issuance. + +Pairing precedes token issuance. An expired credential status without an observed +refresh deadline does not by itself prove revocation. Authorization codes expire +after one minute, including callback handling and admission-lock wait; the setup +ticket's ten-minute deadline is separate. Unknown codes, including expired rows +already pruned during registration, and missing historical binding linkage cannot +safely produce company activity. +Diagnostic storage failure also leaves no record. Absence of an event therefore +does not prove that no token request arrived. Activity contains no OAuth code, +verifier, token, request/client identifier, callback URL, header or secret hash. + +Run the targeted protocol, Runner and UI tests before repository-wide checks. The receiver/client must remain behind the experimental feature until these real journeys are measured: + +1. Pair from a current personal Muse chat, approve the hostname, then wait for an independent background reply. +2. Assign a real research task; verify a useful saved task document and native task finalization. +3. Have Muse ask a structured question, answer it in Paperclip, and verify continuation on the same authorized run. +4. Assign follow-up work and verify proactive task reading/commenting from a fresh Muse conversation without an incoming task. +5. Exercise reconnect, cancellation, stale contact and unconfirmed stop through the actual UI. + +Preserve source/Runner revisions, exact environment, evidence mode, outcome receipts, interventions and latency. Record private provider behavior only when actually observed. Authenticated transport alone does not prove unseen private tool activity. + +## Observed native workflow + +On October 10, 2026, the scripted HTTP peer completed two ordinary native Muse +runs against integration revision `01ecffd32ab73d763c05ee0209e64d86b8ebdea6` +with smoke script revision `eb371d06673764fbdde7cc848ccf336bbd547c3a`. +The first run claimed its assignment, saved a document, received a structured +question answer through Paperclip, persisted and consumed the answer, and +finalized through the native Runner. Reopening the task produced one follow-up +run that read the new comment, revised the document and finalized. The browser +showed the answered question, task completion and document revision two. + +This was a synthetic authenticated receiver, with no personal Muse model or +managed hook installed. Both runs committed native finalization; cleanup revoked +the test connection and left no uncertain work. It validates the Paperclip +workflow, not the personal Muse service or its five-second polling behavior. + +## Bounded 24-hour probe + +`scripts/smoke/muse-qualification.ts` is an operator-run receiver reliability probe using normal task APIs. It is not a registered Product E2E campaign and cannot substitute for the real journeys above. Authenticate through `paperclipai auth login`; the probe uses the saved operator credential and never accepts a Muse cookie or credential argument. State and retained evidence should live in a private directory outside Git. + +```sh +node cli/node_modules/tsx/dist/cli.mjs scripts/smoke/muse-qualification.ts start \ + --state /absolute/private/muse-qualification.json \ + --api-base https://your-paperclip-host.example \ + --company-id COMPANY_UUID --agent-id AGENT_UUID \ + --core-revision EXACT_CORE_SHA --runner-revision EXACT_RUNNER_SHA \ + --environment self-hosted --evidence-mode live + +node cli/node_modules/tsx/dist/cli.mjs scripts/smoke/muse-qualification.ts check \ + --state /absolute/private/muse-qualification.json +``` + +Start records a fixed server-owned deadline. Run `check` regularly during the experiment (for example every five minutes using the host's supported scheduler). Each eligible hour has one deterministic task identity. Hours six through nine are reserved for sustained idle. A lost creation response retries its original idempotency key; pending work blocks later samples. There are twenty-one planned slots and at least twenty successful unattended samples are required. No direct Muse chat may be used to make a sample pass. + +The server's deadline fencing is independent of the watcher. `check` also performs deadline cleanup before task/document reads, so a broken sample cannot prolong the experiment. Use `stop` with the same state path to end early. `report` reads retained evidence without creating work. If a command is killed while holding its local state lock, verify it is no longer running before removing that lock; the server deadline remains authoritative. + +Retain the detector's actual registered hook ID and exact script when installing it. After revocation, and after any qualification deadline has passed, remove the detector in Muse and use `hooks.list({ include_disabled: true })` to verify that exact hook ID and script are absent; an ordinary listing can hide a disabled definition. In the agent's Muse connection settings, choose **Record detector removal observation** and enter the retained hook ID, the actual UTC listing time and the SHA-256 of the secret-free retained observation. Paperclip records `operator_runtime_attestation`, not independent provider confirmation, worker quiescence or release of unknown native effects. Never replace the listing time with the submission time or force cleanup flags. + +Use `--evidence-mode synthetic` for a scripted receiver. Synthetic evidence can calibrate the checker but cannot qualify personal Muse. The report independently requires full duration, native completion/document receipts, cadence coverage, sustained idle, and resolved remote uncertainty. It reports queue-to-offer, queue-to-claim, native acceptance, accepted result and full finalization latency separately. The requested interval remains five seconds; at least 90% of steady observed intervals must be at most seven seconds. Missing persisted contact evidence or any internal or boundary receiver gap longer than one minute fails qualification. Do not replace this with the prior sixty-second hook. + +The public report is an explicit allowlist without task/account/session IDs or credentials. Retain the underlying private evidence for audit. The report's receiver qualification result is separate from its release result; the latter additionally requires the independently verified product journeys. The prior stopped experiment's four replies do not count toward this probe. + +Credential-free checker calibration: + +```sh +node --test scripts/smoke/muse-qualification-report.test.mjs scripts/smoke/muse-qualification.test.mjs +``` + +Cloud must deploy the matching exact protocol manifest and preserve tenant authority. Its companion generator verifies parity with `packages/shared/src/muse-protocol.ts`. A connected receiver currently inhibits tenant sleep; unavailable ingress returns a retryable failure and never implies a delivered task. diff --git a/doc/plans/2026-10-09-muse-on-shared-external-runner.md b/doc/plans/2026-10-09-muse-on-shared-external-runner.md index 437a6721ead..ebe63d34504 100644 --- a/doc/plans/2026-10-09-muse-on-shared-external-runner.md +++ b/doc/plans/2026-10-09-muse-on-shared-external-runner.md @@ -1,6 +1,10 @@ # Bring personal Muse onto the shared external Runner -Date: 2026-10-09. Status: implementation plan; the port and live qualification have not started. +Date: 2026-10-09. Status: historical investigation and implementation plan. +Implementation continued on October 10 using the [accepted architecture](2026-10-10-muse-runner-architecture.md) +and `origin/master` at `0e37a385e678d1a568f483c31ae1c19024d2df98`. +The baseline table below records the original investigation. Live qualification +has not started; see the [current compatibility limit](../muse-personal-agent.md#current-compatibility-and-release-gates). ## Recommendation @@ -151,7 +155,11 @@ Acceptance: hire approval, name/role draft, reload/resume, concurrent setup wind First prove a real research/document task, a clarification answered in Paperclip, a follow-up assignment, and proactive read/create/comment from a fresh Muse conversation. Then run a new bounded 24-hour qualification with at least 20 unattended samples and sustained idle periods. The previous experiment remains stopped; none of its replies count toward this run. -Use the recovered qualification tooling after adapting it to the new receipts and generation identities. Schedule deterministic sample slots; never enqueue over active or uncertain work. A simulated worker or direct chat intervention cannot make an unattended sample pass. Preserve intervention and outage classifications in secret-free evidence. +Use the recovered qualification tooling after adapting it to the new receipts and generation identities. Schedule deterministic sample slots; never enqueue over active or uncertain work. + +Future checker state version 3 retains a terminal failed sample and may collect a distinct later current-hour slot only after exactly one fenced, unclaimed assignment and its matching failed native run are established, with a healthy connection and no live assignment, pending input, uncertainty, or stop hold. It never retries a failed task or catches up a missed hour. A lost task-create response can be retried with its original idempotency key only within that same slot hour. After the hour passes, an uncertain creation without an issue ID remains unresolved and blocks later slots; it is neither dropped nor created late. Version 3 refuses version 2 state; existing campaigns must finish and clean up with their original checker and state. Continuing collection does not make a failed campaign pass: the unchanged grader requires at least 20 successful native samples and success for every persisted sample. + +A simulated worker or direct chat intervention cannot make an unattended sample pass. Preserve intervention and outage classifications in secret-free evidence. Record requested hook interval and actual signal-contact gaps separately. Predeclare the five-second qualification threshold (the earlier harness used at least 90% of steady-state gaps at seven seconds or less); retain a sustained idle window of at least two hours. Separate scheduler queue-to-offer, queue-to-authenticated-claim, claim-to-accepted-result, and queue-to-native-finalization latency. Report counts, tails, missing claims, pending results, and failures, not only averages. diff --git a/doc/plans/2026-10-13-muse-production-readiness.md b/doc/plans/2026-10-13-muse-production-readiness.md new file mode 100644 index 00000000000..e9a56743ebc --- /dev/null +++ b/doc/plans/2026-10-13-muse-production-readiness.md @@ -0,0 +1,450 @@ +# Personal Muse production readiness + +Date: 2026-10-13. Owner: this integration task. This updates the accepted Muse plan and October 10 architecture against origin/master `833769ab557255b11dd8f650f43ed7333653f5e4`. + +## Release contract + +| Area | Required outcome / boundary | +| --- | --- | +| Bot and identity | Existing personal Meta Muse at muse.ai; a named company agent with a separately recorded authorizing user. | +| Release targets | Public HTTPS self-hosted instances and Paperclip Cloud using ordinary managed Runner execution. No control-plane execution fallback. | +| Assignment journey | Ordinary assignment wakes Muse, authenticates and claims once, uses its own relevant native tools, publishes an inspectable document, and finalizes through the native Runner. | +| Idle journey | A fresh Muse conversation discovers the installed client, reads permitted company work, creates governed intake without a human placeholder task, and returns useful attributed work. | +| Human setup | Invite → copy current setup instruction → hostname approval in Muse → watched pairing/receiver/authenticated reply milestones → Done. No cookie export or manually copied durable API key. | +| Credentials | Single-use 10-minute pairing feeds a dedicated Muse OAuth authorization. Secure Vault/authd owns public-client DCR, PKCE S256, 15-minute access and rotating refresh with 30-day authenticated inactivity expiry. The worker uses credential surrogates and stores no raw tokens. Public polling never extends credentials. | +| Public receiver | Explicitly approved by the user on October 13. Nonsecret binding UUID selects an opaque wake-presence signal. No task text, global cursor, credentials, or assignment authority. All worker operations remain authenticated. Public contact cannot establish readiness. | +| Provider placement | Muse owns its personal model and tools. One admitted assignment uses the shared external Runner. Idle connection traffic allocates no Runner. Paperclip workspace/shell and third-party gateway tools remain deferred. | +| Delivery and recovery | One active assignment; stable request/event identities; current company/agent/authorizer/run checks; unknown effects require reconciliation; controller restart must preserve original identities. | +| Stops and cleanup | Fence authority immediately; best-effort remote stop with explicit confirmed/unconfirmed evidence; uncertainty blocks automatic overlap/retry. Disconnect disables the receiver and reports unreachable cleanup honestly. | +| Capabilities and costs | Required first-party task/document/questions/collaboration/intake tools. External Muse usage/cost unavailable unless reported; existing budget/approval gates remain. | +| Qualification bound | New 24-hour campaign, at least 20 unattended samples and at least two sustained idle hours. Request five-second hooks; at least 90% measured steady gaps ≤7 seconds and no gap >1 minute. No silent 60-second fallback. | +| Evidence | Separate scripted, actual Muse, local native Runner, managed Linux artifact, and deployed-tenant results. Preserve failed attempts, operator nudges, exact revisions, latency, and cleanup. | +| Rollout | Default off until selected gates pass. Prepare reviewable app, ingress, and Runner artifacts; verify the real staged serving revision before production rollout approval. | + +## Delivery sequence and acceptance matrix + +| Gate | Existing evidence | Required action / next evidence | +| --- | --- | --- | +| Current master integration | October 10 stacked PRs and two scripted native runs; stale for current source | Reconcile task-workspace/heartbeat extractions, migration numbering, authorizer and skill-test JWT scope; preserve Dot compatibility. | +| Real receiver feasibility | Four old 60-second replies; old experiment stopped. October 10 hooks forbid secrets and accept no script arguments. | Publish a new immutable asset version, install a no-argument secret-free receiver, authenticate a fresh readiness challenge on current Muse. | +| Natural onboarding | Existing invite/settings components and stories; simulated and browser evidence | Fresh invite, expiry/renewal, reload/resume, hiring approval, site permission, current challenge, readiness and Done on actual candidate. | +| Both work directions | Scripted task/document/question/follow-up proof, no actual Muse run | Actual research/document task using Muse tools; Paperclip clarification answer; follow-up; fresh-conversation proactive intake with no queued task. | +| Correctness/recovery | Existing protocol/client/authority/Rust tests and failure evidence | Duplicate/lost responses; competing claims; pause/reassignment/cancel; actual controller restart; credential refresh/replay/revocation; stopped-run authority and cleanup. | +| Cloud/installed artifact | Cloud PR731 exact bounded routing; no deployed Muse proof | Current ingress parity/header tests; normal Linux packaged Runner capability; named synthetic staging tenant through ordinary managed execution and real serving digests. | +| 24-hour reliability | Not run; prior experiment is not reusable qualification | Freeze candidate; bounded unattended monitor with deterministic samples, cadence and queue-to-claim/full-response timings; report incomplete evidence and owned-resource cleanup. | +| Review and release | Previous draft PRs were green/5of5 on old heads | Current targeted checks → required full checks/token gates → current-head CI/review. Keep manageable logical PRs/dependencies. Separate review-ready, qualified, merged, deployed, and canary-verified. | + +Next critical-path gate: establish explicit standing delegation in onboarding, +then prove actual autonomous acceptance and completion before the live campaign. + +The previous receiver test remains stopped. No October 10 scripted evidence establishes actual Muse execution, first-time onboarding, Cloud deployment, or unattended reliability. Existing credentials and private fixture data are never copied into this plan or public PRs. + +## Current provider evidence and architecture correction + +October13 actual personal Muse read-only probes reconfirmed five-second secret-free no-argument hooks. Persistent skills live at `~/workspace/skills/paperclip-muse/SKILL.md` and are discovered by `muse.skill_search` from fresh side conversations. + +The production credential contract is Secure Vault/authd. The old0600 test credential file is an explicit test exception, not production setup. Muse reports supported `credentials.request_api_access` public-client OAuth2/DCR/PKCE and automatic refresh; a worker obtains only `hsurr` surrogates through the bundled `dynamic_credentials.py` helper, with Sentinel substituting the real access credential during approved egress. There is no supported direct pairing-token injection into Vault. + +Decision: reuse the existing OAuth engine with an explicit Muse agent profile and one nullable binding-to-grant link. Keep Muse commands/queries and native authority separate from personal MCP and Dot identity. Preserve Dot refresh behavior. Add only exact OAuth endpoints under the bounded Muse namespace, and use the operator's one-use pairing ticket for exact company/agent/authorizer authorization. + +Strict disconnect and explicit OAuth revocation invalidate the grant immediately. Post-revocation authenticated cleanup cannot be promised with the supported single Vault credential. Public410 disables the detector; stopping/cleanup stay unconfirmed without provider observation or exact operator attestation. Run-level fencing while the connection is live still supports authenticated control receipts. No cleanup credential is written to files and revoked OAuth authority is never revived. + +Next critical gate is supported authd OAuth bootstrap plus the secret-free receiver on actual Muse. Simulated private-file client tests remain labeled deterministic compatibility evidence only. + +## October 13 implementation checkpoint + +The fresh candidate uses master `833769ab557255b11dd8f650f43ed7333653f5e4`. +Five additive migrations 339–343 preserve the master journal prefix and the four +previous Muse migration SQL identities. The fifth links an exact OAuth grant. +Master's workspace/instruction preparation stays excluded for external providers. + +Local verification: 213 targeted Runner/runtime tests, rebuilt debug Runner +capability metadata, server/UI typechecks, HTTP privacy 79 cases, connection UI, +and token gates passed. Cloud ingress matches 20 exact Core routes; its full suite +passed 3260 tests with 92 intentional skips, plus local QA smoke. These checks are +synthetic/local, not actual Muse or deployed managed-runtime proof. + +Review exposed OAuth lock-order and revocation-window races. The candidate now +uses admission/binding locking for OAuth lifecycle, validates credential authority +inside guarded idle writes and native dispatch, and reconciles revoked grants. +OAuth concurrency and regression checks remain in progress. No new hook has been +installed; the previous experiment remains stopped. + +The disposable authenticated qualification company is isolated from the primary +instance. The next provider gate is authd Connect, including preservation of the +pairing-ticket query, a surrogate-authenticated read, then an independent receiver +reply. Do not begin the 24-hour clock before the candidate and receiver are frozen. + +The operator approved the complete bounded public protocol on October13. +The dedicated `/api/muse/v1` route is now installed on the existing HTTPS8443 +service; all unrelated Tailscale configuration was verified unchanged. Muse's +OAuth grant is not yet installed. The final control checks passed51 Muse cases, +101 existing Dot/personal OAuth cases,23 supported-client cases and11 protocol +cases. The rendered expiry journey revealed a missing Reconnect action, now +covered by the actual invitation component regression. UI checks passed27 cases. + +## Actual provider checkpoint, October 13 at 17:26 UTC + +The current source candidate is `51ad4ba59021d5d281736b4c5d4d541d58036aea`, +following integration commit `f6285c7b5a0a82b5cbba53ea67ab18bc99426eee`. +The later change surfaces Vault Connect in the current Muse conversation before +background installation; it does not alter the immutable receiver asset2 bytes. + +The first actual installation attempt created a visible background task but +made no observed installation, credential request, or hook progress. A read-only +operator status inquiry after approximately ten minutes found no active worker, +installed files, or new hook. That attempt is failed/assisted onboarding evidence, +not an unattended success. Its expired one-use ticket was replaced normally. +One subsequent ticket expired while its Connect action was pending; it was also +replaced through the ordinary connection API. + +An explicit current-conversation credential request then completed Muse's normal +Vault Connect flow. The user authorized the agent to perform the test connection +consent. Authd preserved the pairing-ticket query and supplied its actual callback, +public-client registration and S256 PKCE. Paperclip observed an active credential +for the intended disposable company and named agent at 17:26 UTC. No credential +was exported from Muse, copied manually, or written by the worker. This establishes +actual supported OAuth bootstrap, not yet a surrogate-authenticated worker read, +background reply, task execution, or unattended first-time setup. + +The native smoke fixture also passed two scripted assignments on the integrated +candidate: document publication, structured clarification/answer consumption, +native finalization and follow-up. Its first attempt failed because the Runner +WebSocket resolved to a public hostname exposing only the approved Muse routes. +The fixture was repaired using the existing `PAPERCLIP_API_URL` loopback setting; +no new public Runner route or provider fallback was added. A fresh ordinary run +then passed. These remain local synthetic results, separate from personal Muse +and managed Cloud qualification. + +The required full Core test suite is still running. Updated reviewable PR slices +are being reconstructed on the frozen master cutoff. The five-second receiver, +real work in both directions, deployed managed artifact, and new 24-hour campaign +remain release gates. The previous experiment remains stopped. + +## Actual provider checkpoint, October 13 at 18:44 UTC + +Serving source `d80cca8d7ec2d5e19f8396b6b563acf515f1a48b` publishes immutable +asset3. Core and Cloud match 24 bounded protocol routes. Full workspace typecheck +and build passed on this source. Required full tests have retained failures in +unchanged baseline suites; focused reruns passed where recorded, but the complete +run is not green. Remaining serialized phases are still being executed. + +The actual Secure Vault grant completed an authenticated scoped identity read. +Aggregate metadata also proves a rotating refresh and a 900-second maximum +access-token lifetime. Neither raw credentials nor surrogate headers were +exported. Early network reads required normal provider approvals and exceeded +the old 30-second timeout. Asset3 adds a bounded 120-second preflight and an +honest timeout/permission-wait diagnostic. A manual server restart and approved +provider permission actions are recorded setup interventions, not unattended +first-time onboarding evidence. Muse's actual Always allow card covered the +parent domain and subdomains; the setup now warns about the displayed scope. + +Muse installed the five-second managed hook, persistent skill, and asset3 client. +A subsequent independent detector wake confirmed the authenticated readiness +challenge at 18:23:47 UTC. Paperclip observed paired, receiver detected, and +background reply verified separately. This establishes actual background +connectivity, not full task execution or proven effective five-second cadence. + +The first real research/clarification assignment was queued at 18:30:22.994 UTC +and offered 2.096 seconds later. Muse woke and read the authenticated offer, but +asked for fresh permission to accept. There was no claim, native acceptance, +document, question, or result. The normal deadline fenced the assignment and +failed its native run at 18:40:24.485 UTC. Muse then observed the revocation and +reported no work. No admitted effects or uncertain-operation holds were present. +This is a failed unattended acceptance attempt; it is not a successful sample. + +Diagnosis: setup clearly says signals and offers do not grant execution authority +but does not clearly grant standing permission to request native acceptance. +Correct onboarding must authorize autonomous receipt, validation, and acceptance +for the exact company and named agent. Work still requires the native accepted +receipt and current lease; task content cannot expand delegation or override +Muse's own permissions. Publish corrected instructions as asset4 without changing +asset1–3 bytes. Deliver this authorization to the existing test connection as a +recorded onboarding repair, then test a fresh assignment without a chat nudge. + +Review also found a resumed-hire retention defect and missing agent-attributed +readiness/cleanup activity history. Fixes and regression checks are in progress. +The task thread must show Waiting for Muse until native acceptance rather than +implying the worker is thinking. + +The new 24-hour test has not begun. Actual work in both directions, clarification, +follow-up, effective cadence, managed Linux execution, and deployed tenant proof +remain release gates. The old experiment stays stopped and the provider stays +experimental/default off. + +## Asset4 assembly and prequalification repair + +Asset4 adds exact company/agent standing delegation, binding-specific persistent +skills and authenticated installed-version reporting. Historical asset1–3 bytes +are unchanged. Muse acknowledged a direct scoped user authorization at 19:14 UTC +and reported no inline mailbox read, claim, or challenge acknowledgement. This is +a recorded onboarding repair, not a successful task or qualification sample. + +The final stack also retains resumed invitations after readiness, audits +readiness/cleanup with the named agent and authorizing person, and displays +Waiting for Muse/Dot until native acceptance. Question sets share Runner's +196 KiB serialized UTF-8 bound. The five migration SQL files and original journal +timestamps are preserved, with coherent snapshot predecessors. No live database +reset or direct receipt edit was performed during assembly. + +Prequalification receiver measurements over 20 minutes contained 214 contacts: +median gap 5.780 seconds, p90 6.126 seconds, maximum 7.584 seconds; 210 of 213 gaps +were at most seven seconds. This is promising cadence evidence, not the required +24-hour observation or a completed assignment. + +The full frozen asset3 test coverage retains failed attempts. Its control file +passed 52 cases. Unchanged bounded reruns passed the invitation-list and native +bootstrap cases where recorded, while one pipelines case remains reproducibly +unresolved. A clean frozen-master baseline is being tested before attributing +that failure. The required final-candidate checks and live journeys remain open. + + +## Asset4 task and stopping checkpoint, October 13 at 20:11 UTC + +Frozen `dc28979f7d5b7788d0598e0ea1f6e49a58381def` passed workspace typecheck +and build, 929 targeted Vitest cases and 29 Python cases. Its independent +asset4 readiness reply took 18.116 seconds. Historical onboarding interventions +remain recorded; this does not establish unattended first-time setup. + +A fresh real task was queued at 19:43:28.861 UTC and offered 3.506 seconds later. +Muse claimed it at 19:45:43.230 UTC and received native acceptance 379 ms later, +without a chat nudge. Queue-to-claim was 134.369 seconds. Muse asked the required +structured question; the operator answered Engineering audience through Paperclip +at 19:55:51.903 UTC. The worker read and acknowledged the answer but reported +only the next steps. It never consumed that input, published the required memo, +or finalized. A premature native finish was rejected while input was pending. +This is incomplete prequalification, not a successful delivery sample. + +The ordinary task Stop action cancelled the native run and fenced the assignment +at 20:09:18.225 UTC. Muse independently submitted the exact-boundary quiescence +report. Native effects were not uncertain. The connection reports worker_reported; +this does not confirm termination of every private Muse effect. No manual receipt, +claim, completion or operator stop attestation was fabricated. + +Asset5 is a source correction, not yet qualified: its generic wake explicitly +loads the paired skill and executes/resumes in-scope admitted work rather than +ending at mailbox inspection. Canonical answers resume the same live assignment; +rejected premature completion does not revoke otherwise-current authority. +Historical assets1–4 retain their exact bytes; credentials, scope, native leases, +provider permissions and approval gates remain unchanged. + +Current master cutoff is now `c7f4b74111d16f0b57112fa6d0b6a59c2d63bfe8`. +Master added migrations339–340. The Muse suffix is renumbered341–345 with exact +SQL hashes. A normal-engine rehearsal preserved all old history/grant/binding/ +credential values, appended only the two missing master migrations, and matched +a fresh combined schema. No live database reset or receipt edits occurred. +Snapshots incorporate exactly the two new master table changes and retain five +newest snapshots. Normal Drizzle generation reported no extra schema migration. + +Review race/replay fixes prevent a late send error from marking a settled receipt +unknown and prevent exact duplicate results from reopening a settled assignment. +A meaningful before/after regression reproduced three failures on old source and +passed all four cases after repair. Broader original Core failures remain retained; +the candidate's required full-check result is not green by inference. + +The new 24-hour campaign has not begun. Real completed work, follow-up, proactive +intake, the updated receiver, current-head CI/review, managed Linux and deployed +tenant gates remain open. The old experiment stays stopped and Muse stays off by +default. + + +## Current-master assembly and asset6 repairs + +The test fixture is deliberately offline during assembly. The persistent OAuth +grant, database and bounded HTTPS path remain intact. Asset6 has not yet been +installed in Muse. Its source uses immutable version-specific directories for +all six asset versions, with no mutable historical aliases. The client repairs a +crash between saving verified identity and publishing the persistent paired skill. +A definitively rejected premature finish can progress to a new completion phase +only after canonical input consumption; unknown or accepted effects retain their +original identities and are never blindly repeated. + +Current admission rechecks the presented access credential after every lock wait, +before reserving new work. Durable reservations recover under current native and +grant authority without retaining an obsolete bearer across ordinary refresh. +Cancelled answers remain in audit history but do not appear as actionable pending +input. Qualification occupancy ends at recorded native termination and includes +independent stop/effect holds across reconnects; it cannot count unresolved remote +work as idle. Before/after cases reproduced eight product failures and passed the +repaired ten-case subset. All three assembled Muse server suites passed 78 Vitest +cases, including 68 control cases and the wrapper's 30 Python assertions. Server +typecheck and module boundaries passed; original failed attempts remain retained. + +Task retry controls now treat waiting_for_provider as active execution, so a prior +failure cannot offer overlapping retry while Muse/Dot awaits acceptance. The full +TaskChatThread suite passed 229 cases. Storybook fingerprint changes are reviewed +against the fixed current-master cutoff; source-only review does not imply live +render or accessibility qualification. Fresh final renders remain a separate gate. + +Cloud candidate 14ce9db6da196bbf27f35686475aa0cf8b3ee240 passed typecheck and the full +suite: 3,279 passed and 92 skipped. Its 36 exact protocol routes match Core manifest +71fc2c8550eb52fe4842d0ce4bde021ee0ad35d9eadaa7038316857a79b24b2b. +It depends on a separate conditional cleanup repair for a computer claim committed +after lease cancellation, before provider allocation. The repair preserves started +provider resources and replacement claims; no real provider allocation occurred. +Neither candidate is deployed to a managed Muse tenant. + +The last real assignment remains incomplete and its worker_reported stop hold +is not released. Any release requires an honest ordinary exact-boundary operator +attestation. No new assignment or 24-hour campaign begins over that hold. The old +experiment remains stopped. + + +## Asset6 live failure and current-master repair, October 13 at 22:01 UTC + +The frozen asset6 candidate was `7305efe445022205a6440ef8a6caa02460edba9d`. +Its independent authenticated background verification succeeded. A fresh real +research assignment was offered 3.120 seconds after creation and claimed after +103.649 seconds, with native acceptance another 431 milliseconds later. Muse +submitted the structured question. The operator answered through Paperclip. +The worker acknowledged the input notification but the answer helper failed; +there was no canonical consumption, published document, or native completion. +This is failed prequalification, not a reliability sample. + +After ordinary Stop and task cancellation, Muse independently reported the exact +worker quiescent. The operator used the existing exact-boundary attestation to +release that hold. Independent private tools were not certified stopped. A +read-only diagnostic after cancellation requested only checkpoint shape fields. +Muse confirmed valid JSON object, with the helper's `inputs` field missing. No +checkpoint contents, credential files, Vault values or surrogate headers were +read. The diagnostic was an operator intervention, not unattended success. + +The client and model shared the same continuation pathname. Immutable asset7 +separates helper-owned input receipts from model-owned work state, preserving +legacy prepared command identities and failing closed on malformed helper +receipts. This correction and its live retry are still in progress. The fixture +is deliberately offline for source assembly; its database and OAuth grant remain. + +The master cutoff has advanced to `edcfcaf3f69abfda12fcbc63225f7346f53450aa`. +New master migrations341–342 require the Muse suffix343–347. SQL identity, +applied history and credential values must be preserved by normal migration +reconciliation. Master's new Storybook annotations and ignored generated outputs +replace the obsolete aggregate catalog/fingerprint workflow. + +Frozen7305 passed full workspace typecheck and build. The required local full +test command remains incomplete: owned embedded PostgreSQL initialization hit +host shared-memory limits, and one adapter-utils publication-race assertion +failed in the broader remainder. Both attempts and clean focused reruns are +retained. Source equality and a passing focused master case do not prove those +failures were inherited. No full-green local claim is made. + +The frozen Linux Runner compiled and passed installed-package lookup/startup and +its network-disabled Muse native contract. A first twenty-minute packing attempt +was stopped at its bound; a separately bounded pack-only recovery passed using +byte-identical official pack/install/consumer function bodies. This is local +Linux emulation and synthetic protocol evidence, not a deployed managed tenant +or personal Muse task completion. New master contains no Runner/Rust/eval source +changes, to be rechecked on assembly. + +Next critical gate: freeze asset7 on the fixed current-master stack and complete +the real research/document/clarification task. Follow-up and fresh-conversation +proactive intake must then pass before starting the new24-hour campaign. The old +experiment remains stopped, Muse remains experimental and disabled by default, +and no Cloud tenant or production rollout is claimed qualified. + + +## Frozen asset7 verification and next candidate, October 13 + +The assembled serving candidate634da654bbb11ad5edeef4a726818bb17a81382d +passed normal full workspace typecheck and build. Typecheck first failed because +a newly declared master dependency was absent from inherited installed packages; +a normal cached install restored the declared dependency without source or lock +changes. Both attempts are retained. The normal debug Runner compiled; the +release artifact and serving revision were recorded separately. + +The required full test command on634 was stopped after602.360 seconds at its +original ten-minute bound while fixtures were still progressing. PostgreSQL +initialization worked in this attempt. There is no completed aggregate result; +this is an incomplete budget-limited attempt, not the earlier shared-memory +failure. The next frozen candidate requires a fresh normal full command with a +ninety-minute bound and completion of all healthy phases. + +The original three-suite Muse check had142 passes and one failed race assertion. +The claim test relied on an arbitrary50-millisecond delay before reassignment +committed. A test-only repair waits for the actual database lock blocked by the +held reassignment transaction, then requires the precise under-lock rejection, +no sent command and an unclaimed assignment. On that isolated repair over634, +all143 cases passed. The original failure is retained. + +All20 current Muse Storybook journeys rendered and the CopyAndVerify play passed. +The broader selected normal functional batch was37 of40; original intermittent +and fixture failures remain retained. Accessibility reporters failed24 of40, +including shared sidebar/panel failures also observed on Dot. Visual inspection +does not establish an unpublished Linux visual baseline or provider readiness. + +Personal Muse installed pinned immutable asset7 with its existing Vault grant +and five-second hook. The independent background verification was accepted at +22:46:53.253 UTC, about19.839 seconds after the helper recorded its request. +A new ordinary research assignment was offered2.854 seconds after the recorded +queue time, claimed after152.833 seconds and accepted natively another540 +milliseconds later. No Muse chat nudge was used. It submitted the structured +audience question. The answer is still pending: automatic approval review +requires the human's specific audience choice despite the disposable-test +authorization. No input consumption, document or completion is claimed yet. +This prelude is not an unattended qualification sample. + +The fixed master cutoff advanced to e0f30a4c7186498b71b2637f253b3399997dac7d. +Its new agent-creator migration is preserved. Foundation reconciliation passed +five normal-engine rehearsals with unchanged existing migration/grant/token +records. The four unchanged Muse migrations are0344–0347; actual final OAuth +requires normal generation as0348, retaining its SQL hash and original timestamp. +The live database has not been reset. + +Review uncovered two independent compatibility/security defects. Idle Muse +creates/comments must preserve the resolved source-trust policy, and both modern +and legacy native history readers must sanitize quarantined comments for a +higher-trust current run while preserving legitimate low-trust access. Meaningful +before/after cases cover both boundaries. The published client also rejected +65–128 selected options that the canonical question contract accepts. Immutable +asset8 corrects that cap; versions1–7 retain their exact bytes. Its three Muse +suites passed148 outer Vitest tests including53 Python assertions (separate +count). The finite Core/Cloud protocol now has44 method/path pairs; future asset9 +is rejected. Full candidate, review and live gates remain pending. + +The new24-hour campaign has not started. Real task completion, follow-up and +fresh-conversation proactive activity must pass on the frozen release candidate +before that campaign. The old experiment stays stopped. The provider remains +experimental and disabled by default; no managed Cloud tenant or rollout is +claimed qualified. + +### October 13 final review repairs and verification boundary + +The fixed upstream baseline remains `e0f30a4c7186498b71b2637f253b3399997dac7d`; the live fixture still serves `634da654bbb11ad5edeef4a726818bb17a81382d` with installed immutable client 7. The live research assignment reached native acceptance and submitted its structured question. It remains pending a canonical human audience choice; two ordinary UI submission attempts were rejected by automatic approval review, so no answer was inserted through another route. This pending human input is not a successful task, delivery failure, or unattended qualification sample. + +Fresh review repairs are additive: idle task history now applies the current named agent's trust policy; Muse question admission rejects unsupported request IDs and text validation before any stored native input or runtime request; immutable client 9 uses individually bounded input receipts and migrates bounded historical aggregates without changing prior consume identities or removing original evidence. Published clients 1–8 remain byte-identical. The changed Runner Rust source requires new native artifacts and Linux package qualification; prior binaries do not qualify this candidate. + +Cloud routing candidate `a05efb8ad0f88b6d6905b9e953492265a5d9ea0b` admits 48 exact method/path pairs with manifest `98b7acb058c2eb349c6be9ae4b5b48a2f9a4562ceb546d46bd22b05d05cf4f8d`. Normal typecheck, build:test, parity and targeted tenant/protocol tests pass. Complete normal npm test passes: 3,279 passed, zero failures, 92 skipped, 307.656 seconds including test compilation. This is source verification, not a deployed Cloud journey. + +A preliminary live-client-7 timestamp window observed 318 contacts / 317 intervals over approximately 30 minutes. Every interval was at most seven seconds (median 5,863 ms, p90 6,111 ms, maximum 6,934 ms); persisted evidence was complete. This supports the faster hook setting only. The new bounded 24-hour campaign has not started, and the previous stopped experiment stays stopped. The complete Core suite remains not started until final source assembly and targeted checks finish. + +### October 14 corrected source after actual infrastructure failure + +The prior frozen `aa11b95c06f652ecfd940d52208f16dca7b49660` passed normal workspace types (53.182 seconds), build (26.382 seconds), 238 focused outer cases plus64 Python cases, and all20 functional client9 Storybook journeys. Sixteen unchanged accessibility reports remain. The normal full test command started at00:16:22 UTC, then encountered five actual PostgreSQL shared-memory startup failures. It was stopped after22m46s as incomplete infrastructure, with no complete phase aggregate; workspace and159 serialized files were unattempted. Owned test processes and proven stopped data directories were cleaned. No global IPC or system setting changed. This differs from the prior634 ten-minute attempt, which was interrupted while healthy fixtures progressed. + +The unchanged Linux offline package helper compiled the new native Runner, then failed because it omitted a declared root optional Copilot dependency. The partial ELF and package are retained as unqualified; no installed native pass is claimed. Foundation repair `1b25de8524f44670100cd699488e4dc689d0573a` includes installed optional roots and rejects invalid present roots, with six collector cases and38 relevant Node checks passing. Rust source remains unchanged. + +Feature document/audit tip `93626bf0a23f62c7e5c2fa7325d98a26bdf5a1ad` fixes current-policy document projection and first-change audit attribution. Seven baseline failures became passes; its full101-case Muse suite and16 Python cases passed. The updated Feature `7d8b38ce763c36856f33997ac74482721bdbcea9` merges the packaging foundation and validates expiry, current nonce and unread cursor before readiness wake selection. It retains99 paths over Foundation; Composer `4ee2fe63b353d4a73427a41f8541d5ef58dae3e6` retains exactly3 UI paths. These upstream partitions preserve19 stories; the final OAuth slice preserves the twentieth. Upstream266 tests, types and gates pass. + +Final-only `cea76fffe4cc5b4ca20eedd477ae6178f61cc1d1` retains stop control while feature flags are off, only for a presented current credential, current grant/family/person authority and a bounded matching unreleased fence. Ordinary assignments, task content, inputs, readiness and effects stay denied. Expired-access feature-off polls remain quiet. Disconnect and OAuth revocation keep their strict denial and detector-disable behavior; no hidden cleanup credential or post-revocation exception exists. Sixteen focused tests include actual PostgreSQL lock waits followed by token expiry;172 full Muse cases plus64 Python and five selected OAuth/Dot compatibility cases pass. Published assets1–9 and48-route manifest remain identical. + +The corrected freeze requires fresh combined tests and the complete normal Linux check/package workflows. These lanes use isolated default shared memory and normal embedded PostgreSQL without helper, database or test overrides. They are prepared only at this commit. The live fixture still serves634/client7 and waits for the canonical audience input; no task result, unattended campaign, deployed Cloud proof or production readiness is claimed. + + +### October 14: final transport fixtures and provider-specific expiry + +The normal isolated Linux package helper completed on `1b7cededade8e8c2389f4dd96d3c5996e2f747b2`: 256 tarballs, an empty-store offline install including Copilot, bundled Runner lookup/startup/exports/evals, and an actual installed Muse native invalid-question, answer-consumption, recovery, and canonical-completion sequence. A separate stronger cleanup observation proved the owned child exited; its original immediate cleanup observation remains preserved. This is a package proof for that source, not a deployed Cloud or personal Muse qualification. + +The isolated Linux focused suite then found three real Final test failures: merged legacy signal-credential fixtures could not authenticate through the retired lane. `150a6bbadfe544b8cdfb82957e636eca4a628933` adapts those three cases to the public binding-ID receiver and opaque signal references. It preserves all expiry, current nonce, unread cursor, stability, first-change audit, and privacy assertions. The original 180-pass/3-fail evidence is retained; PostgreSQL treatment must pass on the final assembly. The complete normal Core command has not started on this assembly. + +The actual personal Muse research run expired after 120 minutes while its clarification remained unanswered. It produced no document, accepted result, or finalization. After an explicit post-failure cleanup check, Muse reported the exact worker stopped; the ordinary operator UI attestation released only that worker hold. These interventions do not count as unattended completion. No canonical audience answer has been received, and the clarification gate remains unverified. + +The failure also exposed Dot wording in the shared Muse expiry event. Foundation `d8bf622e09f7bcc8c3d9b021ea57a625ff1dcd3e` changes only the three assignment-error sites through the existing provider codec. Muse receives its own name and `muse_assignment_expired`; Dot keeps its exact prior code and text. The new native baseline had two passing Dot cases and two failing Muse cases; all 18 shared-backend cases then passed in both debug and release. This Rust change requires a new package proof and proper debug build before the complete normal Core suite. The immutable client assets, DB migrations, UI source, shared contracts, and 48-route Cloud manifest stay unchanged. + + +## Detector teardown evidence after the deadline + +OAuth revocation remains immediate and final. After the full qualification deadline, an authorized Board operator can record an exact detector-removal observation in the disconnected agent connection section. Retain the actual registered hook ID and generated binding-specific script from installation. Remove that hook through Muse’s supported runtime, then inspect `hooks.list({include_disabled:true})`: the exact ID must be absent, including disabled definitions. A disabled hook, omitted disabled entries, silence, or a removal request is insufficient. + +The Board confirmation records the retained hook ID, exact server-derived script, a SHA-256 digest of the secret-free listing observation, the observation time, and the actual operator. Do not paste raw listings, credentials, URLs, or private tool output. The server checks current Board change authority, latest binding/generation/revision and exact qualification/deadline, then atomically records flags and audit provenance. It does not call Muse, verify installation independently, stop private work, clear unknown native effects, or revive revoked credentials. Refresh is required after offline or changed connection evidence. + +Grader revision `paperclip.muse-qualification.v4` distinguishes authenticated worker reports from operator attestations based on a supported runtime observation. Both are reported evidence, not independent provider confirmation. Exact source provenance and post-deadline timing are required; the 24-hour duration, five-second cadence, sustained idle window, at least twenty samples, every-sample success and all stop/effect uncertainty requirements remain unchanged. Earlier report revisions and sealed campaign results remain historical. diff --git a/scripts/storybook-muse-catalog.test.mjs b/scripts/storybook-muse-catalog.test.mjs new file mode 100644 index 00000000000..ad6041003b2 --- /dev/null +++ b/scripts/storybook-muse-catalog.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { catalogFromStories, renderIndex, scanStories, validateCatalog } from "./storybook-catalog.mjs"; +import { featureIndex } from "./feature-map.mjs"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const file = "ui/storybook/stories/agents/external-agent-invite/muse.stories.tsx"; +const stableId = "onboarding-external-agent-invitation-muse-personal-agent"; +const index = featureIndex(root); +const stories = scanStories(root); +const catalog = catalogFromStories(stories, index); +const muse = stories.find(story => story.file === file); + +// These IDs predate the move; existing links must survive the feature title change. +test("real Muse suite preserves all existing journey IDs and resolves relocated imports", () => { + assert.ok(muse, "Muse suite must be discovered in its agent feature home"); + assert.equal(muse.metaId, stableId); + assert.deepEqual(muse.ids, [ + "start-here", "name-and-role", "copy-setup", "paired", "receiver-detected", + "finishing-setup", "ready", "approval", "loading-invitation", + "authenticated-instance-required", "missing-public-host", "no-recent-response", + "replaced-prompt", "mobile", "mobile-details", "light", "copy-and-verify", + "settings", "stop-boundary", "expired-connection", + ].map(suffix => `${stableId}--${suffix}`)); + assert.equal(muse.plays, 1); + assert.equal(existsSync(path.join(root, "ui/storybook/stories/external-agent-invite/muse.stories.tsx")), false); + const source = readFileSync(path.join(root, file), "utf8"); + for (const [, relative] of source.matchAll(/from "(\.[^"]+)"/g)) { + assert.ok(existsSync(path.resolve(root, path.dirname(file), `${relative}.tsx`)) + || existsSync(path.resolve(root, path.dirname(file), `${relative}.ts`)), `Unresolved moved import: ${relative}`); + } +}); + +test("Muse registration validates actual production subjects with honest fixture limits", () => { + const entries = catalog.entries.filter(entry => entry.file === file); + assert.equal(entries.length, 1); + const entry = entries[0]; + assert.equal(entry.stableId, stableId); + assert.equal(entry.sidebarTitle, muse.title); + assert.deepEqual(entry.capabilities, ["agents/hire", "agents/lifecycle", "access/invitations"]); + assert.equal(entry.status, "unreviewed"); + assert.equal(entry.implementation, "production"); + assert.equal(entry.boundary, "fixtures"); + const source = readFileSync(path.join(root, file), "utf8"); + assert.ok(source.includes("component: ExternalAgentInvitePreview")); + for (const subject of [ + "../../../support/external-agent-invite/ExternalAgentInvitePreview", + "@/components/MuseRunnerConnection", + "../../../support/external-agent-invite/muse-fixtures", + ]) assert.ok(source.includes(`from "${subject}"`), `Missing rendered subject or fixture import: ${subject}`); + assert.match(entry.notes, /simulated API state/); + assert.match(entry.notes, /Does not establish live provider/); + const errors = validateCatalog(catalog, stories, index); + // Other suites retain their outstanding reviews; this assertion covers Muse only. + assert.deepEqual(errors.filter(error => error.startsWith(file)), []); + assert.ok(!errors.includes("Duplicate story IDs")); +}); + +test("generated catalog navigation links the one Muse suite from every registered capability", () => { + const generated = renderIndex(catalog, stories, index); + for (const capability of ["agents/hire", "agents/lifecycle", "access/invitations"]) { + const line = generated.split("\n").find(text => text.startsWith(`- \`${capability}\`:`)); + assert.ok(line?.includes(`../../${file}`), `Missing Muse lookup: ${capability}`); + } +}); diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 3c483953c32..7075ec06792 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -226,6 +226,15 @@ describe("Paperclip Runner Codex configuration", () => { expect(dot).toContain("Dot connection"); expect(dot).toContain("Allow externally billed provider"); }); + it("uses the shared Muse connection and excludes local harness, workspace, and lifecycle fields", async () => { + const muse = await renderRunner({ provider: "muse" }); + expect(muse).toContain("Muse connection"); + expect(muse).toContain("Allow externally billed provider"); + expect(muse).not.toContain('aria-label="Harness"'); + expect(muse).not.toContain("Runner lifecycle"); + expect(muse).not.toContain("Workspace files and commands"); + expect(muse).not.toContain("Capture raw provider traces"); + }); it("keeps the general harness picker hidden for an existing Grok Bot", async () => { const html = await renderRunner({ provider: "grok_bot" }); expect(html).not.toContain('aria-label="Harness"'); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 4e45d7f933e..c2f624b3e8b 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -1,5 +1,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectPopover } from "../../components/ui/select"; import { AdapterMark } from "../../components/AdapterMark"; +import { MuseRunnerConnection } from "../../components/MuseRunnerConnection"; import { GrokBotConnection } from "../../components/GrokBotConnection"; import { DotRunnerConnection } from "../../components/DotRunnerConnection"; import { configFieldsForSection } from "../config-sections"; @@ -99,6 +100,8 @@ export function CodexLocalConfigFields({ isPaperclipRunnerProvider(runnerProvider) ? PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES[runnerProvider] : { configurable: false, defaultMode: "provider-managed", options: [], description: "Unknown provider configuration is preserved." }; + const isMuse = runnerProvider === "muse"; + const isExternal = isMuse || runnerProvider === "openai_dot" || runnerProvider === "grok_bot"; const configuredRunnerPermissionMode = runnerManaged && runnerPermissionCapability.configurable ? isCreate @@ -224,7 +227,7 @@ export function CodexLocalConfigFields({ /> )} - {runnerManaged && !hideRunnerHarness && runnerProvider !== "openai_dot" && runnerProvider !== "grok_bot" && ( + {runnerManaged && !hideRunnerHarness && !isExternal && ( updateRunnerSchemaValue("allowUnmeteredProvider", value)} /> } + {runnerManaged && isMuse && <> + + + + updateRunnerSchemaValue("allowUnmeteredProvider", value)} /> + } {runnerManaged && runnerProvider === "acpx" && runnerSchemaValue("acpxAgent", "claude") !== "grok" && ( { setDetectorHookId(event.target.value); setDetectorConfirmed(false); }} autoComplete="off" /> + +

Use the digest of the secret-free runtime observation. Do not paste the listing, a URL, credentials, or private tool output.

+ +

Enter the actual runtime listing time, not the time you submit this form. Paperclip checks it against revocation and the qualification deadline.

+ +
+ } + {attestation &&
+

Confirm the worker for this exact assignment stopped

+
+ {Object.entries(attestation.boundary).map(([key, value]) =>
{key}
{value === null ? "None" : String(value)}
)} +
+ +
+ + +
+
} + {pairing && !b?.paired &&
+ }} description="Paste into your current Muse conversation. Complete Connect Paperclip if Muse asks, then approve the Paperclip hostname." /> +

One-use setup expires {formatDateTime(pairing.expiresAt)}. Muse keeps the connection credential in its secure vault. Hostname permission applies to future requests to this Paperclip hostname.

+
} +
+ {live && } + + {connection.agentStatus !== "paused" && connection.agentStatus !== "terminated" && } + {live && } + + +
+ {error &&

{error}

} + ; +} + +export function MuseRunnerConnection({ companyId, agentId }: { companyId?: string; agentId?: string }) { + const cache = useQueryClient(); + const muse = useMuseConnection(companyId, agentId); + const pause = useMutation({ mutationFn: () => agentsApi.pause(agentId!, companyId), onSuccess: () => { + void muse.refresh(); void cache.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) }); + if (companyId) void cache.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }); + } }); + if (!agentId) return

Save the agent, then connect your personal Muse here.

; + const error = muse.state.error ?? muse.pair.error ?? muse.verify.error ?? muse.revoke.error ?? muse.attest.error ?? muse.attestDetector.error ?? pause.error; + if (muse.identity.failed) return

Unable to confirm your account. Refresh to inspect this Muse connection.

; + if (!muse.state.data) return

{muse.state.error ? "Unable to load the Muse connection." : "Loading Muse connection…"}

{error &&

{error.message}

}
; + return { if (muse.binding) muse.verify.mutate(muse.binding); }} onRepair={muse.repair} + onPause={() => pause.mutate()} onDisconnect={() => { if (muse.binding) muse.revoke.mutate(muse.binding); }} + onRefresh={() => { void muse.state.refetch(); }} onAttest={input => muse.attest.mutate(input)} onAttestDetectorCleanup={input => muse.attestDetector.mutate(input)} />; +} diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 40677cb3dd3..59bf338dadc 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -2573,6 +2573,77 @@ describe("TaskChatThread runtime transcript selection", () => { }, ); + it.each(["Muse", "Dot"].flatMap( + (provider) => [false, true].map((hasTranscript) => ({ provider, hasTranscript })), + ))( + "hides a previous agent's Retry while waiting for $provider acceptance (transcript: $hasTranscript)", + async ({ provider, hasTranscript }) => { + const onRetryFailedRun = vi.fn(); + if (hasTranscript) transcriptState.transcriptByRun.set("previous-failure", [{ + kind: "assistant", ts: "2026-08-25T18:00:01.000Z", text: "Starting the task.", + }]); + const failedRun = { + runId: "previous-failure", + runtimeMode: "legacy" as const, + status: "failed", + errorCode: "legacy_process_exited", + agentId: "previous-agent", + agentName: "Direct Codex", + adapterType: "codex_local", + createdAt: "2026-08-25T18:00:00.000Z", + startedAt: "2026-08-25T18:00:00.000Z", + finishedAt: "2026-08-25T18:00:02.000Z", + }; + const waitingRun = { + runId: "external-offer", + runtimeMode: "native" as const, + status: "queued", + agentId: "external-agent", + agentName: provider, + adapterType: "paperclip_runner", + createdAt: "2026-08-25T18:01:00.000Z", + startedAt: null, + finishedAt: null, + execution: { + phase: "waiting_for_provider" as const, + label: `Waiting for ${provider}`, + cause: null, + lastConfirmedActivityAt: null, + retryAt: null, + attempt: 1, + maxAttempts: 3, + recoveryOwner: null, + nextAction: null, + permittedActions: [], + predecessorRunId: null, + successorRunId: null, + }, + }; + const renderRun = (waiting: boolean) => render( + {}} + issueStatus="in_progress" + onRetryFailedRun={onRetryFailedRun} + linkedRuns={waiting ? [failedRun, waitingRun] : [failedRun]} + />, + ); + // The linked projection must guard Retry before active/live-run polling arrives. + renderRun(true); + expect(container.textContent).toContain("Your message is preserved."); + expect(container.textContent).not.toContain("You can retry this message now."); + expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull(); + expect(onRetryFailedRun).not.toHaveBeenCalled(); + renderRun(false); + expect(container.textContent).toContain("You can retry this message now."); + const retry = container.querySelector('[data-testid="task-chat-run-failed-try-again"]'); + expect(retry).not.toBeNull(); + flushSync(() => retry!.click()); + await Promise.resolve(); + expect(onRetryFailedRun).toHaveBeenCalledExactlyOnceWith("previous-failure"); + }, + ); + it.each(["active execution", "pending decision", "recovery hold"].flatMap( (gate) => [false, true].map((hasTranscript) => ({ gate, hasTranscript })), ))( diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index de97aa93eb5..d41d9f609ae 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -589,6 +589,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ) return false; return [ "working", + "waiting_for_provider", "retry_scheduled", "reconnecting", "finishing", diff --git a/ui/src/components/new-agent/ExternalAgentInviteContent.tsx b/ui/src/components/new-agent/ExternalAgentInviteContent.tsx index 39072f14dba..d7235538559 100644 --- a/ui/src/components/new-agent/ExternalAgentInviteContent.tsx +++ b/ui/src/components/new-agent/ExternalAgentInviteContent.tsx @@ -1,12 +1,16 @@ +import { useId } from "react"; +import { AGENT_ROLES, AGENT_ROLE_LABELS } from "@paperclipai/shared"; +import type { MuseInvitationDraft } from "@/lib/muse-invitation-draft"; +import { Input } from "../ui/input"; import { Link } from "@/lib/router"; -import { ArrowLeft, Check, Circle, CircleAlert, Loader2 } from "lucide-react"; +import { ArrowLeft, Bot, Check, Circle, CircleAlert, Loader2 } from "lucide-react"; import { AdapterMark } from "../AdapterMark"; import { AgentSetupPrompt } from "../AgentSetupPrompt"; import { Button } from "../ui/button"; import { DialogDescription, DialogTitle } from "../ui/dialog"; import { cn } from "@/lib/utils"; -export type ExternalAgentPreset = "dot" | "grok_bot" | "hermes" | "other"; +export type ExternalAgentPreset = "dot" | "grok_bot" | "muse" | "hermes" | "other"; export type DotConnectionState = { phase: "waiting" | "connected" | "subscribed" | "testing" | "finishing" | "ready"; problem?: "event_timeout" | "prompt_unavailable" | "offline" | "agent_unavailable"; @@ -15,13 +19,14 @@ export type DotConnectionState = { const presets = [ { id: "dot", name: "Dot", adapter: "openai_dot", description: "Your Dot in ChatGPT" }, { id: "grok_bot", name: "Grok Bot", adapter: "grok_bot", description: "An existing Bot in Grok" }, + { id: "muse", name: "Muse — Personal agent", adapter: "muse", description: "Your personal Muse at muse.ai" }, { id: "hermes", name: "Hermes", adapter: "hermes_gateway", description: "An existing Hermes agent" }, { id: "other", name: "Other", adapter: "http", description: "Any agent that can connect to Paperclip" }, ] as const; -export function ExternalAgentPresetPicker({ onSelect, dotDisabledReason, grokDisabledReason }: { onSelect: (preset: ExternalAgentPreset) => void; dotDisabledReason?: string; grokDisabledReason?: string }) { - return
- {presets.map(preset => } {provider && }
- {!provider ? "Invite an external agent" : dot ? ready ? "Your Dot is connected" : "Connect your Dot" : preset === "hermes" ? "Invite your Hermes agent" : "Invite your agent"} + {!provider ? "Invite an external agent" : muse ? ready ? "Muse is connected" : museDetails || museAwaitingInvitation ? "Invite your Muse" : "Connect your Muse" : dot ? ready ? "Your Dot is connected" : "Connect your Dot" : preset === "hermes" ? "Invite your Hermes agent" : "Invite your agent"} {!provider ? `Bring an agent you already use into ${companyName}.` + : muse ? museDetails ? `Give your personal Muse a name and role in ${companyName}.` + : museAwaitingInvitation ? "Muse requires an authenticated Paperclip instance with a public HTTPS URL and a company operator account." + : ready ? `Muse can receive assignments and work with ${companyName}.` + : !prompt && !museConnection?.paired ? "We’re checking whether this instance can prepare Muse setup." + : "Paste the setup prompt into your current Muse conversation. Complete Connect Paperclip if Muse asks, then approve this Paperclip hostname. Muse keeps the connection credential in its secure vault. The hostname permission applies to future requests." : ready ? `Your Dot can now receive assignments and work with ${companyName}.` : connection.problem === "agent_unavailable" ? "Your Dot is connected. This agent must be available in Paperclip before it can receive assignments." : connection.phase === "finishing" ? "Your Dot has connected and confirmed task updates. Paperclip is preparing it for assignments." @@ -127,10 +197,33 @@ export function ExternalAgentInviteContent({
- {error &&

{error}

} - {!provider ? <> + {error &&

{error}

{!museAwaitingInvitation && }
} + {!provider ? <> {dotDisabledReason &&

{dotDisabledReason}

}{grokDisabledReason && grokDisabledReason !== dotDisabledReason &&

{grokDisabledReason}

} - : approvalHref ?

An organization admin needs to approve this agent before Dot can connect. This page will update after approval.

+ : approvalHref ?

An organization admin needs to approve this agent before {muse ? "Muse" : "Dot"} can connect. This page will update after approval.

+ : museAwaitingInvitation ? busy ?

Loading Muse invitation…

: null + : muse && busy && !museDetails && !prompt && !museConnection?.paired ?

Loading Muse setup…

+ : museDetails ?
{ event.preventDefault(); if (museDraft.name.trim() && !busy) onMuseContinue?.(); }}> +
+ + onMuseDraftChange?.({ ...museDraft, name: event.target.value })} /> +
+
+ + +
+

Muse uses its own approved tools. Provider usage and cost are unavailable to Paperclip.

+
+ : muse && museConnection ? <> + + {museConnection.problem === "no_recent_response" && } + {museConnection.problem === "offline" && } + {!museConnection.paired && Open Muse} +

Receiver contact is persisted in batches and may lag. Setup never creates a task. Provider usage and cost are unavailable.

+ : busy && !prompt && connection.phase === "waiting" ?

Preparing your invitation…

: dot ? <>
{connection.phase === "waiting" &&

Dot uses your OpenAI account. Provider usage and cost aren’t reported to Paperclip.

}
@@ -139,11 +232,20 @@ export function ExternalAgentInviteContent({ :

The invitation includes everything your agent needs to join {companyName}. You can review its request before granting access.

}
- +
+ +
{provider && (approvalHref ? + : museDetails ? : ready ? + : museAwaitingInvitation ? + : muse ? museConnection?.problem === "disconnected" || museConnection?.problem === "connection_expired" ? + : museConnection?.problem === "prompt_unavailable" ? + : !museConnection?.paired && prompt ? }} description="Send this whole prompt to your current Muse conversation." align="end" className="min-w-0 shrink" onCopied={onCopied} /> + : busy ? + : : connecting ? + { onSettingsChange(next); setSettings(next); }} onAssigneeChange={vi.fn()} mobile={mobile} + modelOptionsOverride={[{ id: "gpt-6-sol", label: "GPT-6 Sol" }]} /> + ; + } + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + flushSync(() => root!.render()); + await click("Select model and effort"); + await click("Choose exact model"); + expect(document.querySelector('[aria-label="Search or paste a model ID"]')).not.toBeNull(); + await click("Switch to Muse"); + expect(container!.querySelector('[aria-label="Select model and effort"]')).toBeNull(); + expect(document.querySelector('[aria-label="Search or paste a model ID"]')).toBeNull(); + expect(document.querySelector('[aria-label="Choose exact model"]')).toBeNull(); + expect(document.querySelector('[aria-label="Effort"]')).toBeNull(); + expect(document.querySelector('[data-testid="model-unavailable"]')?.textContent).toContain("Muse chooses its model and effort"); + expect(onSettingsChange).toHaveBeenCalledOnce(); + expect(onSettingsChange).toHaveBeenCalledWith(null); + }); + it("opens the assignee list directly on mobile", async () => { render(vi.fn(), vi.fn(), false, { mobile: true }); await click("Select assignee"); diff --git a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx index 27e9cae0b53..1f95fc838d3 100644 --- a/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx +++ b/ui/src/components/task-chat/ComposerRunSettingsPicker.tsx @@ -60,6 +60,7 @@ function harnessLabel(agent: Agent | undefined): string { function unavailableModelReason(agent: Agent | undefined): string { if (!agent) return "Choose an agent to select its model and effort."; + if (agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "muse") return "Muse chooses its model and effort. Paperclip has no per-task override."; if (agent?.adapterType === "process") return "This agent runs a command. Its harness does not expose a model or effort setting."; if (agent?.adapterType === "http") return "This agent calls an HTTP endpoint. The destination service chooses its model."; return "This gateway chooses its model remotely; Paperclip has no per-task model setting for it."; @@ -86,7 +87,7 @@ export function ComposerRunSettingsPicker({ initialOpen = false, initialView = "settings", initialModelSearch = "", initialAssigneeSearch = "", }: Props) { const [open, setOpen] = useState(initialOpen); - const [view, setView] = useState<"settings" | "agents" | "models">(initialView); + const [requestedView, setView] = useState<"settings" | "agents" | "models">(initialView); const [modelSearch, setModelSearch] = useState(initialModelSearch); const [assigneeSearch, setAssigneeSearch] = useState(initialAssigneeSearch); const [highlightedAssignee, setHighlightedAssignee] = useState(0); @@ -103,7 +104,12 @@ export function ComposerRunSettingsPicker({ const agentId = assigneeValue.startsWith("agent:") ? assigneeValue.slice(6) : ""; const agent = agents.get(agentId); const modelSupported = supportsComposerModel(agent); + const view = requestedView === "models" && !modelSupported ? "settings" : requestedView; const provider = composerCatalogProvider(agent); + const isMuseRunner = agent?.adapterType === "paperclip_runner" && provider === "muse"; + useEffect(() => { + if (isMuseRunner && settings !== null) onSettingsChange(null); + }, [isMuseRunner, settings, onSettingsChange]); const binding = aiRuntimeConnectionBindingSchema.safeParse(agent?.runtimeConfig?.aiConnection).data; const poolId = binding?.mode === "router" ? binding.connectionId : undefined; const { data: fetchedModels = [], isPending: modelsPending } = useQuery({ @@ -262,6 +268,6 @@ export function ComposerRunSettingsPicker({ const onOpenChange = (next: boolean) => { setOpen(next); if (!next) { setView("settings"); setModelSearch(""); setAssigneeSearch(""); } }; // The desktop portal needs its own scroll lock inside the new-task dialog. - return mobile ? {triggers}{view === "agents" ? "Select assignee" : "Select model and effort"}{body} + return mobile ? {triggers}{view === "agents" ? "Select assignee" : modelSupported ? "Select model and effort" : "Agent run settings"}{body} : {triggers}{body}; } diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx index 6206e697529..d24e2db7ca9 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx @@ -164,6 +164,33 @@ describe("TaskChatRunnerTurn", () => { }, ); + it.each(["Muse", "Dot"])("shows Waiting for %s until native acceptance, without a Thinking claim", provider => { + const waiting = { phase: "waiting_for_provider", label: `Waiting for ${provider}` } as ExecutionProjection; + render([], "running", "run-1", undefined, false, false, waiting); + expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent) + .toContain(`Waiting for ${provider}`); + const tail = container.querySelector('[data-testid="task-chat-current-activity-label"]'); + expect(tail?.textContent).toBe(`Waiting for ${provider}`); + expect(tail?.classList.contains("shimmer-text")).toBe(false); + expect(container.textContent).not.toContain("Working"); + expect(container.textContent).not.toContain("Thinking"); + + // Re-render the same live turn after the native acceptance projection changes. + render([], "running", "run-1", undefined, false, false, + { phase: "working", label: "Working" } as ExecutionProjection); + expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent) + .toContain("Working for"); + expect(container.querySelector('[data-testid="task-chat-current-activity-label"]')?.textContent) + .toBe("Thinking"); + + // A stale waiting projection must never override a terminal run outcome. + render([], "failed", "run-1", undefined, false, false, waiting); + expect(container.querySelector('[data-testid="task-chat-turn-status-header"]')?.textContent) + .toContain("Stopped"); + expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull(); + expect(container.textContent).not.toContain(`Waiting for ${provider}`); + }); + it("labels the streaming tail as a continuation after steering", () => { render([], "running", "run-1", undefined, false, true); diff --git a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx index 00df5ee5f10..30263f96cd4 100644 --- a/ui/src/components/task-chat/TaskChatRunnerTurn.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerTurn.tsx @@ -66,11 +66,13 @@ function RunnerTurnStatus({ startedAtMs, finishedAtMs, continuedAfterSteering = false, + waitingLabel, }: { status: string; startedAtMs: number | null; finishedAtMs?: number | null; continuedAfterSteering?: boolean; + waitingLabel?: string | null; }) { const terminal = isTerminalRunStatus(status); useSecondTick(!terminal && startedAtMs != null); @@ -84,12 +86,14 @@ function RunnerTurnStatus({ const elapsed = formatCompactDuration(elapsedMs); const failed = terminalStatusFailed(status); - const label = terminal ? (failed ? "Stopped" : "Worked") : "Working"; + const label = terminal ? (failed ? "Stopped" : "Worked") : waitingLabel ?? "Working"; const semanticLabel = terminal ? elapsed ? `${label} ${failed ? "after" : "for"} ${elapsed}` : label - : `${label} for ${elapsed ?? "0s"}`; + : waitingLabel + ? `${label}${elapsed ? ` · ${elapsed}` : ""}` + : `${label} for ${elapsed ?? "0s"}`; const visibleLabel = continuedAfterSteering ? `Continued after steering · ${semanticLabel}` : semanticLabel; @@ -107,10 +111,10 @@ function RunnerTurnStatus({ ); } -function RunnerCurrentActivityTail({ status }: { status: string }) { +function RunnerCurrentActivityTail({ status, waitingLabel }: { status: string; waitingLabel?: string | null }) { if (isTerminalRunStatus(status)) return null; return
- Thinking + {waitingLabel ?? "Thinking"}
; } @@ -121,6 +125,7 @@ export function TaskChatRunnerTurn({ agent, items, status, + execution, startedAtMs, finishedAtMs, activityUnavailable = false, @@ -149,6 +154,7 @@ export function TaskChatRunnerTurn({ ) => void | Promise; }) { const terminal = isTerminalRunStatus(status); + const waitingLabel = !terminal && execution?.phase === "waiting_for_provider" ? execution.label : null; const yielded = items.some( (item) => item.kind === "protocol" && @@ -222,6 +228,7 @@ export function TaskChatRunnerTurn({ startedAtMs={startedAtMs} finishedAtMs={finishedAtMs} continuedAfterSteering={continuedAfterSteering} + waitingLabel={waitingLabel} />
{activityUnavailable ? ( @@ -280,7 +287,7 @@ export function TaskChatRunnerTurn({ /> ) : null} - {!final && currentActivityItems.length === 0 ? : null} + {!final && currentActivityItems.length === 0 ? : null} ); } diff --git a/ui/src/components/task-chat/composer-run-settings.ts b/ui/src/components/task-chat/composer-run-settings.ts index 9dc7fd46556..382676ad13a 100644 --- a/ui/src/components/task-chat/composer-run-settings.ts +++ b/ui/src/components/task-chat/composer-run-settings.ts @@ -23,7 +23,8 @@ const MODEL_ADAPTERS = new Set([ ]); export function supportsComposerModel(agent: Agent | undefined): boolean { - return Boolean(agent && MODEL_ADAPTERS.has(agent.adapterType)); + return Boolean(agent && MODEL_ADAPTERS.has(agent.adapterType) + && !(agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "muse")); } /** Resolve visible defaults; host environment and a local CLI's own config are unknown. */ diff --git a/ui/src/hooks/useMuseConnection.ts b/ui/src/hooks/useMuseConnection.ts new file mode 100644 index 00000000000..89b3ba46572 --- /dev/null +++ b/ui/src/hooks/useMuseConnection.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { MuseConnection, MuseBinding, MusePairing, MusePairingInput, MuseAttestStopInput, MuseDetectorCleanupAttestationInput } from "@paperclipai/shared"; +import { museInvitationsApi } from "@/api/museInvitations"; +import { useAccountIdentity } from "@/api/companies-query"; +import type { MuseConnectionState } from "@/components/new-agent/ExternalAgentInviteContent"; + +export function museConnectionState(connection: MuseConnection | undefined, + { offline = false, promptUnavailable = false }: { offline?: boolean; promptUnavailable?: boolean } = {}): MuseConnectionState { + const b = connection?.binding; + const passed = !!b?.paired && b.receiverDetected && b.backgroundReplyVerified; + const finishing = connection?.canConfigureConnection && ["preparing", "verifying"].includes(connection.agentLifecycleState); + const ready = passed && b.credentialState === "active" && b.status === "ready" && connection?.agentLifecycleState === "ready" + && connection.canConfigureConnection && connection.enabled && connection.agentStatus !== "pending_approval"; + const testing = !!b?.challengeExpiresAt && Date.parse(b.challengeExpiresAt) > Date.now(); + const expiredTest = !!b?.challengeExpiresAt && Date.parse(b.challengeExpiresAt) <= Date.now(); + return { + paired: b?.paired ?? false, receiverDetected: b?.receiverDetected ?? false, + backgroundReplyVerified: b?.backgroundReplyVerified ?? false, ready, finishing, testing, + problem: offline ? "offline" : b?.status === "revoked" ? "disconnected" : b?.credentialState === "expired" ? "connection_expired" : b?.credentialState === "revoked" ? "disconnected" : connection && !connection.enabled ? "disabled" : promptUnavailable && !b?.paired ? "prompt_unavailable" + : passed && !ready && !finishing ? "agent_unavailable" : expiredTest ? "no_recent_response" : undefined, + }; +} + +/** Expire the current test even when polling returns unchanged historical evidence. */ +export function useMuseConnectionState(connection: MuseConnection | undefined, + options: { offline?: boolean; promptUnavailable?: boolean } = {}) { + const [, updateClock] = useState(0); + const expiresAt = connection?.binding?.challengeExpiresAt; + useEffect(() => { + if (!expiresAt) return; + const delay = Date.parse(expiresAt) - Date.now(); + if (!Number.isFinite(delay) || delay <= 0) return; + const timer = window.setTimeout(() => updateClock(value => value + 1), delay); + return () => window.clearTimeout(timer); + }, [expiresAt]); + return museConnectionState(connection, options); +} + +export function museBindingRevision(binding: MuseBinding) { + return { bindingId: binding.id, generation: binding.generation, expectedRevision: binding.revision }; +} + +/** Read queries contain only public evidence. Pairing results live in this mounted hook. */ +export function useMuseConnection(companyId: string | undefined, agentId: string | undefined, enabled = true) { + const cache = useQueryClient(); + const identity = useAccountIdentity(); + const key = ["muse-binding", companyId, identity.userId, agentId]; + const scope = `${companyId}:${agentId}:${identity.userId}`; + const currentScope = useRef(scope); currentScope.current = scope; + const [pairing, setPairing] = useState(null); + const state = useQuery({ queryKey: key, enabled: enabled && identity.settled && !!companyId && !!agentId, + queryFn: ({ signal }) => museInvitationsApi.connection(companyId!, agentId!, signal), retry: false, staleTime: 0, + refetchInterval: query => query.state.error ? false + : query.state.data?.binding || query.state.data?.agentStatus === "pending_approval" ? 5000 : false }); + const refresh = () => cache.invalidateQueries({ queryKey: key }); + const pair = useMutation({ mutationFn: async (input: MusePairingInput) => { + const requestScope = scope; + const result = await museInvitationsApi.pair(companyId!, agentId!, input); + if (currentScope.current === requestScope) setPairing(result); + // Returning void prevents the transient capability entering the mutation cache. + }, onSuccess: () => { void refresh(); }, onError: () => { setPairing(null); void state.refetch(); }, gcTime: 0 }); + const verify = useMutation({ mutationFn: async (binding: MuseBinding) => { + await museInvitationsApi.verify(companyId!, agentId!, museBindingRevision(binding)); + }, onSuccess: () => { void refresh(); }, onError: () => { void state.refetch(); } }); + const revoke = useMutation({ mutationFn: async (binding: MuseBinding) => { + await museInvitationsApi.revoke(companyId!, agentId!, museBindingRevision(binding)); + }, onSuccess: () => { setPairing(null); void refresh(); } }); + const attest = useMutation({ mutationFn: (input: MuseAttestStopInput) => museInvitationsApi.attestStop(companyId!, agentId!, input), + onSuccess: () => { void refresh(); }, onError: () => { void state.refetch(); } }); + const attestDetector = useMutation({ mutationFn: async (input: MuseDetectorCleanupAttestationInput) => { + const requestScope = scope; + const current = await state.refetch({ throwOnError: true }); + const b = current.data?.binding; + if (currentScope.current !== requestScope || !b || !b.cleanup.attestationEligible || b.id !== input.bindingId || b.generation !== input.generation || b.revision !== input.expectedRevision || (b.qualification?.id ?? null) !== input.qualificationId || b.cleanup.hookScript !== input.hookScript) throw new Error("Connection changed. Refresh before recording detector cleanup."); + await museInvitationsApi.attestDetectorCleanup(companyId!, agentId!, input); + await state.refetch({ throwOnError: true }); + }, onError: () => { void state.refetch(); } }); + useEffect(() => { setPairing(null); }, [companyId, agentId, identity.userId]); + useEffect(() => { + if (!pairing) return; + const timer = window.setTimeout(() => setPairing(null), Math.max(0, Date.parse(pairing.expiresAt) - Date.now())); + return () => window.clearTimeout(timer); + }, [pairing]); + const binding = state.data?.binding; + const currentPairing = pairing && pairing.bindingId === binding?.id && pairing.generation === binding.generation + && pairing.revision === binding.revision && !binding.paired ? pairing : null; + return { state, binding, pairing: currentPairing, pair, verify, revoke, attest, attestDetector, + repair: () => pair.mutate(binding && binding.status !== "revoked" ? { replaceBindingId: binding.id, expectedRevision: binding.revision } : {}), + key, identity, refresh }; +} diff --git a/ui/src/lib/muse-invitation-draft.test.ts b/ui/src/lib/muse-invitation-draft.test.ts new file mode 100644 index 00000000000..f9742fad56f --- /dev/null +++ b/ui/src/lib/muse-invitation-draft.test.ts @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import { afterEach, expect, it } from "vitest"; +import { readMuseInvitationDraft, saveMuseInvitationDraft } from "./muse-invitation-draft"; + +afterEach(() => localStorage.clear()); +it("retains only name and role in the exact company and operator scope", () => { + const draft = { name: "Maia", role: "researcher" as const, ticket: "secret-not-to-save", setupInstruction: "private setup" }; + saveMuseInvitationDraft("company-a", "operator-a", draft); + expect(readMuseInvitationDraft("company-a", "operator-a")).toEqual({ name: "Maia", role: "researcher" }); + expect(readMuseInvitationDraft("company-b", "operator-a").name).toBe(""); + expect(readMuseInvitationDraft("company-a", "operator-b").name).toBe(""); + expect(readMuseInvitationDraft("company-a", null).name).toBe(""); + expect(localStorage.getItem(localStorage.key(0)!)).not.toContain("secret-not-to-save"); + expect(localStorage.getItem(localStorage.key(0)!)).not.toContain("setupInstruction"); +}); +it("rejects a corrupt role and does not share a signed-out draft", () => { + saveMuseInvitationDraft("company", null, { name: "Anonymous", role: "general" }); + expect(localStorage.length).toBe(0); + localStorage.setItem("paperclip.muse-invitation.company.operator", JSON.stringify({ name: "Maia", role: "invented" })); + expect(readMuseInvitationDraft("company", "operator")).toEqual({ name: "", role: "general" }); +}); diff --git a/ui/src/lib/muse-invitation-draft.ts b/ui/src/lib/muse-invitation-draft.ts new file mode 100644 index 00000000000..4f37fc1900c --- /dev/null +++ b/ui/src/lib/muse-invitation-draft.ts @@ -0,0 +1,33 @@ +import { AGENT_ROLES, type AgentRole } from "@paperclipai/shared"; + +export interface MuseInvitationDraft { name: string; role: AgentRole } +export const emptyMuseInvitationDraft: MuseInvitationDraft = { name: "", role: "general" }; + +function key(companyId: string, operatorId: string) { + return `paperclip.muse-invitation.${encodeURIComponent(companyId)}.${encodeURIComponent(operatorId)}`; +} + +/** Only the name and role survive navigation. Pairing capabilities never enter storage. */ +export function readMuseInvitationDraft(companyId: string, operatorId: string | null): MuseInvitationDraft { + if (!operatorId) return { ...emptyMuseInvitationDraft }; + try { + const value: unknown = JSON.parse(localStorage.getItem(key(companyId, operatorId)) ?? "null"); + if (typeof value === "object" && value !== null && "name" in value && "role" in value + && typeof value.name === "string" && value.name.length <= 100 + && AGENT_ROLES.some(role => role === value.role)) { + return { name: value.name, role: AGENT_ROLES.find(role => role === value.role)! }; + } + } catch { /* Storage is optional; the mounted draft remains usable. */ } + return { ...emptyMuseInvitationDraft }; +} + +export function saveMuseInvitationDraft(companyId: string, operatorId: string | null, draft: MuseInvitationDraft) { + if (!operatorId) return; + try { localStorage.setItem(key(companyId, operatorId), JSON.stringify({ name: draft.name, role: draft.role })); } + catch { /* Storage is optional. */ } +} + +export function clearMuseInvitationDraft(companyId: string, operatorId: string | null) { + if (!operatorId) return; + try { localStorage.removeItem(key(companyId, operatorId)); } catch { /* Storage is optional. */ } +} diff --git a/ui/src/lib/new-agent-adapters.ts b/ui/src/lib/new-agent-adapters.ts index c68461cc830..3df9de4ebe7 100644 --- a/ui/src/lib/new-agent-adapters.ts +++ b/ui/src/lib/new-agent-adapters.ts @@ -12,12 +12,14 @@ export function isNewAgentAdapterAllowed( cloud, nativeRunnerEnabled, openAiDotEnabled = false, + museEnabled = false, runnerProvider, - }: { cloud: boolean; nativeRunnerEnabled: boolean; openAiDotEnabled?: boolean; runnerProvider?: string }, + }: { cloud: boolean; nativeRunnerEnabled: boolean; openAiDotEnabled?: boolean; museEnabled?: boolean; runnerProvider?: string }, ) { if (type === "openai_dot" || (type === "paperclip_runner" && runnerProvider === "openai_dot")) { return openAiDotEnabled; } + if (type === "muse" || (type === "paperclip_runner" && runnerProvider === "muse")) return museEnabled && nativeRunnerEnabled; if (type === "paperclip_runner") return nativeRunnerEnabled; if (cloud) return CLOUD_ADAPTERS.has(type); return true; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 2c2b3586803..0c2a2b7c2c6 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -213,6 +213,17 @@ export function visibleRunTokenTotal(usage: Record | null | und return input + output + (inputExcludesCached ? cached : 0); } +/** Muse exposes Paperclip bridge events, not raw private provider traffic. */ +export function supportsRawProviderTrace(adapterType: string, adapterConfig: Record | null | undefined): boolean { + return adapterType !== "paperclip_runner" || adapterConfig?.provider !== "muse"; +} + +/** Persisted accounting facts belong to this run, even after its agent changes provider. */ +export function hasUnavailableProviderAccounting(result: Record | null | undefined): boolean { + const accounting = asObject(result?.providerAccounting); + return accounting.externallyBilled === true && accounting.usage === null && accounting.cost === null; +} + export function visibleRunCostUsd( usage: Record | null, result: Record | null = null, diff --git a/ui/src/pages/AgentDetail.accounting.test.tsx b/ui/src/pages/AgentDetail.accounting.test.tsx new file mode 100644 index 00000000000..6a443bbc9ed --- /dev/null +++ b/ui/src/pages/AgentDetail.accounting.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AgentActionButtons } from "../components/AgentActionButtons"; +import type { Agent, HeartbeatRun } from "@paperclipai/shared"; +import { describe, expect, it, vi } from "vitest"; +import { RunAccountingMetrics } from "./AgentDetail"; +import { hasUnavailableProviderAccounting, supportsRawProviderTrace } from "../lib/utils"; + +vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("../context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) })); +vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) })); + +const unavailable = { providerAccounting: { usage: null, cost: null, externallyBilled: true } }; +const run = (usageJson: HeartbeatRun["usageJson"], resultJson: HeartbeatRun["resultJson"] = null) => ({ usageJson, resultJson } as HeartbeatRun); + +describe("run accounting provenance", () => { + it("renders persisted unavailable accounting without zero usage or zero dollars", () => { + const markup = renderToStaticMarkup(); + expect(markup.match(/Unavailable/g)).toHaveLength(4); + expect(markup).not.toContain("$0"); + expect(markup).not.toContain(">0<"); + }); + + it("retains reported historical usage independently of the agent's current provider", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("120"); + expect(markup).toContain("20"); + expect(markup).toContain("40"); + expect(markup).toContain("$0.1200"); + expect(markup).not.toContain("Unavailable"); + }); + + it("requires the saved externally billed and unavailable facts", () => { + expect(hasUnavailableProviderAccounting(unavailable)).toBe(true); + expect(hasUnavailableProviderAccounting(null)).toBe(false); + expect(hasUnavailableProviderAccounting({ providerAccounting: { usage: null, cost: null } })).toBe(false); + expect(hasUnavailableProviderAccounting({ providerAccounting: { usage: { inputTokens: 2 }, cost: 1, externallyBilled: true } })).toBe(false); + }); +}); + + +describe("provider trace support", () => { + const renderActions = (provider: string, isAdmin: boolean) => { + const agent: Agent = { + id: "agent-1", companyId: "company-1", name: "Researcher", urlKey: "researcher", role: "researcher", + title: null, icon: null, status: "active", reportsTo: null, capabilities: null, + adapterType: "paperclip_runner", adapterConfig: { provider }, runtimeConfig: {}, + budgetMonthlyCents: 0, spentMonthlyCents: 0, permissions: { canCreateAgents: false }, + pauseReason: null, pausedAt: null, lastHeartbeatAt: null, metadata: null, + createdAt: new Date("2026-10-10T00:00:00Z"), updatedAt: new Date("2026-10-10T00:00:00Z"), + }; + return renderToStaticMarkup( + + + , + ); + }; + + it("keeps normal Muse actions but removes the unsupported raw traffic promise", () => { + const markup = renderActions("muse", true); + expect(markup).toContain("Run now"); + expect(markup).toContain("Pause"); + expect(markup).not.toContain("Run with provider trace"); + expect(markup).not.toContain("Capture exact provider traffic"); + }); + + it("retains other provider tracing only for authorized administrators", () => { + expect(renderActions("codex", true)).toContain("Run with provider trace"); + expect(renderActions("openai_dot", true)).toContain("Run with provider trace"); + expect(renderActions("codex", false)).not.toContain("Run with provider trace"); + }); +}); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 4fce523e5ad..50368f56534 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -68,7 +68,7 @@ import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge"; import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold"; import { buildSameOriginWebSocketUrl } from "../lib/websocket-url"; import { tryCreateWebSocket } from "../lib/websocket"; -import { formatDate, relativeTime, formatTokens, visibleRunCostUsd, visibleRunTokenTotal } from "../lib/utils"; +import { formatDate, relativeTime, formatTokens, visibleRunCostUsd, visibleRunTokenTotal, hasUnavailableProviderAccounting, supportsRawProviderTrace } from "../lib/utils"; import { cn } from "../lib/utils"; import { RunRetryDetails } from "../components/RunRetryDetails"; import { Button } from "@/components/ui/button"; @@ -372,6 +372,7 @@ function setsEqual(left: Set, right: Set) { function runMetrics(run: HeartbeatRun) { const usage = (run.usageJson ?? null) as Record | null; const result = (run.resultJson ?? null) as Record | null; + const usageUnavailable = hasUnavailableProviderAccounting(result); const input = usageNumber(usage, "inputTokens", "input_tokens"); const output = usageNumber(usage, "outputTokens", "output_tokens"); const cached = usageNumber( @@ -385,11 +386,12 @@ function runMetrics(run: HeartbeatRun) { const provider = asNonEmptyString(usage?.provider) ?? null; const model = asNonEmptyString(usage?.model) ?? null; return { - input, - output, - cached, - cost, - totalTokens: visibleRunTokenTotal(usage), + usageUnavailable, + input: usageUnavailable ? null : input, + output: usageUnavailable ? null : output, + cached: usageUnavailable ? null : cached, + cost: usageUnavailable ? null : cost, + totalTokens: usageUnavailable ? null : visibleRunTokenTotal(usage), provider, model, }; @@ -1290,7 +1292,7 @@ export function AgentDetail() { companyId={resolvedCompanyId} assignLabel="Assign Task" showStatus={false} - canRunWithProviderTrace={canUseProviderTrace} + canRunWithProviderTrace={canUseProviderTrace && supportsRawProviderTrace(currentAgent.adapterType, currentAgent.adapterConfig)} actionsDisabled={agentAction.isPending} workActionsDisabled={hasInvalidOrgChain} workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" @@ -1738,7 +1740,9 @@ export function AgentOverview({ for (const issue of assignedIssues) map.set(issue.id, issue); return map; }, [assignedIssues]); - const configuredModel = asNonEmptyString(agent.adapterConfig?.model) + const configuredModel = agent.adapterType === "paperclip_runner" && agent.adapterConfig.provider === "muse" + ? "Managed by Muse" + : asNonEmptyString(agent.adapterConfig?.model) ?? asNonEmptyString(agent.adapterConfig?.modelName) ?? asNonEmptyString(agent.runtimeConfig?.model) ?? "Adapter default"; @@ -2021,7 +2025,7 @@ export function ConfigurationTab({ ? queryKeys.agents.adapterModels(companyId, agent.adapterType, null, catalogProvider) : ["agents", "none", "adapter-models", agent.adapterType], queryFn: () => agentsApi.adapterModels(companyId!, agent.adapterType, { provider: catalogProvider }), - enabled: Boolean(companyId) && content === "runtime" && !["openai_dot", "grok_bot"].includes(catalogProvider ?? ""), + enabled: Boolean(companyId) && content === "runtime" && !["openai_dot", "grok_bot", "muse"].includes(catalogProvider ?? ""), }); const lowTrustSelected = getTrustPreset(agent.permissions) === "low_trust_review"; @@ -3306,10 +3310,11 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect {summary.slice(0, 60)} )} - {(metrics.totalTokens > 0 || metrics.cost > 0) && ( + {(metrics.usageUnavailable || (metrics.totalTokens ?? 0) > 0 || (metrics.cost ?? 0) > 0) && (
- {metrics.totalTokens > 0 && {formatTokens(metrics.totalTokens)} tok} - {metrics.cost > 0 && ${metrics.cost.toFixed(3)}} + {metrics.usageUnavailable && Usage and cost unavailable} + {metrics.totalTokens !== null && metrics.totalTokens > 0 && {formatTokens(metrics.totalTokens)} tok} + {metrics.cost !== null && metrics.cost > 0 && ${metrics.cost.toFixed(3)}}
)} @@ -3419,6 +3424,27 @@ export function AgentFileRunNotice({ resultJson }: { resultJson: HeartbeatRun["r ; } +export function RunAccountingMetrics({ run }: { run: HeartbeatRun }) { + const metrics = runMetrics(run); + if (!metrics.usageUnavailable && ![metrics.input, metrics.output, metrics.cached, metrics.cost].some(value => (value ?? 0) > 0)) return null; + const values = [ + ["Input", metrics.input === null ? "Unavailable" : formatTokens(metrics.input)], + ["Output", metrics.output === null ? "Unavailable" : formatTokens(metrics.output)], + ["Cached", metrics.cached === null ? "Unavailable" : formatTokens(metrics.cached)], + ["Cost", metrics.cost === null ? "Unavailable" : metrics.cost > 0 ? `$${metrics.cost.toFixed(4)}` : "-"], + ]; + return ( +
+ {values.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }: { run: HeartbeatRun; agentRouteId: string; adapterType: string; adapterConfig: Record }) { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -3445,6 +3471,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } }); const paperclipDeveloperMode = experimentalSettings?.enablePaperclipDeveloperMode === true; + const canRerunWithProviderTrace = canUseProviderTrace && supportsRawProviderTrace(adapterType, adapterConfig); const { data: providerTraceRows } = useQuery({ queryKey: queryKeys.providerTraceMetadata(run.companyId, [run.id]), queryFn: () => heartbeatsApi.providerTraceMetadata(run.companyId, [run.id]), @@ -3619,7 +3646,6 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } ? Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000) : null; const displayDurationSec = durationSec ?? (isRunning ? elapsedSec : null); - const hasMetrics = metrics.input > 0 || metrics.output > 0 || metrics.cached > 0 || metrics.cost > 0; const hasSession = !!(run.sessionIdBefore || run.sessionIdAfter); const sessionChanged = run.sessionIdBefore && run.sessionIdAfter && run.sessionIdBefore !== run.sessionIdAfter; const sessionId = run.sessionIdAfter || run.sessionIdBefore; @@ -3693,7 +3719,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig } runId={run.id} enabled={paperclipDeveloperMode && canUseProviderTrace} /> - {canUseProviderTrace && !["queued", "running"].includes(run.status) ? ( + {canRerunWithProviderTrace && !["queued", "running"].includes(run.status) ? (
Invite an external agentChoose an agent you already use. {}} />
diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 9f8567f0be3..ed68b4e7f6d 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -274,6 +274,17 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) } }); + it("saves the personal Muse opt-in separately and names independent readiness evidence", async () => { + await renderPage(); + expect(container.textContent).toContain("Muse — Personal agent"); + expect(container.textContent).toContain("independent background reply"); + expect(container.textContent).toContain("Provider usage and cost are unavailable"); + const toggle = container.querySelector('button[aria-label="Toggle Muse experimental setting"]')!; + await act(() => toggle.click()); + await flushReact(); + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableMuse: true }); + }); + it("names Dot prerequisites and saves its opt-in independently in both directions", async () => { await renderPage(); const selector = 'button[aria-label="Toggle OpenAI Dot experimental setting"]'; diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index e691c63989a..358c4f9518b 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -504,6 +504,18 @@ export function InstanceExperimentalSettings() { ariaLabel="Toggle memory connectors experimental setting" /> + toggleMutation.mutate({ enableMuse: checked })} + disabled={toggleMutation.isPending} + settingKey="enableMuse" + managed={managedKeys.enableMuse} + ariaLabel="Toggle Muse experimental setting" + /> + , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const StartHere: Story = { name: "01 · Invite a personal Muse", args: { initialScreen: "picker" } }; +export const NameAndRole: Story = { name: "02 · Name and role", args: { initialScreen: "setup", museDetails: true, simulate: false } }; +export const CopySetup: Story = { name: "03 · Copy setup and approve hostname", args: { initialScreen: "setup", simulate: false } }; +export const Paired: Story = { name: "04 · Paired, receiver not yet detected", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: false, backgroundReplyVerified: false, ready: false } } }; +export const ReceiverDetected: Story = { name: "05 · Receiver detected, waiting for background reply", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: false, ready: false } } }; +export const FinishingSetup: Story = { name: "06 · All checks passed, lifecycle preparing", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: false, finishing: true } } }; +export const Ready: Story = { name: "07 · Ready for assignments", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: true } } }; +export const Approval: Story = { name: "Approval required", args: { initialScreen: "setup", pendingApproval: true, simulate: false } }; +export const LoadingInvitation: Story = { name: "Recovery · Loading invitation", args: { initialScreen: "setup", invitationUnavailable: true, preparing: true, simulate: false } }; +export const AuthenticatedInstanceRequired: Story = { name: "Recovery · Authenticated instance required", args: { initialScreen: "setup", invitationUnavailable: true, error: "Use an authenticated Paperclip instance with a public HTTPS URL and a company operator account. Local trusted access alone cannot connect Muse.", simulate: false } }; +export const MissingPublicHost: Story = { name: "Recovery · Configure public HTTPS address", args: { initialScreen: "setup", invitationUnavailable: true, error: "This instance has no public HTTPS URL for Muse. Ask your instance administrator to configure a stable public HTTPS address, then refresh setup. Muse cannot connect through a local-only address.", simulate: false } }; +export const NoRecentResponse: Story = { name: "Recovery · No recent response", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: true, receiverDetected: true, backgroundReplyVerified: false, ready: false, problem: "no_recent_response" } } }; +export const ReplacedPrompt: Story = { name: "Recovery · Replaced or expired setup", args: { initialScreen: "setup", simulate: false, initialMuseConnection: { paired: false, receiverDetected: false, backgroundReplyVerified: false, ready: false, problem: "prompt_unavailable" } } }; +export const Mobile: Story = { name: "Mobile · Copy setup", args: { initialScreen: "setup", simulate: false }, globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const MobileDetails: Story = { name: "Mobile · Name and role", args: { initialScreen: "setup", museDetails: true, simulate: false }, globals: { viewport: { value: "mobile1", isRotated: false } } }; +export const Light: Story = { name: "Light · Name and role", args: { initialScreen: "setup", museDetails: true, simulate: false }, globals: { theme: "light" } }; +export const CopyAndVerify: Story = { name: "Test · Copy, three milestones, then ready", args: { initialScreen: "setup", stepDelayMs: 300 }, + loaders: [() => ({ copyFeedbackGate: createMuseCopyFeedbackGate() })], + render: (args, { loaded }) => , + play: async ({ canvasElement, loaded }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Copy setup prompt" })); + const copiedFeedback = await page.findByText("Copied to clipboard"); + await waitFor(() => expect(copiedFeedback).toBeVisible()); + loaded.copyFeedbackGate.feedbackObserved(); + await waitFor(() => expect(page.getByText("Receiver detected", { exact: true })).toHaveTextContent("complete")); + await expect(page.queryByRole("button", { name: "Done" })).not.toBeInTheDocument(); + await waitFor(() => expect(page.getByRole("heading", { name: "Muse is connected" })).toBeVisible()); + await userEvent.click(page.getByRole("button", { name: "Done" })); + }, +}; +export const Settings: Story = { name: "Settings · Persisted evidence and controls", render: () =>
}; +export const StopBoundary: Story = { name: "Settings · Disabled, cleanup, uncertain work, exact stop boundary", render: () =>
}; + +export const ExpiredConnection: Story = { name: "Recovery · Expired Vault connection", args: { + initialScreen: "setup", simulate: false, initialMuseConnection: { + paired: true, receiverDetected: true, backgroundReplyVerified: true, ready: false, problem: "connection_expired", + }, +} }; diff --git a/ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx b/ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx index db23a4c5f2c..a3859e5d9b1 100644 --- a/ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx +++ b/ui/storybook/support/external-agent-invite/ExternalAgentInvitePreview.tsx @@ -4,37 +4,63 @@ import { Layout } from "@/components/Layout"; import { Agents } from "@/pages/Agents"; import { PluginLauncherProvider } from "@/plugins/launchers"; import { AgentBasicsDialog } from "@/components/new-agent/AgentBasicsDialog"; -import { ExternalAgentInviteContent, type DotConnectionState, type ExternalAgentPreset } from "@/components/new-agent/ExternalAgentInviteContent"; +import { ExternalAgentInviteContent, type DotConnectionState, type ExternalAgentPreset, type MuseConnectionState } from "@/components/new-agent/ExternalAgentInviteContent"; import { Dialog } from "@/components/ui/dialog"; import { AnimatedDialogContent } from "@/components/AnimatedDialogContent"; import { Button } from "@/components/ui/button"; +import { musePairing } from "./muse-fixtures"; +import type { MuseInvitationDraft } from "@/lib/muse-invitation-draft"; import { dotInvitePrompt, externalInvitePrompt } from "./fixtures"; export interface ExternalAgentInvitePreviewProps { initialScreen?: "entry" | "picker" | "setup"; initialPreset?: ExternalAgentPreset; initialConnection?: DotConnectionState; + initialMuseConnection?: MuseConnectionState; + museDetails?: boolean; + museEnabled?: boolean; + pendingApproval?: boolean; companyName?: string; simulate?: boolean; failTest?: boolean; stepDelayMs?: number; + /** The copy play releases simulated Muse progress after asserting real visible feedback. */ + beforeMuseSimulation?: () => Promise; shell?: boolean; preparing?: boolean; + invitationUnavailable?: boolean; + error?: string; } -/** Storybook owns simulation. No timer, fixture code, or fake status ships in the UI component. */ +/** + * Offline versioned Muse presentation only: copying this fixture simulates milestones; + * it never installs credentials, records operator delegation, or accepts work. + * Public contact, authenticated background reply, and lifecycle readiness stay separate. + */ export function ExternalAgentInvitePreview({ initialScreen = "entry", initialPreset = "dot", initialConnection = { phase: "waiting" }, - companyName = "Paperclip", simulate = true, failTest = false, stepDelayMs = 2000, shell = true, preparing = false, + initialMuseConnection = { paired: false, receiverDetected: false, backgroundReplyVerified: false, ready: false }, + museDetails = false, museEnabled = false, pendingApproval = false, + companyName = "Paperclip", simulate = true, failTest = false, stepDelayMs = 2000, beforeMuseSimulation, shell = true, preparing = false, invitationUnavailable = false, error, }: ExternalAgentInvitePreviewProps) { const [screen, setScreen] = useState<"entry" | "picker" | "setup" | "closed">(initialScreen); const [preset, setPreset] = useState(initialPreset); const [connection, setConnection] = useState(initialConnection); + const [museConnection, setMuseConnection] = useState(initialMuseConnection); + const [museDraft, setMuseDraft] = useState(museDetails ? { name: "Maia", role: "researcher" } : undefined); const [watching, setWatching] = useState(false); const [retrying, setRetrying] = useState(false); const [generation, setGeneration] = useState(1); useEffect(() => { - if (!simulate || !watching || connection.problem || connection.phase === "ready") return; + if (preset !== "muse" || !simulate || !watching || museConnection.problem || museConnection.ready) return; + const timer = window.setTimeout(() => setMuseConnection(previous => !previous.paired ? { ...previous, paired: true } + : !previous.receiverDetected ? { ...previous, receiverDetected: true } + : !previous.backgroundReplyVerified ? { ...previous, backgroundReplyVerified: true, finishing: true } + : { ...previous, finishing: false, ready: true }), stepDelayMs); + return () => window.clearTimeout(timer); + }, [preset, simulate, watching, museConnection, stepDelayMs]); + useEffect(() => { + if (preset === "muse" || !simulate || !watching || connection.problem || connection.phase === "ready") return; const phase = connection.phase; const next = { waiting: "connected", connected: "subscribed", subscribed: "testing", testing: "finishing", finishing: "ready" } as const; const timer = window.setTimeout(() => { @@ -54,14 +80,24 @@ export function ExternalAgentInvitePreview({ preset={screen === "picker" ? null : preset} companyName={companyName} busy={preparing} - prompt={preparing ? "" : preset === "dot" ? dotInvitePrompt(generation) : externalInvitePrompt} + error={error} + prompt={preparing || invitationUnavailable ? "" : preset === "muse" ? musePairing.setupInstruction : preset === "dot" ? dotInvitePrompt(generation) : externalInvitePrompt} + museEnabled={museEnabled} + museConnection={invitationUnavailable ? undefined : museConnection} + museDraft={museDraft} + onMuseDraftChange={setMuseDraft} + onMuseContinue={() => setMuseDraft(undefined)} + approvalHref={pendingApproval ? "/approvals/example-muse-hire" : undefined} connection={connection} - onSelect={value => { setPreset(value); setScreen("setup"); }} + onSelect={value => { setPreset(value); if (value === "muse") setMuseDraft({ name: "", role: "general" }); setScreen("setup"); }} onBack={() => setScreen("picker")} onClose={() => setScreen("closed")} - onCopied={() => { if (preset === "dot") setWatching(true); }} - onRetry={() => { setConnection(previous => ({ phase: previous.phase })); setRetrying(true); setWatching(true); }} - onNewPrompt={() => { setGeneration(value => value + 1); setConnection({ phase: "waiting" }); setWatching(false); }} + onCopied={() => { + if (preset === "muse" && beforeMuseSimulation) void beforeMuseSimulation().then(() => setWatching(true)); + else if (preset === "dot" || preset === "muse") setWatching(true); + }} + onRetry={() => { setMuseConnection(previous => ({ ...previous, problem: undefined })); setConnection(previous => ({ phase: previous.phase })); setRetrying(true); setWatching(true); }} + onNewPrompt={() => { setMuseConnection({ paired: false, receiverDetected: false, backgroundReplyVerified: false, ready: false }); setGeneration(value => value + 1); setConnection({ phase: "waiting" }); setWatching(false); }} /> diff --git a/ui/storybook/support/external-agent-invite/muse-fixtures.ts b/ui/storybook/support/external-agent-invite/muse-fixtures.ts new file mode 100644 index 00000000000..2f55fbe3862 --- /dev/null +++ b/ui/storybook/support/external-agent-invite/muse-fixtures.ts @@ -0,0 +1,47 @@ +import { MUSE_ASSET_VERSION, MUSE_CLIENT_VERSION, type MuseBinding, type MuseConnection, type MusePairing, type MuseStopBoundary } from "@paperclipai/shared"; + +/** Public, nonsecret fixture values only; the .example origin cannot connect a real Muse. */ +export const museBinding: MuseBinding = { + id: "11111111-1111-4111-8111-111111111111", generation: 2, revision: 7, status: "connected", + paired: true, receiverDetected: true, backgroundReplyVerified: false, + pairingExpiresAt: null, challengeExpiresAt: null, + lastReceiverContactAt: "2026-10-13T14:00:00Z", lastWorkerActivityAt: "2026-10-13T13:59:40Z", lastVerifiedReplyAt: null, + credentialState: "active", refreshExpiresAt: "2026-11-12T14:00:00Z", + receiverContactTrust: "public_untrusted", contactPersistenceLagMs: 30000, clientVersion: MUSE_CLIENT_VERSION, qualification: null, + liveAssignments: 0, uncertainOperations: 0, pendingInputs: 0, + cleanup: { authenticatedAfterRevocation: false, pending: false, detectorRemovalRequested: false, detectorRemoved: false, workerQuiescenceReported: false, expiresAt: null }, + stop: { status: "none", nativeEffectsUnknown: false, boundary: null, bindingRevision: null }, +}; +export const museConnection: MuseConnection = { + enabled: true, publicOrigin: "https://paperclip.example", agentStatus: "idle", agentLifecycleState: "verifying", + canConfigureConnection: true, binding: museBinding, usage: null, cost: null, +}; +export const musePairing: MusePairing = { + bindingId: museBinding.id, generation: museBinding.generation, revision: museBinding.revision, + ticket: "STORYBOOK-NOT-A-REAL-TICKET", expiresAt: "2099-10-10T14:10:00Z", assetVersion: MUSE_ASSET_VERSION, + setupInstruction: `Storybook illustration only. The .example hostname, scope IDs, and ticket are public fixture values, not credentials. This example cannot connect a real agent or grant task authority. + +Production setup connects the exact company, agent, and binding through Muse's supported SecureVault/authd OAuth consent. credentials.request_api_access uses oauth2_code, public-client registration and S256 PKCE; the single-use setup ticket is carried only into the OAuth authorization URL. Authd stores credentials and refreshes them automatically. The asset ${MUSE_ASSET_VERSION} client uses the supported dynamic_credentials surrogate helper. Never copy access/refresh tokens into files or environment variables. + +After consent, install the immutable asset ${MUSE_ASSET_VERSION} client, detector and instructions using their pinned production manifest. Configure only the operator-approved nonsecret company/agent/binding scope. Complete Muse's actual site and tool permission prompts; public preflight grants no task authority. Verify authenticated scoped status before registering the five-second public detector and the discoverable standing-delegation skill. + +In production, the operator's scoped installation delegates receiving, validating and autonomously accepting ordinary tasks for that exact company, agent and binding, including later detector wakes and fresh conversations. Task text cannot expand the delegation. Observe the exact native accepted lease before any task effect; Muse's private tool, site, safety and approval gates remain in force. + +Public receiver contact is untrusted and may take up to 30 seconds to persist. Only an independent authenticated background reply and the agent lifecycle gate establish readiness. Disconnect immediately revokes OAuth authority; post-revocation authenticated cleanup is unavailable, and detector removal, worker stopping and unknown effects remain unconfirmed.`, +}; +export const museStopBoundary: MuseStopBoundary = { + bindingId: museBinding.id, generation: museBinding.generation, + assignmentId: "22222222-2222-4222-8222-222222222222", runId: "33333333-3333-4333-8333-333333333333", + turnId: "turn-muse-example", assignmentRevision: 3, stopNonce: "44444444-4444-4444-8444-444444444444", operationBoundary: "task-document-save", +}; +export const stoppedMuseConnection: MuseConnection = { ...museConnection, enabled: false, agentStatus: "paused", agentLifecycleState: "paused", + binding: { ...museBinding, status: "revoked", credentialState: "revoked", refreshExpiresAt: null, liveAssignments: 0, uncertainOperations: 1, pendingInputs: 1, + cleanup: { authenticatedAfterRevocation: false, pending: true, detectorRemovalRequested: false, detectorRemoved: false, workerQuiescenceReported: false, expiresAt: null }, + stop: { status: "cannot_confirm", nativeEffectsUnknown: true, boundary: museStopBoundary, bindingRevision: museBinding.revision } } }; + +/** Each play/render gets its own gate; simulated progress follows observed copy feedback. */ +export function createMuseCopyFeedbackGate() { + let feedbackObserved!: () => void; + const feedback = new Promise(resolve => { feedbackObserved = resolve; }); + return { waitForFeedback: () => feedback, feedbackObserved }; +}