Compare commits
10 Commits
464a49bb6d
...
47935e22a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 47935e22a0 | |||
| 04efa31c40 | |||
| dc9db7c732 | |||
| bed92c28e7 | |||
| b573646f2d | |||
| a18c8c630c | |||
| 4be98d7080 | |||
| 051d0ce469 | |||
| 083c146aac | |||
| a997f13601 |
+23
-2
@@ -3,10 +3,17 @@ NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
|
||||
|
||||
# Runtime gateway URL — takes effect on restart without a rebuild.
|
||||
# Use this instead of NEXT_PUBLIC_GATEWAY_URL when you want to change the
|
||||
# gateway endpoint without re-running `npm run build`. Also used as a
|
||||
# fallback when openclaw.json is not present.
|
||||
# gateway endpoint without re-running `npm run build`.
|
||||
# CLAW3D_GATEWAY_URL=ws://localhost:18789
|
||||
# CLAW3D_GATEWAY_TOKEN=
|
||||
# Optional: tell Studio which backend that runtime gateway URL represents.
|
||||
# Valid values: openclaw, hermes, demo, custom
|
||||
# CLAW3D_GATEWAY_ADAPTER_TYPE=openclaw
|
||||
|
||||
|
||||
# HERMES_API_URL=http://localhost:8642
|
||||
# HERMES_API_KEY=change-me-local-dev
|
||||
# HERMES_MODEL=hermes-agent
|
||||
|
||||
# Debug UI
|
||||
DEBUG=true
|
||||
@@ -23,6 +30,20 @@ DEBUG=true
|
||||
# OPENCLAW_GATEWAY_SSH_PORT=
|
||||
# OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING=accept-new
|
||||
|
||||
# Hermes Agent adapter (alternative to OpenClaw)
|
||||
# Run `npm run hermes-adapter` to start the adapter, then connect Claw3D to ws://localhost:18789
|
||||
# HERMES_API_URL=http://localhost:8642
|
||||
# HERMES_API_KEY=
|
||||
# HERMES_ADAPTER_PORT=18789
|
||||
# HERMES_MODEL=hermes
|
||||
# HERMES_AGENT_NAME=Hermes
|
||||
# If CLAW3D_GATEWAY_URL is unset, Studio can still detect this local adapter port.
|
||||
|
||||
# Demo gateway (no OpenClaw or Hermes required)
|
||||
# Run `npm run demo-gateway` and connect Claw3D to ws://localhost:18789
|
||||
# DEMO_ADAPTER_PORT=18789
|
||||
# If CLAW3D_GATEWAY_URL is unset, Studio can still detect this local adapter port.
|
||||
|
||||
# Optional: voice features
|
||||
# ELEVENLABS_API_KEY=
|
||||
# ELEVENLABS_VOICE_ID=21m00Tcm4TlvDq8ikWAM
|
||||
|
||||
@@ -9,3 +9,33 @@ Do not modify the OpenClaw source code. When the user asks for changes, they are
|
||||
If you use local private overlay instructions, keep them outside the repository and do not commit them here.
|
||||
|
||||
Do not commit personal, environment-specific, or secret instructions to this repository.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Service overview
|
||||
|
||||
Claw3D is a Next.js 16 frontend (TypeScript, React 19, Three.js, Phaser) for OpenClaw. It runs a custom Node.js server (`server/index.js`) that bundles a same-origin WebSocket proxy to the upstream OpenClaw Gateway. No database or Docker is required. The only hard system dependency is Node.js 20+ with npm 10+.
|
||||
|
||||
### Running the app
|
||||
|
||||
- `npm run dev` starts the dev server on port 3000 via the custom server (`node server/index.js --dev`).
|
||||
- The app requires a running OpenClaw Gateway to show agent data. Without one, the UI loads but shows the gateway connection form. This is expected and not an error.
|
||||
- `.env` is copied from `.env.example`; see `README.md` "Configuration" for variable descriptions.
|
||||
|
||||
### Lint, typecheck, and tests
|
||||
|
||||
- `npm run lint` — ESLint. The codebase has a small number of pre-existing warnings and one pre-existing error (in `RetroOffice3D.tsx`).
|
||||
- `npm run typecheck` — `tsc --noEmit`. Pre-existing type errors exist in some test files (`agentChatPanel-*.test.ts`) due to a stale `onOpenSettings` prop.
|
||||
- `npm run test -- --run` — Vitest unit tests (use `--run` for single-run mode). A few pre-existing failures exist.
|
||||
- `npm run e2e` — Playwright E2E tests; requires `npx playwright install` first.
|
||||
- `npm run smoke:dev-server` — starts the dev server on a random port and verifies HTTP response.
|
||||
|
||||
### Build
|
||||
|
||||
- `npm run build` — Next.js production build. Expect a non-blocking warning about `Can't resolve 'openclaw'`; the `openclaw` npm package is resolved optionally at runtime and is not bundled.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- The `openclaw` npm package is not a dependency of this repo. The build warning about it is harmless.
|
||||
- `npm run studio:setup` is interactive (TTY prompts) — avoid running it in non-interactive cloud environments.
|
||||
- Vitest runs in watch mode by default; always pass `--run` for CI/cloud agent use.
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Claw3D - 3D agent visualization for OpenClaw.
|
||||
# Multi-stage build: install prod deps -> build Next.js -> run with custom server.
|
||||
|
||||
FROM node:20-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts --omit=dev
|
||||
|
||||
FROM node:20-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# Build-time gateway URL (overridden at runtime by CLAW3D_GATEWAY_URL).
|
||||
ENV NEXT_PUBLIC_GATEWAY_URL=ws://127.0.0.1:18789
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Copy built app + custom server + production node_modules only.
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/server ./server
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
@@ -0,0 +1,177 @@
|
||||
## Summary
|
||||
|
||||
This PR started as the Hermes/demo/runtime seam work and has now grown into
|
||||
the main runtime stabilization branch for Claw3D.
|
||||
|
||||
The branch now does four things:
|
||||
|
||||
- keeps the Hermes adapter path working and easier to debug
|
||||
- makes backend selection more explicit and truthful in Studio
|
||||
- extends the runtime seam beyond OpenClaw-only assumptions
|
||||
- fixes capability mismatches that were exposing unsupported UI/actions
|
||||
|
||||
This is now the right branch for the Hermes/runtime review path.
|
||||
|
||||
## What Changed Since The Earlier PR Notes
|
||||
|
||||
The earlier summary for this work is now outdated.
|
||||
|
||||
This branch no longer only has a single OpenClaw-shaped runtime path.
|
||||
It now includes:
|
||||
|
||||
- persisted backend selection in Studio settings
|
||||
- active backend identity surfaced in the UI
|
||||
- real provider selection across:
|
||||
- `openclaw`
|
||||
- `hermes`
|
||||
- `demo`
|
||||
- `custom`
|
||||
- Hermes/demo hello identity support
|
||||
- stronger office/runtime alignment so the office is less tightly coupled to
|
||||
raw gateway assumptions
|
||||
- runtime and reconnect fixes discovered while exercising Hermes in the real UI
|
||||
|
||||
## Included In This PR
|
||||
|
||||
### 1. Hermes/runtime stability and reconnect behavior
|
||||
|
||||
- improved Hermes adapter/runtime selection handling
|
||||
- reduced OpenClaw-specific assumptions leaking into Hermes/demo/custom paths
|
||||
- stabilized reconnect and connect-screen behavior in the office flow
|
||||
- improved local backend switching/debuggability from inside Studio
|
||||
- Studio now only auto-connects from a verified last-known-good runtime state;
|
||||
otherwise it shows the connect overlay and waits for explicit operator choice
|
||||
- fixed a gateway connect-screen hydration bug caused by invalid HTML around the
|
||||
inline loading avatar
|
||||
- fixed an onboarding hydration mismatch caused by reading browser storage
|
||||
during initial render
|
||||
- kept the office mounted while gateway loading/prompt states are shown as
|
||||
overlays instead of full page swaps
|
||||
- kept the office visible behind the runtime overlay, while making the connect
|
||||
modal itself more opaque/readable
|
||||
- aligned Studio proxy URL resolution back to upstream `main`
|
||||
- added a small first auto-connect delay for Hermes/Demo so the initial browser
|
||||
ws proxy attempt is less likely to race Next dev startup
|
||||
- removed destructive office camera resets that were firing on runtime-status
|
||||
and agent-count changes
|
||||
- manual connect now cancels pending auto-connect/retry overlap before opening a
|
||||
new Hermes/Demo attempt
|
||||
- Hermes/Demo gateway connects now allow a second internal attempt before
|
||||
surfacing failure, which materially reduces the first-connect miss pattern in
|
||||
local Studio testing
|
||||
|
||||
### 2. Truthful backend selection
|
||||
|
||||
- Studio persists the selected backend instead of only URL/token
|
||||
- the UI shows selected vs active backend
|
||||
- backend settings are now clearer and less misleading when switching between:
|
||||
- OpenClaw
|
||||
- Hermes
|
||||
- Demo
|
||||
- Custom
|
||||
|
||||
### 3. Runtime seam expansion
|
||||
|
||||
- provider selection is no longer effectively OpenClaw-only
|
||||
- Hermes and Demo use real provider paths
|
||||
- groundwork for direct HTTP-backed custom runtimes is included on this branch
|
||||
|
||||
### 4. Office/runtime alignment
|
||||
|
||||
- office bootstrap and runtime handling are less coupled to raw gateway transport
|
||||
- blank-office / reconnect edge cases were investigated and several stability
|
||||
fixes landed during Hermes validation
|
||||
|
||||
### 5. Luke’s findings addressed
|
||||
|
||||
The Demo cron mismatch called out in review is fixed.
|
||||
|
||||
Changes:
|
||||
|
||||
- `Automations` is hidden when `supportsCapability("cron")` is false
|
||||
- cron create/run/delete now fail fast with a clear user-facing error when the
|
||||
runtime does not support cron
|
||||
- added tests covering:
|
||||
- no Automations tab when cron is unsupported
|
||||
- cron mutations blocked when cron capability is unavailable
|
||||
|
||||
## Review Follow-Up For Luke
|
||||
|
||||
Addressed review finding:
|
||||
|
||||
- Demo runtime no longer exposes unsupported cron mutation behavior through the
|
||||
settings flow
|
||||
- unsupported cron actions are blocked before any RPC is sent
|
||||
|
||||
Files directly involved in that fix:
|
||||
|
||||
- `src/features/agents/screens/AgentsPageScreen.tsx`
|
||||
- `src/features/agents/operations/settingsSidebarTabs.ts`
|
||||
- `src/features/agents/operations/useAgentSettingsMutationController.ts`
|
||||
- `tests/unit/settingsSidebarTabs.test.ts`
|
||||
- `tests/unit/useAgentSettingsMutationController.test.ts`
|
||||
|
||||
## Validation
|
||||
|
||||
- [x] `npm run typecheck`
|
||||
- [x] `npx vitest run tests/unit/useGatewayConnection.test.ts tests/unit/useRuntimeConnection.test.ts`
|
||||
- [x] `npx vitest run tests/unit/useAgentSettingsMutationController.test.ts tests/unit/settingsSidebarTabs.test.ts`
|
||||
- [x] `npx vitest run tests/unit/demoGatewayAdapter.test.ts`
|
||||
|
||||
## Scope Notes
|
||||
|
||||
This PR still does **not** claim:
|
||||
|
||||
- ACP-native Hermes integration
|
||||
- process launch from Studio
|
||||
- fully mature custom-runtime streaming/session semantics
|
||||
- finished office-system feature implementation
|
||||
|
||||
## Office Systems Note
|
||||
|
||||
The office-system roadmap/spec work exists, but it is **not** part of this
|
||||
implementation scope.
|
||||
|
||||
That work was split into feature issue `#90`.
|
||||
|
||||
Current docs/specs are planning artifacts only:
|
||||
|
||||
- bulletin board
|
||||
- whiteboard
|
||||
- meeting room workflow
|
||||
- QA department
|
||||
- desk progression
|
||||
- hierarchy/teams
|
||||
|
||||
Nothing from those office-system specs should be interpreted as already built
|
||||
in this PR.
|
||||
|
||||
## Suggested Reviewer Focus
|
||||
|
||||
- Hermes adapter/runtime stability in the real Studio/office flow
|
||||
- whether backend selection is now clearer and less misleading
|
||||
- whether capability claims are honest, especially for Demo
|
||||
- whether the runtime/provider seam changes remain coherent for upstream use
|
||||
|
||||
## Remaining Follow-Up Work
|
||||
|
||||
- ACP-backed Hermes provider as separate work
|
||||
- better Hermes profile/model visibility in settings
|
||||
- stronger reconnect persistence behavior
|
||||
- richer multi-agent Hermes flows
|
||||
- continued custom-runtime and downstream orchestrator work
|
||||
|
||||
## Current Operator Status
|
||||
|
||||
As of the latest local verification on April 1, 2026:
|
||||
|
||||
- Hermes now reaches a stable connected office state without the earlier heavy
|
||||
scene flicker/reset behavior
|
||||
- the office remains visible behind gateway loading/error overlays
|
||||
- the Demo cron capability mismatch called out in review is fixed end-to-end
|
||||
- one residual Hermes issue may still remain in some local dev sessions:
|
||||
the first automatic connection attempt can miss once before a manual
|
||||
`Hermes backend` + `Connect` succeeds cleanly
|
||||
|
||||
That remaining first-attempt Hermes miss is now the only notable runtime issue
|
||||
still observed in manual browser testing for this pass.
|
||||
@@ -29,17 +29,23 @@ You walk through your AI workplace.
|
||||
|
||||
## What Claw3D Is
|
||||
|
||||
OpenClaw is the intelligence and task-execution layer.
|
||||
|
||||
Claw3D is the visualization and interaction layer.
|
||||
|
||||
Today it can sit on top of:
|
||||
|
||||
- OpenClaw through the existing gateway flow
|
||||
- Hermes through the bundled WebSocket adapter
|
||||
- a direct HTTP `custom` runtime provider for orchestrator-backed stacks
|
||||
- a built-in demo gateway for office exploration without a real agent framework
|
||||
|
||||
In practical terms, this app gives you:
|
||||
|
||||
- a live `/office` retro-office environment where agents appear as workers moving through a shared 3D world
|
||||
- an `/office/builder` surface for editing and publishing office layouts
|
||||
- a gateway-first architecture that keeps agent state in OpenClaw while Studio stores local UI preferences
|
||||
- a gateway-first architecture that keeps runtime state in the connected backend while Studio stores local UI preferences
|
||||
- a backend-neutral runtime seam inside Studio so additional providers can be integrated without rewriting the whole UI
|
||||
|
||||
This repository does not build or modify the OpenClaw runtime itself. It is the frontend and proxy layer that connects to an existing OpenClaw Gateway.
|
||||
This repository does not build the upstream runtimes themselves. It is the frontend, Studio, and adapter/proxy layer that connects to a runtime speaking the Claw3D gateway protocol.
|
||||
|
||||
## Why It Exists
|
||||
|
||||
@@ -71,13 +77,16 @@ Requirements:
|
||||
|
||||
- Node.js 20+ recommended.
|
||||
- npm 10+ recommended.
|
||||
- A working OpenClaw installation with a reachable Gateway URL and token.
|
||||
- One of:
|
||||
- a working OpenClaw installation with a reachable Gateway URL and token
|
||||
- Hermes with the bundled adapter
|
||||
- the built-in demo gateway for local exploration
|
||||
|
||||
Prerequisite:
|
||||
|
||||
- Claw3D does not install, build, or run OpenClaw for you.
|
||||
- Before starting Claw3D, make sure your OpenClaw gateway is already running and that you know the gateway URL and token you want Studio to use.
|
||||
- This repository is the UI and Studio/proxy layer only.
|
||||
- Claw3D does not install or build OpenClaw or Hermes for you.
|
||||
- Before starting Claw3D against a real backend, make sure your chosen runtime is already running and that you know the gateway URL and token Studio should use.
|
||||
- For a no-framework local office demo, run the bundled demo gateway instead.
|
||||
- If you need a full cross-machine setup guide (OpenClaw + Tailscale + Claw3D), follow [`TUTORIAL.md`](TUTORIAL.md).
|
||||
|
||||
Run from source:
|
||||
@@ -91,6 +100,65 @@ npm run dev
|
||||
```
|
||||
|
||||
Then open `http://localhost:3000` and configure the gateway URL and token in Studio.
|
||||
Studio now also persists the selected backend mode (`OpenClaw`, `Hermes`, `Demo`, or `Custom`) and
|
||||
shows the active backend reported by the connected gateway.
|
||||
|
||||
### Custom runtime mode
|
||||
|
||||
If you are integrating an orchestrator-backed runtime through the `custom`
|
||||
provider seam, start your runtime first, then start Claw3D:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then open `http://localhost:3000`, choose `Custom backend`, and point the
|
||||
upstream URL at your runtime boundary, for example:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:7770
|
||||
```
|
||||
|
||||
Current `custom` runtime expectations:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /state`
|
||||
- `GET /registry`
|
||||
- `POST /v1/chat/completions`
|
||||
|
||||
The browser does not call that runtime directly. Claw3D proxies the
|
||||
`custom` provider through its own same-origin route at
|
||||
`/api/runtime/custom`, which avoids browser-side CORS problems and keeps
|
||||
the provider transport separate from the OpenClaw/Hermes gateway path.
|
||||
|
||||
### Demo mode
|
||||
|
||||
If you only want to see the office and agent interactions without installing OpenClaw or Hermes:
|
||||
|
||||
```bash
|
||||
npm run demo-gateway
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then connect Studio to:
|
||||
|
||||
```text
|
||||
ws://localhost:18789
|
||||
```
|
||||
|
||||
This starts a mock local gateway with demo agents, streaming chat, session previews, and office presence.
|
||||
In the connect screen, choose `Demo backend`, then connect.
|
||||
|
||||
### Hermes adapter
|
||||
|
||||
If you want to use Hermes instead of OpenClaw:
|
||||
|
||||
```bash
|
||||
npm run hermes-adapter
|
||||
npm run dev
|
||||
```
|
||||
|
||||
See [`docs/hermes-gateway.md`](docs/hermes-gateway.md) for setup details and current scope.
|
||||
|
||||
For a local gateway on the same machine, the usual upstream URL is:
|
||||
|
||||
@@ -98,6 +166,8 @@ For a local gateway on the same machine, the usual upstream URL is:
|
||||
ws://localhost:18789
|
||||
```
|
||||
|
||||
In the connect screen, choose `Hermes backend`, then connect.
|
||||
|
||||
## How It Connects
|
||||
|
||||
Claw3D uses two separate network hops:
|
||||
@@ -138,6 +208,17 @@ Alternative with SSH:
|
||||
3. Set `STUDIO_ACCESS_TOKEN` if Studio binds to a public host.
|
||||
4. Configure the gateway URL and token inside Studio.
|
||||
|
||||
### Studio on LAN or Tailscale for other devices
|
||||
|
||||
1. Start Studio with `HOST=0.0.0.0` (or a specific LAN/Tailscale host).
|
||||
2. Set `STUDIO_ACCESS_TOKEN` before exposing Studio beyond localhost.
|
||||
3. Open Claw3D from the LAN/Tailscale address instead of `localhost`.
|
||||
4. If you are connecting to a remote OpenClaw gateway, remember device approval is per browser/device. A new browser may still require:
|
||||
|
||||
```bash
|
||||
openclaw devices approve --latest
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js App Router, React, and TypeScript for the main web application.
|
||||
@@ -157,8 +238,13 @@ Common environment variables:
|
||||
|
||||
- `HOST` and `PORT` control the Studio server bind address and port.
|
||||
- `STUDIO_ACCESS_TOKEN` protects Studio when binding to a public host.
|
||||
- `UPSTREAM_ALLOWLIST` restricts which upstream gateway hosts Studio may proxy to. Set this in production.
|
||||
- `CUSTOM_RUNTIME_ALLOWLIST` restricts which hosts `/api/runtime/custom` may fetch. If unset, it falls back to `UPSTREAM_ALLOWLIST`.
|
||||
- `NEXT_PUBLIC_GATEWAY_URL` provides the default upstream gateway URL when Studio settings are empty. **Note:** this is a build-time variable — changes require `npm run build` to take effect.
|
||||
- `CLAW3D_GATEWAY_URL` and `CLAW3D_GATEWAY_TOKEN` provide a runtime alternative to `NEXT_PUBLIC_GATEWAY_URL` that takes effect on server restart without a rebuild. These are also used as a fallback when `openclaw.json` is not present.
|
||||
- `CLAW3D_GATEWAY_URL` and `CLAW3D_GATEWAY_TOKEN` provide a runtime alternative to `NEXT_PUBLIC_GATEWAY_URL` that takes effect on server restart without a rebuild.
|
||||
- `CLAW3D_GATEWAY_ADAPTER_TYPE` can pair with `CLAW3D_GATEWAY_URL` to mark those runtime defaults as `openclaw`, `hermes`, `demo`, or `custom`.
|
||||
- If `CLAW3D_GATEWAY_URL` is not set, Studio can still surface local Hermes or demo adapter defaults from `HERMES_ADAPTER_PORT` / `DEMO_ADAPTER_PORT`.
|
||||
- OpenClaw file defaults still come from `~/.openclaw/openclaw.json` when present.
|
||||
- `OPENCLAW_STATE_DIR` and `OPENCLAW_CONFIG_PATH` override the default OpenClaw paths.
|
||||
- `OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`, `OPENCLAW_GATEWAY_SSH_PORT`, and `OPENCLAW_GATEWAY_SSH_STRICT_HOST_KEY_CHECKING` support advanced gateway-host operations over SSH when needed.
|
||||
- `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, and `ELEVENLABS_MODEL_ID` enable voice reply integration.
|
||||
@@ -168,6 +254,8 @@ See [`.env.example`](.env.example) for the full local development template.
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` starts the Studio dev server.
|
||||
- `npm run hermes-adapter` starts the Hermes WebSocket adapter.
|
||||
- `npm run demo-gateway` starts the built-in mock gateway for demo mode.
|
||||
- `npm run build` builds the production Next.js app.
|
||||
- `npm run start` starts the production server.
|
||||
- `npm run lint` runs ESLint.
|
||||
@@ -189,6 +277,7 @@ See [`.env.example`](.env.example) for the full local development template.
|
||||
- [`ROADMAP.md`](ROADMAP.md): near-term priorities and contributor-friendly work areas.
|
||||
- [`docs/pi-chat-streaming.md`](docs/pi-chat-streaming.md): gateway runtime streaming and transcript rendering.
|
||||
- [`docs/permissions-sandboxing.md`](docs/permissions-sandboxing.md): Studio permissions and OpenClaw behavior.
|
||||
- [`docs/hermes-gateway.md`](docs/hermes-gateway.md): Hermes adapter setup, capabilities, and current limitations.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
@@ -202,7 +291,9 @@ If the UI loads but Connect fails, the problem is usually on the Studio -> Gatew
|
||||
|
||||
- Confirm the upstream URL and token in Studio settings.
|
||||
- `EPROTO` or `wrong version number` usually means `wss://` was used against a non-TLS endpoint.
|
||||
- `INVALID_REQUEST` errors mentioning `minProtocol` or `maxProtocol` usually mean the gateway is too old for Claw3D protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway`.
|
||||
- `401 Studio access token required` usually means `STUDIO_ACCESS_TOKEN` is enabled and the request is missing the expected `studio_access` cookie.
|
||||
- If `/api/runtime/custom` returns a blocked-host error in production, set `CUSTOM_RUNTIME_ALLOWLIST` or include the runtime host in `UPSTREAM_ALLOWLIST`.
|
||||
- Helpful proxy error codes include `studio.gateway_url_missing`, `studio.gateway_token_missing`, `studio.upstream_error`, and `studio.upstream_closed`.
|
||||
|
||||
Marketplace skill installs now use a gateway-native workspace flow and do not require enabling SSH on the user machine.
|
||||
|
||||
@@ -25,6 +25,7 @@ We aim to acknowledge reports promptly, investigate them, and coordinate a fix a
|
||||
|
||||
- Studio gateway settings are stored on disk in plaintext under the local OpenClaw state directory.
|
||||
- The current UI loads the configured upstream gateway URL/token into browser memory at runtime, even though those values are not stored in browser persistent storage.
|
||||
- There is currently no built-in cookie issuance/login flow for `STUDIO_ACCESS_TOKEN`; deployments that enable the access gate must provision the `studio_access` cookie outside the app.
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -35,3 +36,10 @@ Please report issues related to:
|
||||
- Remote code execution or privilege escalation paths.
|
||||
- Unsafe filesystem, proxy, or network behavior.
|
||||
- Dependency vulnerabilities that materially affect this project.
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- In production, set `UPSTREAM_ALLOWLIST` for the Studio gateway proxy.
|
||||
- In production, set `CUSTOM_RUNTIME_ALLOWLIST` if you use `/api/runtime/custom`. If unset, it falls back to `UPSTREAM_ALLOWLIST`.
|
||||
- Empty allowlists are intended for local development only.
|
||||
- If you enable `STUDIO_ACCESS_TOKEN`, you must also provision the `studio_access` cookie through your deployment/auth layer.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Security Hardening
|
||||
|
||||
Changes applied to the upstream Claw3D codebase for production use.
|
||||
|
||||
## Critical Fixes
|
||||
|
||||
### 1. Telemetry Removed
|
||||
- `@vercel/otel` dependency removed from package.json
|
||||
- `src/instrumentation.ts` replaced with no-op
|
||||
- No data is sent to Vercel or any external telemetry service
|
||||
|
||||
### 2. Constant-Time Token Comparison
|
||||
- `server/access-gate.js` now uses `crypto.timingSafeEqual()` for
|
||||
token validation, preventing timing attacks
|
||||
|
||||
### 3. Auth Rate Limiting
|
||||
- In-memory rate limiter added to access gate for failed auth attempts
|
||||
only (10 failures per IP per 60 seconds)
|
||||
- Prevents brute-force token guessing
|
||||
|
||||
### 4. WebSocket Frame Validation
|
||||
- Maximum frame size: 256 KB (prevents resource exhaustion)
|
||||
- Per-connection rate limit: 30 frames/second
|
||||
- Connections closed on violation
|
||||
|
||||
### 5. Upstream URL Allowlist
|
||||
- `UPSTREAM_ALLOWLIST` env var restricts which gateway hosts the
|
||||
WebSocket proxy can connect to
|
||||
- Prevents DNS hijacking or SSRF through the proxy
|
||||
- Required in production; empty allowlist is permitted in dev only
|
||||
|
||||
### 6. Custom Runtime Proxy Allowlist
|
||||
- `/api/runtime/custom` now enforces `CUSTOM_RUNTIME_ALLOWLIST`
|
||||
- Falls back to `UPSTREAM_ALLOWLIST` if no custom-specific allowlist is set
|
||||
- Required in production; empty allowlist is permitted in dev only
|
||||
|
||||
### 7. Security Headers
|
||||
- Baseline response headers now set from `next.config.ts`
|
||||
- Includes CSP, `X-Content-Type-Options`, `Referrer-Policy`,
|
||||
`Permissions-Policy`, and cross-origin isolation headers
|
||||
|
||||
### 8. Media Route Symlink Rejection
|
||||
- `/api/gateway/media` now rejects symlinked local files
|
||||
- Realpath is verified inside the allowed root before reading bytes
|
||||
|
||||
## Remaining Items (Phase 2)
|
||||
|
||||
- Encrypt gateway tokens at rest
|
||||
- Add Zod schema validation for all API inputs
|
||||
- Implement secure cookie flags (HttpOnly, Secure, SameSite)
|
||||
- Sanitize error messages before sending to clients
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: task-manager
|
||||
description: Capture actionable user requests as persistent tasks, update task status as work progresses, and keep a shared task store in sync. Use when a user asks an agent to do work, check progress, block a task, complete a task, or manage the Kanban board.
|
||||
metadata: {"openclaw":{"skillKey":"task-manager"}}
|
||||
---
|
||||
|
||||
# Task Manager
|
||||
|
||||
Use this skill for task capture and task lifecycle updates.
|
||||
|
||||
## Trigger
|
||||
|
||||
```json
|
||||
{
|
||||
"activation": {
|
||||
"anyPhrases": [
|
||||
"add a task",
|
||||
"create a task",
|
||||
"track this task",
|
||||
"task status",
|
||||
"mark this done",
|
||||
"block this task",
|
||||
"what tasks do we have"
|
||||
]
|
||||
},
|
||||
"movement": {
|
||||
"target": "desk",
|
||||
"skipIfAlreadyThere": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also use this skill even when those exact phrases are absent if the latest user message is an actionable work request. If the user asks the agent to do something, that request must become a task before the agent proceeds.
|
||||
|
||||
## Storage location
|
||||
|
||||
The authoritative task file is:
|
||||
|
||||
- `${OPENCLAW_STATE_DIR}/claw3d/task-manager/tasks.json` when `OPENCLAW_STATE_DIR` is set.
|
||||
- `~/.openclaw/claw3d/task-manager/tasks.json` otherwise.
|
||||
|
||||
Always treat that file as the shared source of truth for the Kanban board.
|
||||
|
||||
## Required workflow
|
||||
|
||||
1. Read the task file before handling an actionable request.
|
||||
2. If the file does not exist, create it with the schema in this document.
|
||||
3. If the latest user message is actionable and no matching active task exists, create one immediately.
|
||||
4. Before starting execution, ensure the task is `todo` or move it to `in_progress`.
|
||||
5. If work cannot continue, set the task to `blocked` and record a short reason in `notes`.
|
||||
6. When work is finished, set the task to `done`.
|
||||
7. When work needs user review or confirmation, set the task to `review`.
|
||||
8. After every mutation, write the full updated JSON back to disk.
|
||||
|
||||
## Matching rules
|
||||
|
||||
- Match first by `externalThreadId` when the request comes from a stable thread or conversation.
|
||||
- Otherwise match by a concise normalized title that preserves user intent.
|
||||
- Avoid creating duplicate active tasks for the same request.
|
||||
|
||||
## Task fields
|
||||
|
||||
Each task must include:
|
||||
|
||||
- `id`
|
||||
- `title`
|
||||
- `description`
|
||||
- `status`
|
||||
- `source`
|
||||
- `sourceEventId`
|
||||
- `assignedAgentId`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
- `playbookJobId`
|
||||
- `runId`
|
||||
- `channel`
|
||||
- `externalThreadId`
|
||||
- `lastActivityAt`
|
||||
- `notes`
|
||||
- `isArchived`
|
||||
- `isInferred`
|
||||
- `history`
|
||||
|
||||
## Status rules
|
||||
|
||||
- New actionable requests start as `todo` unless work has already begun.
|
||||
- Move to `in_progress` when the agent is actively working.
|
||||
- Move to `blocked` when progress depends on missing input, credentials, approvals, or failures.
|
||||
- Move to `review` when the work is ready for inspection or handoff.
|
||||
- Move to `done` only when the requested work is complete.
|
||||
|
||||
## File format
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-03-30T00:00:00.000Z",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "research-mtulsa-com",
|
||||
"title": "Research mtulsa.com",
|
||||
"description": "Review mtulsa.com and summarize the site, positioning, and improvement opportunities.",
|
||||
"status": "in_progress",
|
||||
"source": "claw3d_manual",
|
||||
"sourceEventId": null,
|
||||
"assignedAgentId": "main",
|
||||
"createdAt": "2026-03-30T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"playbookJobId": null,
|
||||
"runId": null,
|
||||
"channel": "telegram",
|
||||
"externalThreadId": "telegram:direct:6866695577",
|
||||
"lastActivityAt": "2026-03-30T00:10:00.000Z",
|
||||
"notes": [],
|
||||
"isArchived": false,
|
||||
"isInferred": false,
|
||||
"history": [
|
||||
{
|
||||
"at": "2026-03-30T00:00:00.000Z",
|
||||
"type": "created",
|
||||
"note": "Task created.",
|
||||
"fromStatus": null,
|
||||
"toStatus": "todo"
|
||||
},
|
||||
{
|
||||
"at": "2026-03-30T00:10:00.000Z",
|
||||
"type": "status_changed",
|
||||
"note": null,
|
||||
"fromStatus": "todo",
|
||||
"toStatus": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Response rules
|
||||
|
||||
- Briefly confirm which task was created or updated.
|
||||
- If the request is ambiguous, ask a clarifying question instead of guessing.
|
||||
- Do not claim work is complete without updating the task status.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "research-mtulsa-com",
|
||||
"title": "Research mtulsa.com",
|
||||
"description": "Review mtulsa.com and summarize the site, positioning, and improvement opportunities.",
|
||||
"status": "in_progress",
|
||||
"source": "claw3d_manual",
|
||||
"sourceEventId": null,
|
||||
"assignedAgentId": "main",
|
||||
"createdAt": "2026-03-30T00:00:00.000Z",
|
||||
"updatedAt": "2026-03-30T00:10:00.000Z",
|
||||
"playbookJobId": null,
|
||||
"runId": null,
|
||||
"channel": "telegram",
|
||||
"externalThreadId": "telegram:direct:6866695577",
|
||||
"lastActivityAt": "2026-03-30T00:10:00.000Z",
|
||||
"notes": [],
|
||||
"isArchived": false,
|
||||
"isInferred": false,
|
||||
"history": [
|
||||
{
|
||||
"at": "2026-03-30T00:00:00.000Z",
|
||||
"type": "created",
|
||||
"note": "Task created.",
|
||||
"fromStatus": null,
|
||||
"toStatus": "todo"
|
||||
},
|
||||
{
|
||||
"at": "2026-03-30T00:10:00.000Z",
|
||||
"type": "status_changed",
|
||||
"note": null,
|
||||
"fromStatus": "todo",
|
||||
"toStatus": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
# Agent State Model Spec
|
||||
|
||||
> Seventh concrete office-system feature for Claw3D, formalizing operational agent states before any deeper simulation or affective layer is introduced.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a public, office-visible agent state model that explains how an agent is currently operating.
|
||||
|
||||
The state model should answer:
|
||||
|
||||
- what is this agent doing right now?
|
||||
- are they available?
|
||||
- are they blocked?
|
||||
- are they overloaded?
|
||||
- should the office route more work to them?
|
||||
|
||||
This state model should be understandable to users and stable across backends.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Start with operational states, not emotional roleplay.
|
||||
|
||||
That means the visible model should describe work conditions such as:
|
||||
|
||||
- focused
|
||||
- idle
|
||||
- blocked
|
||||
- overloaded
|
||||
- waiting
|
||||
- recovering
|
||||
- degraded
|
||||
|
||||
The office should show states that are useful for coordination.
|
||||
|
||||
Any richer or more proprietary internal signal stack can feed these states later, but should not be required to understand them.
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Claw3D already visualizes activity, presence, and meeting participation.
|
||||
|
||||
What is still missing is a reliable office-wide language for work condition and agent load.
|
||||
|
||||
A proper state model would improve:
|
||||
|
||||
- delegation
|
||||
- meeting routing
|
||||
- desk progression meaning
|
||||
- team visibility
|
||||
- office atmosphere
|
||||
|
||||
It also gives a clean place for future internal state systems to map into.
|
||||
|
||||
## Product Position
|
||||
|
||||
The agent state model is:
|
||||
|
||||
- a public coordination surface
|
||||
- not a hidden internal metric system
|
||||
- not a mood simulator in V1
|
||||
|
||||
It should help the user make decisions quickly.
|
||||
|
||||
## Recommended Visible States
|
||||
|
||||
Suggested initial state set:
|
||||
|
||||
- `idle`
|
||||
- `focused`
|
||||
- `working`
|
||||
- `waiting`
|
||||
- `blocked`
|
||||
- `overloaded`
|
||||
- `recovering`
|
||||
- `degraded`
|
||||
- `meeting`
|
||||
- `error`
|
||||
|
||||
### Idle
|
||||
|
||||
The agent is available and not actively engaged in a task.
|
||||
|
||||
### Focused
|
||||
|
||||
The agent is actively working and should not be interrupted casually.
|
||||
|
||||
### Working
|
||||
|
||||
The agent is doing normal active work without special load concerns.
|
||||
|
||||
### Waiting
|
||||
|
||||
The agent is paused on a dependency, approval, or external result.
|
||||
|
||||
### Blocked
|
||||
|
||||
The agent cannot make forward progress because a required condition is missing.
|
||||
|
||||
### Overloaded
|
||||
|
||||
The agent has too much work, too much context pressure, or too many active demands.
|
||||
|
||||
### Recovering
|
||||
|
||||
The agent has recently completed intense work or a failure state and should stabilize before taking more on.
|
||||
|
||||
### Degraded
|
||||
|
||||
The agent is operational but impaired in quality, speed, or confidence.
|
||||
|
||||
### Meeting
|
||||
|
||||
The agent is participating in a coordination workflow and is temporarily occupied there.
|
||||
|
||||
### Error
|
||||
|
||||
The agent encountered a concrete failure or unrecoverable problem and needs attention.
|
||||
|
||||
## Relationship To Existing Presence
|
||||
|
||||
Claw3D already uses simpler presence states such as:
|
||||
|
||||
- idle
|
||||
- working
|
||||
- meeting
|
||||
- error
|
||||
|
||||
This spec should extend that concept rather than replace it abruptly.
|
||||
|
||||
The new states should be layered so older/fallback providers can still map into the simpler model.
|
||||
|
||||
## Public vs Internal State
|
||||
|
||||
This distinction matters.
|
||||
|
||||
### Public State
|
||||
|
||||
What Claw3D shows in the office:
|
||||
|
||||
- stable
|
||||
- understandable
|
||||
- backend-neutral
|
||||
- useful for coordination
|
||||
|
||||
### Internal State
|
||||
|
||||
What a stack like Vera might use internally:
|
||||
|
||||
- latent regime
|
||||
- coherence
|
||||
- confidence or control signals
|
||||
- advanced routing/load heuristics
|
||||
|
||||
Internal state can be richer.
|
||||
|
||||
Public state should remain simpler and more durable.
|
||||
|
||||
## Suggested Mapping Model
|
||||
|
||||
Recommended structure:
|
||||
|
||||
```ts
|
||||
type OfficeAgentState =
|
||||
| "idle"
|
||||
| "focused"
|
||||
| "working"
|
||||
| "waiting"
|
||||
| "blocked"
|
||||
| "overloaded"
|
||||
| "recovering"
|
||||
| "degraded"
|
||||
| "meeting"
|
||||
| "error";
|
||||
|
||||
type AgentStateReason =
|
||||
| "task_active"
|
||||
| "approval_pending"
|
||||
| "dependency_wait"
|
||||
| "meeting_active"
|
||||
| "high_load"
|
||||
| "recent_failure"
|
||||
| "runtime_signal"
|
||||
| "manual_override"
|
||||
| "unknown";
|
||||
|
||||
type OfficeAgentStateSnapshot = {
|
||||
state: OfficeAgentState;
|
||||
reason: AgentStateReason;
|
||||
updatedAt: string;
|
||||
note?: string | null;
|
||||
confidence?: number | null;
|
||||
};
|
||||
```
|
||||
|
||||
The `reason` is important because it prevents the state from feeling arbitrary.
|
||||
|
||||
## V1 Derivation Rules
|
||||
|
||||
The first version should use straightforward observable signals.
|
||||
|
||||
Examples:
|
||||
|
||||
- active run -> `working` or `focused`
|
||||
- pending approval -> `waiting`
|
||||
- unresolved dependency or explicit blocker -> `blocked`
|
||||
- too many simultaneous demands -> `overloaded`
|
||||
- active standup/meeting -> `meeting`
|
||||
- recent hard failure -> `error` or `degraded`
|
||||
- no recent work -> `idle`
|
||||
|
||||
This gives immediate utility without needing a deeper cognitive model.
|
||||
|
||||
## Relationship To Other Office Systems
|
||||
|
||||
### Meetings
|
||||
|
||||
Meeting participation should temporarily dominate many other states.
|
||||
|
||||
Example:
|
||||
|
||||
- a focused agent who joins a standup becomes `meeting` while the meeting is active
|
||||
|
||||
### Bulletin Board
|
||||
|
||||
The bulletin board should be able to surface meaningful state changes.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Alice blocked waiting on approval"
|
||||
- "Bob overloaded during release push"
|
||||
- "Hermes degraded after provider failure"
|
||||
|
||||
### Whiteboard
|
||||
|
||||
Planning workflows can use state to decide:
|
||||
|
||||
- who is available
|
||||
- who should not be interrupted
|
||||
- who is the right candidate for next actions
|
||||
|
||||
### QA Department
|
||||
|
||||
QA and state should influence each other.
|
||||
|
||||
Examples:
|
||||
|
||||
- repeated QA failures may push an agent or workflow into `degraded`
|
||||
- review-ready but approval-blocked work can show `waiting`
|
||||
|
||||
### Desk Progression / Hierarchy
|
||||
|
||||
More mature roles may tolerate:
|
||||
|
||||
- more context
|
||||
- more delegation
|
||||
- higher review authority
|
||||
|
||||
But the visible state model should still be shared across all roles.
|
||||
|
||||
## Visual Expression
|
||||
|
||||
States should be visible in restrained ways.
|
||||
|
||||
Examples:
|
||||
|
||||
- nameplate subtitle or badge
|
||||
- desk lighting/accent
|
||||
- movement pacing
|
||||
- speech bubble framing
|
||||
- office panel badges
|
||||
|
||||
Examples by state:
|
||||
|
||||
- `focused`: sharper or brighter work signal
|
||||
- `waiting`: subdued idle with pending marker
|
||||
- `blocked`: warning tone
|
||||
- `overloaded`: high activity / stress marker
|
||||
- `meeting`: meeting-specific signal
|
||||
- `degraded`: weakened signal, slower feel
|
||||
|
||||
Keep the visual language readable, not noisy.
|
||||
|
||||
## Human Interaction Model
|
||||
|
||||
The human should be able to:
|
||||
|
||||
- see current state
|
||||
- understand why the state was chosen
|
||||
- optionally override state in limited cases
|
||||
|
||||
The user should not have to guess what "degraded" or "blocked" means.
|
||||
|
||||
## Provider / Backend Considerations
|
||||
|
||||
Different runtimes will expose different levels of insight.
|
||||
|
||||
That is fine.
|
||||
|
||||
The public state model should support:
|
||||
|
||||
- direct provider-native state
|
||||
- derived state from Claw3D activity
|
||||
- optional custom stack enrichments
|
||||
|
||||
This is especially important for:
|
||||
|
||||
- OpenClaw
|
||||
- Hermes
|
||||
- Demo mode
|
||||
- future custom providers
|
||||
|
||||
## Vera / Coherence / Latent-Regime Compatibility
|
||||
|
||||
This spec intentionally leaves room for deeper internal models without exposing them directly.
|
||||
|
||||
Good future pattern:
|
||||
|
||||
- internal stack computes richer latent regime / coherence / workload state
|
||||
- adapter maps that into public office states
|
||||
- Claw3D shows the public office state plus optional note/reason
|
||||
|
||||
This preserves:
|
||||
|
||||
- clean UX
|
||||
- backend neutrality
|
||||
- proprietary implementation freedom
|
||||
|
||||
## V1 Scope
|
||||
|
||||
Recommended V1 scope:
|
||||
|
||||
- define expanded office agent state enum
|
||||
- derive state from observable office/runtime signals
|
||||
- surface state in UI and office visuals
|
||||
- show a short reason or note when helpful
|
||||
|
||||
Do not build the full simulation layer yet.
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
- emotional simulation
|
||||
- arbitrary personality modeling
|
||||
- hidden scoring systems shown as fake moods
|
||||
- deep physiological metaphors
|
||||
|
||||
Those can come later if they remain useful and readable.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Define public state model and reason codes.
|
||||
2. Add derivation rules from current runtime/task/meeting signals.
|
||||
3. Update office visuals and badges.
|
||||
4. Add optional state note/reason surfaces.
|
||||
5. Leave room for future provider-specific enrichments.
|
||||
|
||||
## Existing Code Seams
|
||||
|
||||
This should likely align with:
|
||||
|
||||
- current office presence/state handling
|
||||
- runtime event bridge and latest-update logic
|
||||
- standup meeting state
|
||||
- task and approval signals
|
||||
- office visual systems
|
||||
|
||||
## Success Criteria
|
||||
|
||||
V1 is successful if:
|
||||
|
||||
- users can tell what condition each agent is in
|
||||
- state changes help routing and coordination
|
||||
- the model works without any proprietary backend
|
||||
- the design still leaves room for richer future internal stacks
|
||||
|
||||
## Summary
|
||||
|
||||
The agent state model should become the office’s shared language for work condition.
|
||||
|
||||
It should be simple enough to understand immediately, but structured enough to accept richer future inputs from advanced runtime stacks.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Hermes Gateway Adapter
|
||||
|
||||
Claw3D can run against Hermes by using the bundled adapter in
|
||||
[`server/hermes-gateway-adapter.js`](../server/hermes-gateway-adapter.js).
|
||||
|
||||
This is the current production-ready Hermes path in this repository.
|
||||
It is not yet a fully native Studio-side Hermes provider. Instead, it
|
||||
uses the runtime seam in Studio while Hermes is exposed through a
|
||||
Claw3D-compatible WebSocket adapter.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Browser UI <-> Studio runtime/client <-> Hermes gateway adapter <-> Hermes HTTP API
|
||||
```
|
||||
|
||||
The frontend keeps using the Claw3D gateway protocol. The Hermes adapter
|
||||
translates that protocol into Hermes HTTP calls and streams the results
|
||||
back as gateway events.
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Start Hermes
|
||||
|
||||
Start your Hermes API server. The default expected endpoint is:
|
||||
|
||||
```text
|
||||
http://localhost:8642
|
||||
```
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
Copy `.env.example` to `.env` and set the Hermes values:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
|
||||
|
||||
HERMES_API_URL=http://localhost:8642
|
||||
HERMES_API_KEY=
|
||||
HERMES_ADAPTER_PORT=18789
|
||||
HERMES_MODEL=hermes
|
||||
HERMES_AGENT_NAME=Hermes
|
||||
```
|
||||
|
||||
### 3. Start Claw3D and the adapter
|
||||
|
||||
In separate terminals:
|
||||
|
||||
```bash
|
||||
npm run hermes-adapter
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Then open `http://localhost:3000` and connect to:
|
||||
|
||||
```text
|
||||
ws://localhost:18789
|
||||
```
|
||||
|
||||
In the connect screen, select `Hermes backend`. Claw3D will persist that
|
||||
selection in Studio settings and show `Hermes` as the active backend once
|
||||
the adapter hello response is received.
|
||||
|
||||
### 4. Optional all-in-one local startup
|
||||
|
||||
The repo also includes:
|
||||
|
||||
```bash
|
||||
bash scripts/clawd3d-start.sh
|
||||
```
|
||||
|
||||
That script now resolves the repo root dynamically from the script
|
||||
location instead of assuming a machine-specific checkout path.
|
||||
|
||||
## What this adapter supports
|
||||
|
||||
The adapter currently supports the Claw3D surfaces needed for normal
|
||||
office use:
|
||||
|
||||
- Agent listing, creation, update, and deletion
|
||||
- Session listing, preview, patch, reset, and history lookup
|
||||
- Chat send, targeted abort, and run wait
|
||||
- Config get/set/patch shims needed by the Studio UI
|
||||
- Models and skills status
|
||||
- Exec approvals surfaces used by the current UI
|
||||
- Cron list/add/remove/patch/run
|
||||
- Multi-agent orchestration tools on the Hermes side
|
||||
|
||||
## Hermes orchestration tools
|
||||
|
||||
The main Hermes agent acts as an orchestrator with these tools:
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `spawn_agent` | Create a specialist sub-agent |
|
||||
| `delegate_task` | Send work to a specific agent |
|
||||
| `list_team` | List active agents, names, and roles |
|
||||
| `configure_agent` | Update agent name, role, instructions, or settings |
|
||||
| `dismiss_agent` | Remove an agent from the team |
|
||||
| `read_agent_context` | Read another agent's recent conversation history for coordination |
|
||||
|
||||
Sub-agents appear in the office as separate characters and keep their
|
||||
own conversation state.
|
||||
|
||||
## Production-readiness notes
|
||||
|
||||
This adapter includes the fixes that blocked the original Hermes PR:
|
||||
|
||||
- `chat.abort` now aborts only the requested `runId` or `sessionKey`
|
||||
instead of cancelling every active run
|
||||
- history clears from `sessions.reset`, `agents.delete`, and
|
||||
`dismiss_agent` now persist to disk immediately
|
||||
- `scripts/clawd3d-start.sh` no longer hardcodes one developer's local path
|
||||
|
||||
## ACP status
|
||||
|
||||
Hermes has a real ACP surface and that remains the preferred long-term
|
||||
integration direction.
|
||||
|
||||
This branch does not replace the adapter with ACP yet. The current
|
||||
production-ready path uses the adapter because it works with the existing
|
||||
Claw3D gateway contract today and is ready for upstream testing now.
|
||||
|
||||
The runtime seam added in Studio is what makes an ACP-backed Hermes
|
||||
provider feasible as a follow-up without reworking the whole UI again.
|
||||
|
||||
## Persistence
|
||||
|
||||
Conversation history is stored at:
|
||||
|
||||
```text
|
||||
~/.hermes/clawd3d-history.json
|
||||
```
|
||||
|
||||
It is loaded on startup and updated when conversations change.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- Hermes is integrated through the adapter path today, not yet through a
|
||||
dedicated native Studio provider implementation
|
||||
- Config and approvals behavior still matches the current adapter contract,
|
||||
not a fully Hermes-native settings model
|
||||
- This path is intended to get Hermes working reliably now while the
|
||||
broader runtime-provider architecture continues to mature
|
||||
|
||||
## When to use demo mode instead
|
||||
|
||||
If you only want to see the office boot without installing Hermes or
|
||||
OpenClaw, use:
|
||||
|
||||
```bash
|
||||
npm run demo-gateway
|
||||
npm run dev
|
||||
```
|
||||
|
||||
That starts a bundled mock gateway for a no-framework Claw3D demo.
|
||||
|
||||
## Using OpenClaw instead
|
||||
|
||||
If you want the OpenClaw path, do not run the Hermes adapter. Start
|
||||
OpenClaw and point Claw3D at that gateway instead.
|
||||
@@ -0,0 +1,316 @@
|
||||
# Custom Provider Reference
|
||||
|
||||
> Reference implementation guide for plugging a non-OpenClaw, non-Hermes runtime into Claw3D through the upstream-safe `custom` provider seam.
|
||||
|
||||
## Goal
|
||||
|
||||
Show how a custom orchestration stack should plug into Claw3D without requiring:
|
||||
|
||||
- a named built-in provider
|
||||
- OpenClaw emulation
|
||||
- Hermes adapter semantics
|
||||
|
||||
The shape should be:
|
||||
|
||||
- Claw3D sees `custom`
|
||||
- the external runtime stays implementation-specific
|
||||
- Claw3D core remains generic
|
||||
|
||||
## Current Implementation Notes
|
||||
|
||||
The current `custom` branch path is deliberately conservative.
|
||||
|
||||
What exists today:
|
||||
|
||||
- provider selection and metadata flow through the Studio runtime seam
|
||||
- same-origin runtime proxying through `/api/runtime/custom`
|
||||
- health, state, and registry probing
|
||||
- direct chat via `/v1/chat/completions`
|
||||
- office/bootstrap/chat/model loading routed through the provider layer
|
||||
|
||||
What still needs to mature:
|
||||
|
||||
- true provider-native streaming
|
||||
- stronger multi-session persistence semantics
|
||||
- integration tests against a live custom runtime
|
||||
- richer office presentation of runtime metadata, lanes, and model identity
|
||||
|
||||
## Position
|
||||
|
||||
A custom runtime should sit at the Claw3D boundary as an orchestrator-backed service.
|
||||
|
||||
That means:
|
||||
|
||||
- Claw3D should talk to one stable runtime boundary
|
||||
- that boundary may route work to internal worker processes, models, or lanes
|
||||
- those internal details should remain mostly hidden behind the provider
|
||||
|
||||
This keeps the integration:
|
||||
|
||||
- stable
|
||||
- upstream-safe
|
||||
- reusable for multiple custom stacks later
|
||||
|
||||
## Why The Custom Provider Exists
|
||||
|
||||
Not every useful runtime should need a named upstream provider branch.
|
||||
|
||||
Some stacks will be:
|
||||
|
||||
- internal
|
||||
- experimental
|
||||
- organization-specific
|
||||
- built around custom orchestration
|
||||
|
||||
The `custom` provider exists so those stacks can integrate cleanly without forcing Claw3D core to absorb stack-specific assumptions.
|
||||
|
||||
## Recommended Boundary
|
||||
|
||||
Claw3D should integrate with the custom runtime's orchestrator or gateway layer, not with its individual workers.
|
||||
|
||||
Recommended public boundary:
|
||||
|
||||
- `POST /v1/chat/completions`
|
||||
- `POST /v1/completions`
|
||||
- `POST /v1/contracted-completions`
|
||||
- `GET /health`
|
||||
- `GET /state`
|
||||
- `GET /registry`
|
||||
|
||||
These do not need to be mandatory for every implementation, but they are a strong reference shape because they provide:
|
||||
|
||||
- chat entry points
|
||||
- reachability
|
||||
- runtime summary
|
||||
- model or role registry visibility
|
||||
|
||||
## Runtime Shape
|
||||
|
||||
Recommended mapping:
|
||||
|
||||
```text
|
||||
Claw3D
|
||||
-> custom provider
|
||||
-> custom orchestrator / gateway
|
||||
-> internal routing
|
||||
-> workers / roles / models / tools
|
||||
```
|
||||
|
||||
This means the custom provider should not need to know:
|
||||
|
||||
- worker ports
|
||||
- shard startup semantics
|
||||
- internal runtime policy
|
||||
- proprietary state heuristics
|
||||
|
||||
It only needs:
|
||||
|
||||
- request/response surfaces
|
||||
- route or session metadata
|
||||
- health/state/registry metadata
|
||||
|
||||
## Provider Identity
|
||||
|
||||
Upstream-facing identity should remain:
|
||||
|
||||
- `providerId: "custom"`
|
||||
|
||||
Implementation-specific identity should appear as metadata, not provider class naming.
|
||||
|
||||
Suggested metadata:
|
||||
|
||||
```ts
|
||||
type CustomRuntimeMetadata = {
|
||||
id: "custom";
|
||||
label: "Custom Runtime";
|
||||
runtimeName?: string | null;
|
||||
vendor?: string | null;
|
||||
runtimeVersion?: string | null;
|
||||
routeProfile?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
This gives the UI enough identity without forcing upstream Claw3D to branch on every runtime brand.
|
||||
|
||||
## Capability Profile
|
||||
|
||||
Initial `custom` provider capability claims should be conservative and honest.
|
||||
|
||||
Likely V1 support for many custom runtimes:
|
||||
|
||||
- `agents`
|
||||
- `sessions`
|
||||
- `chat`
|
||||
- `streaming`
|
||||
- `agent_roles`
|
||||
- `config`
|
||||
|
||||
Possible later support depending on implementation:
|
||||
|
||||
- `files`
|
||||
- `approvals`
|
||||
- `skills`
|
||||
- `cron`
|
||||
- `session_settings`
|
||||
|
||||
Important rule:
|
||||
|
||||
Do not claim support just because the backend can theoretically do something. Claim support only where the provider exposes a stable Claw3D-facing behavior.
|
||||
|
||||
## Agent Mapping
|
||||
|
||||
Claw3D agents should not be modeled as one fixed backend process each unless the runtime truly behaves that way.
|
||||
|
||||
Instead, a custom runtime may map agents to:
|
||||
|
||||
- roles
|
||||
- lanes
|
||||
- strategies
|
||||
- departments
|
||||
- routed execution identities
|
||||
|
||||
Example office-facing identities:
|
||||
|
||||
- `Main`
|
||||
- `Assistant`
|
||||
- `Coder`
|
||||
- `Reviewer`
|
||||
- `Professor`
|
||||
|
||||
The exact labels are implementation-specific.
|
||||
|
||||
The important point is that the provider should expose office-meaningful identities rather than leaking raw backend topology.
|
||||
|
||||
## Session Mapping
|
||||
|
||||
Claw3D sessions should map to runtime conversations or execution threads, not to whichever backend storage structure happens to exist internally.
|
||||
|
||||
Recommended session metadata:
|
||||
|
||||
- `sessionKey`
|
||||
- conversation or thread id
|
||||
- active role
|
||||
- lane
|
||||
- requested model
|
||||
- resolved model
|
||||
- request id
|
||||
|
||||
The provider should normalize these into a stable session model no matter what the backend calls them internally.
|
||||
|
||||
## Event Mapping
|
||||
|
||||
The provider should translate runtime activity into the same normalized Claw3D runtime events used elsewhere.
|
||||
|
||||
Recommended mappings:
|
||||
|
||||
- runtime agent change -> `presence.changed`
|
||||
- conversation/session update -> `session.activity`
|
||||
- streaming token output -> `chat.delta`
|
||||
- final assistant turn -> `chat.final`
|
||||
- routed or backend failure -> `chat.error`
|
||||
- request lifecycle -> `run.lifecycle`
|
||||
- tool or workflow progress -> `tool.progress`
|
||||
|
||||
Implementation-specific metadata is allowed, but only as additive metadata.
|
||||
|
||||
## Route Metadata
|
||||
|
||||
Many custom runtimes will have useful execution metadata.
|
||||
|
||||
The provider may expose optional metadata such as:
|
||||
|
||||
- selected role
|
||||
- candidate roles
|
||||
- lane
|
||||
- routing reason
|
||||
- registry profile
|
||||
- resolved model id
|
||||
- worker or runtime health summary
|
||||
|
||||
This metadata should be visible in diagnostics or advanced panels, not required for basic user flow.
|
||||
|
||||
## State And Health Surfaces
|
||||
|
||||
The custom provider should prefer orchestrator-level health/state queries first.
|
||||
|
||||
Recommended usage:
|
||||
|
||||
- `/health`
|
||||
- basic runtime reachability
|
||||
- `/state`
|
||||
- route profile, active roles, worker/runtime summary
|
||||
- `/registry`
|
||||
- active model, role, or profile catalog
|
||||
|
||||
Worker-level status endpoints should stay behind the provider unless the UI needs a diagnostic drill-down.
|
||||
|
||||
## Relationship To Agent State
|
||||
|
||||
The custom provider is the right place to map richer internal runtime signals into Claw3D's public office state model.
|
||||
|
||||
Public office state should remain simple:
|
||||
|
||||
- `focused`
|
||||
- `working`
|
||||
- `blocked`
|
||||
- `overloaded`
|
||||
- `degraded`
|
||||
|
||||
Internally, a custom stack may derive those from richer signals such as:
|
||||
|
||||
- route stress
|
||||
- worker saturation
|
||||
- runtime policy
|
||||
- confidence or control signals
|
||||
- proprietary orchestration heuristics
|
||||
|
||||
That should remain implementation-private.
|
||||
|
||||
Claw3D only needs the resulting office-safe state and maybe a public reason label.
|
||||
|
||||
## Relationship To Office Systems
|
||||
|
||||
The custom provider should support office systems without Claw3D needing to know the backend's internals.
|
||||
|
||||
Examples:
|
||||
|
||||
- bulletin board gets agent/session/task context
|
||||
- whiteboard gets planning-session context
|
||||
- meetings get coordination state
|
||||
- QA gets review or readiness metadata
|
||||
|
||||
Again, the provider should expose only what the office needs.
|
||||
|
||||
## Suggested V1 Scope
|
||||
|
||||
Recommended first `custom` provider scope:
|
||||
|
||||
1. Reach the custom orchestrator over `/v1/chat/completions`.
|
||||
2. Pull metadata from `/health`, `/state`, and `/registry`.
|
||||
3. Map runtime roles or routes into Claw3D agent identities.
|
||||
4. Surface streaming and final turns.
|
||||
5. Expose route metadata in a diagnostics-friendly way.
|
||||
6. Map simple public office states from observable runtime conditions.
|
||||
|
||||
This is enough to make a custom runtime useful in Claw3D without overexposing internals.
|
||||
|
||||
## Suggested V2 Scope
|
||||
|
||||
Once the V1 provider is stable, add:
|
||||
|
||||
- richer session persistence
|
||||
- role-aware office teams
|
||||
- custom runtime diagnostics panel
|
||||
- office state enrichment from internal stack signals
|
||||
- hooks into bulletin board, whiteboard, QA, and meeting systems
|
||||
|
||||
## Explicit Non-Goals
|
||||
|
||||
This reference should not require:
|
||||
|
||||
- runtime-specific branches throughout Claw3D core
|
||||
- OpenClaw compatibility shims
|
||||
- routing logic duplicated in the frontend
|
||||
- direct worker orchestration in the browser
|
||||
|
||||
The provider should remain the containment layer.
|
||||
@@ -0,0 +1,300 @@
|
||||
# Custom Runtime Provider Spec
|
||||
|
||||
> Generic extension seam for non-OpenClaw, non-Hermes stacks.
|
||||
|
||||
## Goal
|
||||
|
||||
Define a clean `custom` runtime provider class for Claw3D.
|
||||
|
||||
This provider should let external orchestration stacks integrate with Claw3D through a stable seam without requiring:
|
||||
|
||||
- OpenClaw emulation
|
||||
- Hermes-specific semantics
|
||||
- named first-class core support for every stack
|
||||
|
||||
The idea is:
|
||||
|
||||
- upstream concept: `custom` provider
|
||||
- downstream implementations provide their own runtime behavior against that seam
|
||||
|
||||
## Current Branch Status
|
||||
|
||||
On `dev/vera_lane`, the `custom` provider is no longer just a design
|
||||
placeholder.
|
||||
|
||||
Current implemented behavior:
|
||||
|
||||
- `custom` is a first-class provider ID in the runtime seam
|
||||
- Studio persists the selected backend mode as `custom`
|
||||
- the provider exposes runtime metadata such as `runtimeName`, `vendor`,
|
||||
`runtimeVersion`, and `routeProfile` when available
|
||||
- Claw3D probes `GET /health`, `GET /state`, and `GET /registry`
|
||||
- chat uses a direct HTTP path to `POST /v1/chat/completions`
|
||||
- browser traffic is proxied through Claw3D's same-origin
|
||||
`/api/runtime/custom` route instead of calling the runtime directly
|
||||
|
||||
Still intentionally missing in this branch:
|
||||
|
||||
- normalized streaming event support
|
||||
- richer session persistence beyond the synthetic provider session layer
|
||||
- direct approvals/files/cron surfaces
|
||||
- process auto-launch from Studio
|
||||
|
||||
## Why This Matters
|
||||
|
||||
Not every useful runtime should have to become a named built-in provider in upstream Claw3D.
|
||||
|
||||
A clean custom provider seam gives:
|
||||
|
||||
- extensibility
|
||||
- lower upstream friction
|
||||
- room for proprietary or stack-specific orchestration
|
||||
- a path for advanced internal systems without polluting core abstractions
|
||||
|
||||
This is especially important when a stack is:
|
||||
|
||||
- internal
|
||||
- organization-specific
|
||||
- experimental
|
||||
- orchestration-heavy
|
||||
|
||||
## Product Position
|
||||
|
||||
The `custom` provider should be treated as:
|
||||
|
||||
- a first-class extension seam
|
||||
- not a hack
|
||||
- not a vendor-specific side path
|
||||
|
||||
This is the upstream-safe abstraction.
|
||||
|
||||
## Relationship To Existing Provider Work
|
||||
|
||||
Claw3D’s runtime abstraction already points toward multiple providers:
|
||||
|
||||
- `openclaw`
|
||||
- `hermes`
|
||||
- future providers
|
||||
|
||||
The `custom` provider should sit alongside those as a generic lane for:
|
||||
|
||||
- external orchestrators
|
||||
- private agent stacks
|
||||
- organization-specific routing systems
|
||||
|
||||
## Core Principle
|
||||
|
||||
Do not leak implementation-specific internal logic into the generic provider contract.
|
||||
|
||||
The provider should expose:
|
||||
|
||||
- common runtime behaviors
|
||||
- provider capabilities
|
||||
- normalized events
|
||||
- optional metadata
|
||||
|
||||
It should not require upstream Claw3D to know:
|
||||
|
||||
- runtime-specific routing internals
|
||||
- proprietary state logic
|
||||
- private planning models
|
||||
- stack-specific heuristics
|
||||
|
||||
## Provider Identity
|
||||
|
||||
Suggested provider IDs:
|
||||
|
||||
- `openclaw`
|
||||
- `hermes`
|
||||
- `custom`
|
||||
|
||||
Then allow custom metadata such as:
|
||||
|
||||
```ts
|
||||
type CustomRuntimeDescriptor = {
|
||||
providerId: "custom";
|
||||
runtimeName: string;
|
||||
runtimeVersion?: string | null;
|
||||
vendor?: string | null;
|
||||
capabilities?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
This gives Claw3D enough identity for UI without hardcoding a brand into the provider class.
|
||||
|
||||
## Reference Implementation Strategy
|
||||
|
||||
The `custom` provider should exist as a generic extension seam.
|
||||
|
||||
That means:
|
||||
|
||||
- upstream Claw3D gets `custom`
|
||||
- downstream stacks supply their own behavior
|
||||
- others can later implement their own custom providers against the same seam
|
||||
|
||||
That is much easier to justify upstream than:
|
||||
|
||||
- adding a deeply stack-specific built-in provider before the generic extension seam exists
|
||||
|
||||
## Suggested Contract Shape
|
||||
|
||||
This should align with the existing runtime abstraction, but allow custom metadata.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
type RuntimeProviderId = "openclaw" | "hermes" | "custom";
|
||||
|
||||
type RuntimeProviderMetadata = {
|
||||
id: RuntimeProviderId;
|
||||
label: string;
|
||||
runtimeName?: string | null;
|
||||
runtimeVersion?: string | null;
|
||||
vendor?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
The `custom` provider should still implement the same base runtime methods:
|
||||
|
||||
- list agents
|
||||
- list sessions
|
||||
- send chat
|
||||
- abort run
|
||||
- wait for run
|
||||
- stream normalized events
|
||||
- expose capabilities honestly
|
||||
|
||||
## Capability Philosophy
|
||||
|
||||
The custom provider should be capability-driven, not assumption-driven.
|
||||
|
||||
That means if a custom stack supports:
|
||||
|
||||
- roles
|
||||
- approvals
|
||||
- files
|
||||
- cron
|
||||
- whiteboard integration
|
||||
- meeting signals
|
||||
|
||||
it should declare those clearly.
|
||||
|
||||
If it does not, the UI should degrade honestly.
|
||||
|
||||
## Event Model
|
||||
|
||||
The custom provider should emit the same normalized event categories as other providers.
|
||||
|
||||
Examples:
|
||||
|
||||
- `presence.changed`
|
||||
- `session.activity`
|
||||
- `chat.delta`
|
||||
- `chat.final`
|
||||
- `chat.error`
|
||||
- `run.lifecycle`
|
||||
- `tool.progress`
|
||||
|
||||
This keeps Claw3D stable even if the custom stack has richer private event semantics internally.
|
||||
|
||||
## Custom Metadata Surface
|
||||
|
||||
The custom provider may optionally expose richer metadata for display.
|
||||
|
||||
Examples:
|
||||
|
||||
- routed lane
|
||||
- active model id
|
||||
- strategy label
|
||||
- execution mode
|
||||
- custom stack status
|
||||
|
||||
Important:
|
||||
|
||||
This metadata should be additive and optional.
|
||||
|
||||
It should not change the core runtime contract.
|
||||
|
||||
## Reference Implementation Model
|
||||
|
||||
A custom provider can reasonably map:
|
||||
|
||||
- agents -> routed roles / lanes
|
||||
- sessions -> runtime conversations
|
||||
- streaming -> orchestrator or gateway output streams
|
||||
- provider metadata -> runtime name, lane, model, or route state
|
||||
|
||||
The public upstream concept remains `custom`.
|
||||
|
||||
Implementation-specific mapping stays in the runtime layer.
|
||||
|
||||
## Relationship To Agent State Model
|
||||
|
||||
The custom provider is the right place for richer internal state to enter Claw3D.
|
||||
|
||||
For example:
|
||||
|
||||
- a custom runtime computes deeper control or state signals
|
||||
- the provider maps them into public office states
|
||||
|
||||
This keeps:
|
||||
|
||||
- internal intelligence private
|
||||
- public office state understandable
|
||||
|
||||
## Relationship To Office Systems
|
||||
|
||||
The custom provider should be able to support office systems without Claw3D needing to know the backend’s internals.
|
||||
|
||||
Examples:
|
||||
|
||||
- bulletin board gets agent/session/task context
|
||||
- whiteboard gets planning-session context
|
||||
- meetings get coordination state
|
||||
- QA gets review or readiness metadata
|
||||
|
||||
Again, the custom provider should expose only what the office needs.
|
||||
|
||||
## V1 Scope
|
||||
|
||||
Recommended V1 scope:
|
||||
|
||||
- define `custom` provider identity
|
||||
- allow custom provider metadata
|
||||
- ensure capability-driven UI behavior
|
||||
- make no runtime-specific assumptions in core Claw3D
|
||||
|
||||
Actual runtime adapters can be implemented separately against that seam.
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
- embedding proprietary internal orchestration logic in Claw3D core
|
||||
- hardcoding any one runtime as upstream architecture
|
||||
- requiring all custom providers to support advanced signals
|
||||
|
||||
The point is generic extensibility first.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Add `custom` as a first-class runtime provider identity.
|
||||
2. Add a metadata surface for runtime name/vendor/version.
|
||||
3. Ensure the provider factory supports `custom`.
|
||||
4. Keep the runtime event and capability model normalized.
|
||||
5. Implement Vera as the first reference adapter outside the generic contract.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
This spec is successful if:
|
||||
|
||||
- upstream Claw3D gains a clean extension seam
|
||||
- Vera can integrate without bloating core architecture
|
||||
- future stacks can follow the same pattern
|
||||
- the office systems continue to work against normalized runtime behavior
|
||||
|
||||
## Summary
|
||||
|
||||
The `custom` runtime provider is the right upstream abstraction for stack-specific orchestrators.
|
||||
|
||||
It gives Claw3D extensibility, gives Vera a clean path in, and avoids hardwiring a personal stack directly into the core product model.
|
||||
@@ -0,0 +1,264 @@
|
||||
# Universal Backend Plan
|
||||
|
||||
> Backend-neutral Claw3D integration plan for OpenClaw, Hermes, Vera, and other runtimes.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Do not treat PR #70 as the long-term integration architecture.
|
||||
|
||||
It is useful as a short-term compatibility shim and a source of a few good UX changes, but it does not make Claw3D backend-neutral. It keeps Claw3D OpenClaw-shaped and makes Hermes imitate OpenClaw.
|
||||
|
||||
That matters because:
|
||||
|
||||
- Hermes already has real control surfaces: ACP and an OpenAI-compatible API server.
|
||||
- Vera already has a real orchestrator/gateway shape.
|
||||
- Every future backend would otherwise need to keep emulating the OpenClaw gateway protocol.
|
||||
|
||||
The better path is:
|
||||
|
||||
1. Keep OpenClaw support intact.
|
||||
2. Extract a backend-neutral runtime adapter inside Claw3D.
|
||||
3. Add Hermes and Vera providers against their native surfaces where possible.
|
||||
4. Cherry-pick the high-value UI pieces from PR #70 into that new architecture.
|
||||
|
||||
## What To Reuse From PR #70
|
||||
|
||||
These are worth keeping:
|
||||
|
||||
- Multi-agent UX concepts.
|
||||
- `read_agent_context` as a coordination primitive.
|
||||
- Agent `role` flowing into the 3D office nameplate.
|
||||
- Click-to-chat behavior.
|
||||
- Live speech bubble rendering for streaming text.
|
||||
- Hermes-specific env var documentation.
|
||||
|
||||
These are not the right long-term seam:
|
||||
|
||||
- A full OpenClaw-protocol emulator as the primary Hermes integration.
|
||||
- Fake-success implementations for `config.*` and approvals.
|
||||
- Synthesizing runtime freshness from `Date.now()` instead of real event/message timestamps.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
Claw3D should stop treating the browser gateway client as the backend abstraction.
|
||||
|
||||
Instead, Studio should expose a backend-neutral runtime service with provider adapters:
|
||||
|
||||
```text
|
||||
Browser UI
|
||||
-> Studio runtime API
|
||||
-> OpenClaw provider
|
||||
-> Hermes provider
|
||||
-> Vera provider
|
||||
```
|
||||
|
||||
The browser can still use WebSocket streaming from Studio, but the messages should be Claw3D-native runtime events rather than implicitly OpenClaw events.
|
||||
|
||||
## Core Adapter Contract
|
||||
|
||||
Suggested TypeScript shape:
|
||||
|
||||
```ts
|
||||
export type RuntimeCapability =
|
||||
| "agents"
|
||||
| "sessions"
|
||||
| "chat"
|
||||
| "streaming"
|
||||
| "agent_roles"
|
||||
| "files"
|
||||
| "skills"
|
||||
| "cron"
|
||||
| "approvals"
|
||||
| "config"
|
||||
| "session_settings";
|
||||
|
||||
export type RuntimeEvent =
|
||||
| { type: "presence.changed"; agents: RuntimeAgentSummary[] }
|
||||
| { type: "session.activity"; sessionKey: string; agentId: string; at: number }
|
||||
| { type: "chat.delta"; runId: string; sessionKey: string; text: string; at: number }
|
||||
| { type: "chat.final"; runId: string; sessionKey: string; text: string; at: number }
|
||||
| { type: "chat.error"; runId: string; sessionKey: string; message: string; at: number }
|
||||
| { type: "run.lifecycle"; runId: string; sessionKey: string; phase: "start" | "end" | "error"; at: number }
|
||||
| { type: "tool.progress"; runId: string; sessionKey: string; label: string; at: number };
|
||||
|
||||
export interface RuntimeProvider {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
getCapabilities(): Promise<Set<RuntimeCapability>>;
|
||||
connect(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
subscribe(listener: (event: RuntimeEvent) => void): () => void;
|
||||
listAgents(): Promise<RuntimeAgentSummary[]>;
|
||||
listSessions(input?: { agentId?: string }): Promise<RuntimeSessionSummary[]>;
|
||||
getSessionPreview(keys: string[]): Promise<RuntimeSessionPreview[]>;
|
||||
sendChat(input: { sessionKey: string; message: string; agentId?: string }): Promise<{ runId: string }>;
|
||||
abortRun(input: { runId?: string; sessionKey?: string }): Promise<void>;
|
||||
waitForRun(input: { runId: string; timeoutMs?: number }): Promise<"running" | "done">;
|
||||
}
|
||||
```
|
||||
|
||||
Optional features such as config editing, approvals, files, skills, and cron should sit behind capability checks instead of being assumed to exist.
|
||||
|
||||
## Capability Matrix
|
||||
|
||||
Initial expected support:
|
||||
|
||||
| Capability | OpenClaw | Hermes | Vera |
|
||||
|---|---|---|---|
|
||||
| Agents | Native | Native | Provider-defined |
|
||||
| Sessions | Native | Native | Provider-defined |
|
||||
| Chat send/abort/wait | Native | Native | Native via orchestrator |
|
||||
| Streaming | Native | Native | Native |
|
||||
| Agent roles | Native-ish | Native | Native |
|
||||
| Files | Native | Partial | Optional |
|
||||
| Skills | Native | Native | Optional |
|
||||
| Cron | Native | Native | Optional |
|
||||
| Approvals | Native | Partial | Optional |
|
||||
| Config mutation | Native | Limited | Limited |
|
||||
|
||||
Important rule:
|
||||
|
||||
If a provider does not support a surface, Claw3D should disable or hide the UI for it. It should not fake a successful write.
|
||||
|
||||
## Provider Strategy
|
||||
|
||||
### OpenClaw Provider
|
||||
|
||||
Use the existing gateway client as the first provider implementation.
|
||||
|
||||
This keeps current behavior working while the rest of the app migrates to the adapter contract.
|
||||
|
||||
### Hermes Provider
|
||||
|
||||
Preferred order:
|
||||
|
||||
1. ACP for session-aware agent orchestration.
|
||||
2. Hermes API server for OpenAI-compatible chat and streaming.
|
||||
3. OpenClaw-protocol shim only as a temporary bridge.
|
||||
|
||||
Rationale:
|
||||
|
||||
- ACP is a better semantic fit for sessions, cancellation, fork/resume, approvals, and editor-style state.
|
||||
- The Hermes API server is already stable and useful for chat, tool calling, and cron-backed service behavior.
|
||||
- The OpenClaw shim should be treated as transitional compatibility, not the permanent contract.
|
||||
|
||||
### Vera Provider
|
||||
|
||||
Target the Vera orchestrator, not individual `vera-torch` workers.
|
||||
|
||||
Use:
|
||||
|
||||
- `POST /v1/chat/completions`
|
||||
- `POST /v1/completions`
|
||||
- `POST /v1/contracted-completions`
|
||||
- `GET /health`
|
||||
- `GET /state`
|
||||
- `GET /registry`
|
||||
|
||||
The Vera provider should map Claw3D agent identities to routed roles or lanes rather than pretending Vera is an OpenClaw gateway.
|
||||
|
||||
## Event Model
|
||||
|
||||
Current Claw3D expects OpenClaw-flavored `chat`, `agent`, and `presence` events.
|
||||
|
||||
That is too narrow for universal providers. Studio should normalize provider-native updates into a Claw3D event model with explicit semantics:
|
||||
|
||||
- `presence.changed`
|
||||
- `session.activity`
|
||||
- `chat.delta`
|
||||
- `chat.final`
|
||||
- `chat.error`
|
||||
- `run.lifecycle`
|
||||
- `tool.progress`
|
||||
|
||||
Then the browser UI can consume one stable event shape no matter what backend is in use.
|
||||
|
||||
## High-Value PR Split
|
||||
|
||||
Recommended implementation order:
|
||||
|
||||
### PR 1: Runtime Abstraction
|
||||
|
||||
Scope:
|
||||
|
||||
- Introduce the provider interface.
|
||||
- Wrap current OpenClaw behavior in an `openclaw` provider.
|
||||
- Move capability checks into the UI state layer.
|
||||
- Add a Studio-level runtime event normalization layer.
|
||||
|
||||
This is the most important PR.
|
||||
|
||||
### PR 2: Safe UX Cherry-Picks From PR #70
|
||||
|
||||
Scope:
|
||||
|
||||
- Agent `role` in store and office UI.
|
||||
- Click-to-chat.
|
||||
- Streaming speech bubbles.
|
||||
|
||||
These are good product improvements and do not require committing to the Hermes shim architecture.
|
||||
|
||||
### PR 3: Hermes Native Provider
|
||||
|
||||
Scope:
|
||||
|
||||
- Add a `hermes` provider using ACP where possible.
|
||||
- Use Hermes API server for chat/streaming surfaces.
|
||||
- Expose capabilities honestly.
|
||||
- Persist and surface real timestamps from Hermes session/message state.
|
||||
|
||||
Keep the shim optional for compatibility, not required.
|
||||
|
||||
### PR 4: Vera Provider
|
||||
|
||||
Scope:
|
||||
|
||||
- Add a `vera` provider against the Vera orchestrator.
|
||||
- Map Claw3D agents to Vera roles or lanes.
|
||||
- Surface orchestrator state and routed worker identity.
|
||||
|
||||
### PR 5: Optional Compatibility Layer Cleanup
|
||||
|
||||
Scope:
|
||||
|
||||
- Retire or reduce the Hermes OpenClaw shim.
|
||||
- Convert shim-only routes into provider-native routes where possible.
|
||||
|
||||
## Near-Term Guidance For Luke
|
||||
|
||||
If Luke wants "drop-in Hermes support right now", PR #70 is directionally useful.
|
||||
|
||||
If Luke wants "Claw3D should support any backend cleanly", PR #70 should not be the mainline architecture.
|
||||
|
||||
Best compromise:
|
||||
|
||||
- Do not merge PR #70 as the final backend architecture.
|
||||
- Split out the UI improvements and any safe Hermes-specific pieces.
|
||||
- Open a new architecture PR for the runtime provider seam.
|
||||
- Rebase Hermes integration on top of that seam.
|
||||
|
||||
## Why This Also Helps Vera
|
||||
|
||||
This path avoids making Vera imitate OpenClaw.
|
||||
|
||||
Instead, Vera can appear as:
|
||||
|
||||
- a routed multi-role intelligence backend,
|
||||
- with Claw3D visualizing agents, runs, status, and streamed text,
|
||||
- while preserving Vera-specific routing, lane, and model identity.
|
||||
|
||||
That gives Claw3D a broader identity:
|
||||
|
||||
- similar to the OpenClaw ecosystem,
|
||||
- but not subordinate to OpenClaw's protocol and assumptions.
|
||||
|
||||
## Proposed First Deliverable
|
||||
|
||||
The first concrete deliverable should be a new PR that does only this:
|
||||
|
||||
- add the provider interface,
|
||||
- wrap existing OpenClaw integration behind it,
|
||||
- add capability flags,
|
||||
- make the UI stop assuming config/approval/file support from every backend.
|
||||
|
||||
That PR creates the seam both Hermes and Vera need.
|
||||
@@ -0,0 +1,393 @@
|
||||
# Bulletin Board Spec
|
||||
|
||||
> First concrete office-system feature for Claw3D.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a shared bulletin board inside the office that acts as the visible coordination surface for:
|
||||
|
||||
- goals
|
||||
- announcements
|
||||
- blockers
|
||||
- handoff notes
|
||||
- standup outcomes
|
||||
- lightweight task cards
|
||||
|
||||
This should be the first step toward making Claw3D a real agent operations environment instead of only a gateway visualizer.
|
||||
|
||||
## Product Position
|
||||
|
||||
The bulletin board is not a replacement for the existing task board or Kanban views.
|
||||
|
||||
It is the office-native layer above them.
|
||||
|
||||
Think of it as:
|
||||
|
||||
- the most important things the office should see right now
|
||||
- the shared memory wall
|
||||
- the in-world coordination surface
|
||||
|
||||
## Why This Feature First
|
||||
|
||||
This is the best first office-system feature because it is:
|
||||
|
||||
- easy to understand
|
||||
- visually natural in the office
|
||||
- useful even before deeper simulation systems exist
|
||||
- compatible with all backends
|
||||
- able to reuse existing task and standup signals
|
||||
|
||||
It also creates a clean landing zone for future systems:
|
||||
|
||||
- whiteboards
|
||||
- meeting summaries
|
||||
- QA queues
|
||||
- hierarchy / department routing
|
||||
- shared office memory
|
||||
|
||||
## Primary Use Cases
|
||||
|
||||
### Shared Goals
|
||||
|
||||
Examples:
|
||||
|
||||
- "Ship Hermes adapter support"
|
||||
- "Fix production bug in standup flow"
|
||||
- "Prepare Friday release review"
|
||||
|
||||
### Announcements
|
||||
|
||||
Examples:
|
||||
|
||||
- "Hermes provider smoke test passed"
|
||||
- "Build is blocked on QA"
|
||||
- "Meeting starts in 5 minutes"
|
||||
|
||||
### Blockers
|
||||
|
||||
Examples:
|
||||
|
||||
- "Gateway auth broken on staging"
|
||||
- "Agent Alice waiting on review"
|
||||
- "No provider token configured"
|
||||
|
||||
### Handoffs
|
||||
|
||||
Examples:
|
||||
|
||||
- "Backend done, hand off to QA"
|
||||
- "Needs design signoff"
|
||||
- "Waiting for owner approval"
|
||||
|
||||
### Meeting Output
|
||||
|
||||
Examples:
|
||||
|
||||
- standup summary
|
||||
- decisions made
|
||||
- next actions
|
||||
- active speaker queue
|
||||
|
||||
## V1 Scope
|
||||
|
||||
V1 should stay intentionally small.
|
||||
|
||||
The board should support a few card types and simple interactions, not a full project-management suite.
|
||||
|
||||
### Card Types
|
||||
|
||||
Initial types:
|
||||
|
||||
- `goal`
|
||||
- `announcement`
|
||||
- `blocker`
|
||||
- `handoff`
|
||||
- `meeting_note`
|
||||
|
||||
### Card Fields
|
||||
|
||||
Minimum shape:
|
||||
|
||||
```ts
|
||||
type BulletinBoardCardType =
|
||||
| "goal"
|
||||
| "announcement"
|
||||
| "blocker"
|
||||
| "handoff"
|
||||
| "meeting_note";
|
||||
|
||||
type BulletinBoardCard = {
|
||||
id: string;
|
||||
type: BulletinBoardCardType;
|
||||
title: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authorType: "human" | "agent" | "system";
|
||||
authorId?: string | null;
|
||||
authorName?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
taskId?: string | null;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
priority?: "low" | "normal" | "high";
|
||||
tags?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
### Basic Interactions
|
||||
|
||||
V1 interactions:
|
||||
|
||||
- create card
|
||||
- edit card
|
||||
- pin/unpin card
|
||||
- archive/unarchive card
|
||||
- filter by type
|
||||
- filter by author
|
||||
- open linked session or linked agent
|
||||
|
||||
No drag-and-drop lane system is required for V1.
|
||||
|
||||
## Visual Design
|
||||
|
||||
The bulletin board should feel like a wall-mounted coordination surface inside the office.
|
||||
|
||||
Possible visual forms:
|
||||
|
||||
- cork board
|
||||
- notice board
|
||||
- sprint wall
|
||||
- pinboard with index cards / sticky notes
|
||||
|
||||
The in-world object should have:
|
||||
|
||||
- a visible prop in the retro office
|
||||
- a click target
|
||||
- an immersive detail panel when opened
|
||||
|
||||
It should feel distinct from the existing Kanban board.
|
||||
|
||||
Suggested difference:
|
||||
|
||||
- Kanban = detailed task workflow
|
||||
- Bulletin board = office-wide signal surface
|
||||
|
||||
## Information Hierarchy
|
||||
|
||||
At a glance, the board should answer:
|
||||
|
||||
1. What is the office trying to do?
|
||||
2. What is blocked?
|
||||
3. What changed recently?
|
||||
4. What needs a human to notice?
|
||||
|
||||
Recommended layout:
|
||||
|
||||
- pinned cards first
|
||||
- blockers prominently visible
|
||||
- recent announcements grouped together
|
||||
- meeting notes grouped separately
|
||||
|
||||
## Integration Points
|
||||
|
||||
The feature should hook into systems Claw3D already has.
|
||||
|
||||
### Task Board / Kanban
|
||||
|
||||
Use the bulletin board as a summary layer over the task board, not a duplicate.
|
||||
|
||||
Examples:
|
||||
|
||||
- show a pinned goal card that links into Kanban
|
||||
- create blocker cards when a task enters a blocked state
|
||||
- create handoff cards when work moves between agents or departments
|
||||
|
||||
### Standup System
|
||||
|
||||
The standup system already exists.
|
||||
|
||||
Use it to populate:
|
||||
|
||||
- current meeting announcement
|
||||
- summary card after standup completes
|
||||
- follow-up note cards for unresolved blockers
|
||||
|
||||
### Agent Sessions
|
||||
|
||||
Cards should be linkable to:
|
||||
|
||||
- an agent
|
||||
- a session key
|
||||
- a run or task where applicable
|
||||
|
||||
That lets the user jump from "office signal" to "underlying conversation or task".
|
||||
|
||||
### Runtime-Neutral Backends
|
||||
|
||||
The board must not depend on OpenClaw-specific methods.
|
||||
|
||||
It should operate off:
|
||||
|
||||
- Claw3D state
|
||||
- local persisted office data
|
||||
- optional provider metadata when available
|
||||
|
||||
That keeps it usable with:
|
||||
|
||||
- OpenClaw
|
||||
- Hermes
|
||||
- Vera
|
||||
- Demo mode
|
||||
|
||||
## Storage Model
|
||||
|
||||
V1 storage should be local office data persisted through the same Studio settings path used by other office preferences.
|
||||
|
||||
Suggested storage location:
|
||||
|
||||
- studio office settings keyed by gateway URL
|
||||
|
||||
Example shape:
|
||||
|
||||
```ts
|
||||
type OfficePreference = {
|
||||
bulletinBoard?: {
|
||||
cards: BulletinBoardCard[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
- matches existing office preference patterns
|
||||
- backend-neutral
|
||||
- fast to implement
|
||||
- easy to migrate later
|
||||
|
||||
## Authoring Rules
|
||||
|
||||
Cards may be created by:
|
||||
|
||||
- human user
|
||||
- agent action
|
||||
- system automation
|
||||
|
||||
Recommended rules:
|
||||
|
||||
- human cards should always be editable
|
||||
- system cards can be archived but not freely mutated
|
||||
- agent cards should show authorship clearly
|
||||
|
||||
That keeps provenance visible without overcomplicating the model.
|
||||
|
||||
## V1 Automation
|
||||
|
||||
Useful automations to add early:
|
||||
|
||||
- create a meeting note card after standup
|
||||
- create a blocker card from explicit blocked-state flows
|
||||
- create announcement cards for major office events
|
||||
|
||||
Keep automation conservative.
|
||||
|
||||
The board should not flood itself with noise.
|
||||
|
||||
## UI Surfaces
|
||||
|
||||
### In-World Object
|
||||
|
||||
Add a dedicated bulletin board prop to the office layout.
|
||||
|
||||
It should:
|
||||
|
||||
- be visible from the main office floor
|
||||
- support hover/click affordance
|
||||
- open an immersive board panel
|
||||
|
||||
### Sidebar / Panel Access
|
||||
|
||||
Also add a panel entry for cases where the user wants quick access without camera movement.
|
||||
|
||||
Possible placement:
|
||||
|
||||
- HQ sidebar tab
|
||||
- office control panel
|
||||
|
||||
### Agent Interaction
|
||||
|
||||
Optional for V1:
|
||||
|
||||
- agents can approach the board during meetings or handoffs
|
||||
- pinned cards can be reflected in ambient office behavior
|
||||
|
||||
This is useful but not required for first delivery.
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
Do not include these initially:
|
||||
|
||||
- full Kanban replacement
|
||||
- freehand drawing
|
||||
- multiplayer collaborative editing
|
||||
- complicated permission lattice
|
||||
- department-specific boards
|
||||
- heavy simulation logic
|
||||
- arbitrary external integrations
|
||||
|
||||
Those belong in later systems.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Define board card types and storage schema.
|
||||
2. Add persisted board data to office settings.
|
||||
3. Add a simple board panel UI.
|
||||
4. Add in-world bulletin board prop and open interaction.
|
||||
5. Connect standup summary output.
|
||||
6. Add simple blocker / announcement automation.
|
||||
|
||||
## Existing Code Seams
|
||||
|
||||
This feature should likely align with:
|
||||
|
||||
- task board state and controller logic in `src/features/office/tasks`
|
||||
- standup flows in `src/features/office/hooks/useOfficeStandupController.ts`
|
||||
- office settings persistence
|
||||
- retro office object interaction in `src/features/retro-office/RetroOffice3D.tsx`
|
||||
- furniture/object definitions in `src/features/retro-office/objects`
|
||||
|
||||
This is intentional.
|
||||
|
||||
The bulletin board should reuse existing office mechanics where possible.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
V1 is successful if:
|
||||
|
||||
- the user can open the board from inside the office
|
||||
- the board shows office-relevant cards, not just generic notes
|
||||
- standup or blocker information can appear on the board
|
||||
- cards can link back into agents/sessions/tasks
|
||||
- the system works with Hermes, OpenClaw, and demo mode
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Once V1 is stable, this can grow into:
|
||||
|
||||
- department boards
|
||||
- QA wall
|
||||
- release wall
|
||||
- meeting room whiteboard handoff
|
||||
- agent-authored summaries
|
||||
- office-wide historical archive
|
||||
- team-specific bulletin surfaces
|
||||
|
||||
## Summary
|
||||
|
||||
The bulletin board should become the first shared memory surface inside Claw3D.
|
||||
|
||||
It is the clearest next step toward making the office itself the product.
|
||||
@@ -0,0 +1,410 @@
|
||||
# Desk Progression Spec
|
||||
|
||||
> Fifth concrete office-system feature for Claw3D, connecting visible office presence to role maturity, permissions, and capability growth.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a desk progression system so agents visibly grow from limited office members into more capable contributors.
|
||||
|
||||
Desk progression should connect:
|
||||
|
||||
- role maturity
|
||||
- workspace/tool access
|
||||
- permissions
|
||||
- office identity
|
||||
- visible progression in the environment
|
||||
|
||||
The goal is not just cosmetics.
|
||||
|
||||
The goal is to make office growth legible and meaningful.
|
||||
|
||||
## Product Position
|
||||
|
||||
Desk progression should be the physical expression of organizational state.
|
||||
|
||||
It answers questions like:
|
||||
|
||||
- is this agent an intern or a fully trusted contributor?
|
||||
- what tools can they use?
|
||||
- how much autonomy do they have?
|
||||
- how much context or responsibility should they carry?
|
||||
|
||||
In other words:
|
||||
|
||||
- desk progression = visible capability ladder
|
||||
|
||||
## Why This Feature Matters
|
||||
|
||||
Without progression, all agents tend to feel flat.
|
||||
|
||||
Desk progression creates:
|
||||
|
||||
- visible hierarchy without requiring a complex org chart first
|
||||
- a natural path for permissions and access
|
||||
- stronger office storytelling
|
||||
- motivation for role specialization and promotion systems later
|
||||
|
||||
It also gives you a much cleaner bridge between:
|
||||
|
||||
- abstract policy
|
||||
- physical office layout
|
||||
- agent identity
|
||||
|
||||
## Core Principle
|
||||
|
||||
Do not start with fake game stats.
|
||||
|
||||
Start with operational capability tiers that can later gain more playful flavor.
|
||||
|
||||
That means progression should first affect:
|
||||
|
||||
- tool access
|
||||
- workspace access
|
||||
- review requirements
|
||||
- ability to spawn/delegate
|
||||
- context budget or workload tolerance
|
||||
|
||||
The visual office layer should reflect those operational differences.
|
||||
|
||||
## Example Role Ladder
|
||||
|
||||
Recommended initial tiers:
|
||||
|
||||
- `intern`
|
||||
- `probation`
|
||||
- `employee`
|
||||
- `senior`
|
||||
- `lead`
|
||||
- `contractor`
|
||||
|
||||
These are examples, not hard-coded lore.
|
||||
|
||||
### Intern
|
||||
|
||||
Characteristics:
|
||||
|
||||
- limited tools
|
||||
- limited workspace access
|
||||
- small or shared desk
|
||||
- requires close oversight
|
||||
|
||||
### Probation
|
||||
|
||||
Characteristics:
|
||||
|
||||
- basic desk
|
||||
- restricted autonomy
|
||||
- still under review for sensitive actions
|
||||
|
||||
### Employee
|
||||
|
||||
Characteristics:
|
||||
|
||||
- normal desk
|
||||
- normal task ownership
|
||||
- standard office access
|
||||
|
||||
### Senior
|
||||
|
||||
Characteristics:
|
||||
|
||||
- stronger autonomy
|
||||
- wider task scope
|
||||
- can mentor or review others
|
||||
|
||||
### Lead
|
||||
|
||||
Characteristics:
|
||||
|
||||
- can coordinate others
|
||||
- can trigger certain meetings
|
||||
- can manage or route work more broadly
|
||||
|
||||
### Contractor
|
||||
|
||||
Characteristics:
|
||||
|
||||
- useful specialist
|
||||
- limited long-term authority
|
||||
- constrained workspace and access model
|
||||
|
||||
## Suggested Capability Model
|
||||
|
||||
V1 should describe progression in terms of clear capability flags.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
type DeskTier =
|
||||
| "intern"
|
||||
| "probation"
|
||||
| "employee"
|
||||
| "senior"
|
||||
| "lead"
|
||||
| "contractor";
|
||||
|
||||
type DeskCapabilityProfile = {
|
||||
tier: DeskTier;
|
||||
canUseFileTools: boolean;
|
||||
canUseWebTools: boolean;
|
||||
canInstallSkills: boolean;
|
||||
canRequestApprovalsDirectly: boolean;
|
||||
canReviewOthers: boolean;
|
||||
canTriggerMeetings: boolean;
|
||||
canCreateTasks: boolean;
|
||||
canDelegateTasks: boolean;
|
||||
workspaceAccess: "none" | "limited" | "standard" | "extended";
|
||||
contextBudgetClass: "small" | "normal" | "large";
|
||||
};
|
||||
```
|
||||
|
||||
The exact values can evolve, but the idea should remain:
|
||||
|
||||
- tier drives visible access differences
|
||||
|
||||
## Visual Expression
|
||||
|
||||
Each tier should map to a clear desk/environment feel.
|
||||
|
||||
Examples:
|
||||
|
||||
### Intern Desk
|
||||
|
||||
- minimal desk
|
||||
- no dedicated computer or weaker setup
|
||||
- fewer personal objects
|
||||
- close to a shared area or support station
|
||||
|
||||
### Probation Desk
|
||||
|
||||
- basic computer
|
||||
- little customization
|
||||
- modest footprint
|
||||
|
||||
### Employee Desk
|
||||
|
||||
- normal workstation
|
||||
- standard office setup
|
||||
- stable identity in the room
|
||||
|
||||
### Senior Desk
|
||||
|
||||
- expanded desk
|
||||
- more equipment / screens / references
|
||||
- visually established presence
|
||||
|
||||
### Lead Desk
|
||||
|
||||
- premium workstation
|
||||
- visibility within the office
|
||||
- closer proximity to planning or meeting surfaces
|
||||
|
||||
### Contractor Desk
|
||||
|
||||
- temporary station
|
||||
- portable or isolated feel
|
||||
- clearly functional but not deeply embedded
|
||||
|
||||
## Relationship To Existing Systems
|
||||
|
||||
Desk progression should integrate with real Claw3D systems rather than sit beside them.
|
||||
|
||||
### Permissions
|
||||
|
||||
Claw3D already has permission and approval surfaces.
|
||||
|
||||
Desk progression should act as a higher-level office policy layer that influences:
|
||||
|
||||
- what defaults an agent gets
|
||||
- whether sensitive actions need review
|
||||
- what tools or flows are emphasized
|
||||
|
||||
Important:
|
||||
|
||||
This does not need to replace existing permission logic.
|
||||
|
||||
It should help explain and structure it.
|
||||
|
||||
### Workspace Access
|
||||
|
||||
Agents already have real workspaces.
|
||||
|
||||
Desk progression should help determine:
|
||||
|
||||
- how much workspace freedom an agent gets
|
||||
- whether they operate in restricted or normal modes
|
||||
- whether some installs or edits require higher tiers
|
||||
|
||||
### QA Department
|
||||
|
||||
More mature agents can naturally interact differently with QA.
|
||||
|
||||
Examples:
|
||||
|
||||
- interns more often route into review
|
||||
- seniors can participate in review
|
||||
- leads can mark certain work as ready for higher-level signoff
|
||||
|
||||
### Meeting Room
|
||||
|
||||
Meeting behavior can reflect progression.
|
||||
|
||||
Examples:
|
||||
|
||||
- leads can call planning meetings
|
||||
- seniors can present or facilitate review
|
||||
- interns may attend but not control outcomes
|
||||
|
||||
### Bulletin Board / Whiteboard
|
||||
|
||||
More mature tiers may:
|
||||
|
||||
- author higher-priority office notes
|
||||
- post official announcements
|
||||
- create planning documents for others
|
||||
|
||||
Again, this should be treated as office behavior, not roleplay for its own sake.
|
||||
|
||||
## Promotion / Progression Logic
|
||||
|
||||
V1 does not need automatic leveling.
|
||||
|
||||
Start with:
|
||||
|
||||
- manual assignment
|
||||
- explicit promotion/demotion
|
||||
- visible tier on the agent profile
|
||||
|
||||
Later, progression can be influenced by:
|
||||
|
||||
- successful task completion
|
||||
- review outcomes
|
||||
- reliability
|
||||
- blockers created vs resolved
|
||||
- trust level
|
||||
|
||||
## Suggested Data Model
|
||||
|
||||
Example V1 shape:
|
||||
|
||||
```ts
|
||||
type AgentDeskProfile = {
|
||||
agentId: string;
|
||||
tier: DeskTier;
|
||||
assignedDeskUid?: string | null;
|
||||
promotedAt?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
Office-level data:
|
||||
|
||||
```ts
|
||||
type OfficePreference = {
|
||||
deskProgression?: {
|
||||
byAgentId: Record<string, AgentDeskProfile>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Human Interaction Model
|
||||
|
||||
The human should be able to:
|
||||
|
||||
- view an agent’s desk tier
|
||||
- promote or demote an agent
|
||||
- reassign desk placement
|
||||
- understand what the tier changes operationally
|
||||
|
||||
This should be clear and reversible.
|
||||
|
||||
Do not hide progression behind mystery rules.
|
||||
|
||||
## Agent Interaction Model
|
||||
|
||||
Agents may later:
|
||||
|
||||
- request promotion
|
||||
- request better tools
|
||||
- recommend another agent for a role upgrade
|
||||
- be restricted from actions based on tier
|
||||
|
||||
But V1 should not depend on autonomous progression requests.
|
||||
|
||||
## V1 Scope
|
||||
|
||||
Recommended V1 scope:
|
||||
|
||||
- define desk tiers
|
||||
- persist per-agent desk tier
|
||||
- show desk tier in UI
|
||||
- apply visual desk differentiation
|
||||
- connect tier to a small number of capability differences
|
||||
|
||||
Good first capability differences:
|
||||
|
||||
- review / approval expectations
|
||||
- delegation rights
|
||||
- desk computer presence
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
Do not include these initially:
|
||||
|
||||
- hidden progression XP systems
|
||||
- complex morale simulation
|
||||
- salary/economy systems
|
||||
- automatic performance scoring
|
||||
- punitive systems that make agents unusable
|
||||
|
||||
Keep V1 understandable and operational.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Define desk tier model and profile storage.
|
||||
2. Add UI for viewing and assigning tier.
|
||||
3. Add retro-office visual differences by tier.
|
||||
4. Connect tier to a small capability profile.
|
||||
5. Surface tier in agent details and office presence.
|
||||
|
||||
## Existing Code Seams
|
||||
|
||||
This feature should likely align with:
|
||||
|
||||
- office desk assignment systems
|
||||
- agent settings / permissions UI
|
||||
- approval and policy surfaces
|
||||
- retro office desk rendering
|
||||
- office preferences persistence
|
||||
|
||||
This matters because progression should feel native to the office, not bolted on.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
V1 is successful if:
|
||||
|
||||
- desk tier is visible and understandable
|
||||
- the office reflects agent maturity visually
|
||||
- tier differences have real operational meaning
|
||||
- the user can promote/demote intentionally
|
||||
- the system reinforces office identity instead of distracting from it
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Once V1 is stable, later systems can add:
|
||||
|
||||
- promotion ceremonies or office events
|
||||
- hierarchy-aware desk placement
|
||||
- department-specific workstation styles
|
||||
- probation rules
|
||||
- contractor/offsite variants
|
||||
- context / workload tuning by tier
|
||||
|
||||
## Summary
|
||||
|
||||
Desk progression should turn office growth into something visible and operational.
|
||||
|
||||
It is the cleanest way to connect hierarchy, permissions, workspace access, and office identity without jumping straight into heavy simulation.
|
||||
@@ -0,0 +1,423 @@
|
||||
# Hierarchy And Teams Spec
|
||||
|
||||
> Sixth concrete office-system feature for Claw3D, turning visible office roles into an actual organizational model for delegation, permissions, and team coordination.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a hierarchy and teams system so the office can express:
|
||||
|
||||
- who leads
|
||||
- who reports where
|
||||
- who can delegate
|
||||
- who can approve
|
||||
- who belongs to which team
|
||||
|
||||
The goal is to move from "a group of agents in one room" to "an actual organization with structure".
|
||||
|
||||
## Product Position
|
||||
|
||||
Hierarchy and teams should not exist only as labels.
|
||||
|
||||
They should affect:
|
||||
|
||||
- delegation
|
||||
- review authority
|
||||
- meeting participation
|
||||
- bulletin/whiteboard authorship weight
|
||||
- task routing
|
||||
- desk progression meaning
|
||||
|
||||
This is the organizational layer that sits above desks and departments.
|
||||
|
||||
## Why This Feature Matters
|
||||
|
||||
Without hierarchy, all agents are peers by default.
|
||||
|
||||
That creates limits:
|
||||
|
||||
- delegation feels flat
|
||||
- responsibility is ambiguous
|
||||
- review authority is unclear
|
||||
- the office lacks believable structure
|
||||
|
||||
Hierarchy and teams solve that by giving the office:
|
||||
|
||||
- reporting lines
|
||||
- ownership boundaries
|
||||
- authority surfaces
|
||||
- coordination lanes
|
||||
|
||||
## Core Principle
|
||||
|
||||
Keep hierarchy operational, not theatrical.
|
||||
|
||||
Do not start with elaborate roleplay.
|
||||
|
||||
Start with real organizational questions:
|
||||
|
||||
- who can assign work?
|
||||
- who can review work?
|
||||
- who can call meetings?
|
||||
- who can finalize outcomes?
|
||||
- which agents belong together?
|
||||
|
||||
## Suggested Role Model
|
||||
|
||||
Recommended role classes:
|
||||
|
||||
- `owner`
|
||||
- `executive`
|
||||
- `manager`
|
||||
- `lead`
|
||||
- `member`
|
||||
- `contractor`
|
||||
- `intern`
|
||||
|
||||
These role classes are about authority and org structure.
|
||||
|
||||
They are distinct from:
|
||||
|
||||
- desk tier
|
||||
- functional specialty
|
||||
- department
|
||||
|
||||
### Owner
|
||||
|
||||
Characteristics:
|
||||
|
||||
- human-controlled top authority
|
||||
- final signoff for high-impact actions
|
||||
- can override structure
|
||||
|
||||
### Executive
|
||||
|
||||
Characteristics:
|
||||
|
||||
- broad office-level coordination
|
||||
- can set direction across teams
|
||||
|
||||
### Manager
|
||||
|
||||
Characteristics:
|
||||
|
||||
- owns a team or department lane
|
||||
- routes work
|
||||
- coordinates reviews and meetings
|
||||
|
||||
### Lead
|
||||
|
||||
Characteristics:
|
||||
|
||||
- technical or functional authority inside a team
|
||||
- can delegate and review
|
||||
|
||||
### Member
|
||||
|
||||
Characteristics:
|
||||
|
||||
- standard contributor role
|
||||
- executes work inside team scope
|
||||
|
||||
### Contractor
|
||||
|
||||
Characteristics:
|
||||
|
||||
- scoped contributor
|
||||
- limited authority outside assigned work
|
||||
|
||||
### Intern
|
||||
|
||||
Characteristics:
|
||||
|
||||
- low-authority contributor
|
||||
- learning / supervised mode
|
||||
|
||||
## Team Model
|
||||
|
||||
Teams should be explicit groups, not only emergent behavior.
|
||||
|
||||
Suggested examples:
|
||||
|
||||
- Platform
|
||||
- Frontend
|
||||
- QA
|
||||
- Research
|
||||
- Ops
|
||||
- Design
|
||||
|
||||
Suggested V1 shape:
|
||||
|
||||
```ts
|
||||
type TeamId = string;
|
||||
|
||||
type OfficeTeam = {
|
||||
id: TeamId;
|
||||
name: string;
|
||||
description?: string;
|
||||
leadAgentId?: string | null;
|
||||
managerAgentId?: string | null;
|
||||
memberAgentIds: string[];
|
||||
departmentId?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
## Hierarchy Model
|
||||
|
||||
Suggested V1 shape:
|
||||
|
||||
```ts
|
||||
type OfficeHierarchyRole =
|
||||
| "owner"
|
||||
| "executive"
|
||||
| "manager"
|
||||
| "lead"
|
||||
| "member"
|
||||
| "contractor"
|
||||
| "intern";
|
||||
|
||||
type AgentHierarchyProfile = {
|
||||
agentId: string;
|
||||
role: OfficeHierarchyRole;
|
||||
reportsToAgentId?: string | null;
|
||||
teamId?: string | null;
|
||||
departmentId?: string | null;
|
||||
canDelegate?: boolean;
|
||||
canReview?: boolean;
|
||||
canApprove?: boolean;
|
||||
canCallMeetings?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
The exact flags can be derived from role later.
|
||||
|
||||
V1 can store them explicitly for clarity if needed.
|
||||
|
||||
## Relationship To Desk Progression
|
||||
|
||||
Desk progression expresses maturity and capability in a physical way.
|
||||
|
||||
Hierarchy expresses authority and organizational position.
|
||||
|
||||
These should be related, but not identical.
|
||||
|
||||
Examples:
|
||||
|
||||
- a senior desk does not automatically make an agent a manager
|
||||
- a contractor may have a strong workstation but still limited authority
|
||||
- a lead may have more coordination authority than a senior member
|
||||
|
||||
That separation matters.
|
||||
|
||||
## Relationship To Departments
|
||||
|
||||
Departments are organizational domains.
|
||||
|
||||
Examples:
|
||||
|
||||
- Engineering
|
||||
- QA
|
||||
- Research
|
||||
- Ops
|
||||
|
||||
Teams live inside or alongside departments.
|
||||
|
||||
Examples:
|
||||
|
||||
- Engineering -> Frontend Team
|
||||
- Engineering -> Platform Team
|
||||
- QA -> Release Team
|
||||
|
||||
Hierarchy determines authority.
|
||||
Departments determine domain.
|
||||
Teams determine working group.
|
||||
|
||||
## Relationship To Meetings
|
||||
|
||||
Hierarchy should affect meetings in practical ways.
|
||||
|
||||
Examples:
|
||||
|
||||
- managers or leads can call planning meetings
|
||||
- review meetings may require a lead or manager present
|
||||
- interns may attend but not finalize decisions
|
||||
- executives may approve office-wide changes after summary
|
||||
|
||||
This gives meetings more structure without overcomplicating V1.
|
||||
|
||||
## Relationship To QA
|
||||
|
||||
Hierarchy should influence QA responsibility.
|
||||
|
||||
Examples:
|
||||
|
||||
- leads can review work
|
||||
- managers can route items into QA
|
||||
- members can request review
|
||||
- interns more often require review
|
||||
- owners/executives can override final readiness decisions when needed
|
||||
|
||||
QA should remain operationally distinct, but authority should not be flat.
|
||||
|
||||
## Relationship To Bulletin Board / Whiteboard
|
||||
|
||||
Hierarchy can shape information flow.
|
||||
|
||||
Examples:
|
||||
|
||||
- high-priority office announcements may come from leads/managers
|
||||
- planning whiteboards may identify team ownership
|
||||
- bulletin cards can show team and owner context
|
||||
|
||||
Important:
|
||||
|
||||
Do not hide information behind hierarchy.
|
||||
|
||||
Use hierarchy to improve clarity, not to make the office opaque.
|
||||
|
||||
## Delegation Model
|
||||
|
||||
Hierarchy becomes most useful when it changes delegation behavior.
|
||||
|
||||
Suggested operational rules:
|
||||
|
||||
- owners, executives, managers, and leads can delegate
|
||||
- members can hand off but not broadly route work across the org
|
||||
- contractors delegate only within limited scope
|
||||
- interns usually cannot delegate except in restricted workflows
|
||||
|
||||
This should be represented both:
|
||||
|
||||
- in UI
|
||||
- in agent-facing behavior and constraints where appropriate
|
||||
|
||||
## Visual Expression
|
||||
|
||||
Hierarchy should have visible but restrained expression in the office.
|
||||
|
||||
Examples:
|
||||
|
||||
- title/subtitle on agent nameplate
|
||||
- seat/desk placement
|
||||
- room proximity to planning areas
|
||||
- meeting table positioning
|
||||
- desk quality in combination with progression
|
||||
|
||||
The office should communicate structure without turning into a caricature.
|
||||
|
||||
## Human Interaction Model
|
||||
|
||||
The human should be able to:
|
||||
|
||||
- assign hierarchy role
|
||||
- assign team
|
||||
- set reporting line
|
||||
- move agents between teams
|
||||
- understand what organizational changes actually affect
|
||||
|
||||
This should be editable and transparent.
|
||||
|
||||
## Agent Interaction Model
|
||||
|
||||
Longer term, agents may:
|
||||
|
||||
- recommend reassignments
|
||||
- request escalation
|
||||
- request specialist support from another team
|
||||
- suggest promotions or org changes
|
||||
|
||||
V1 does not need autonomous re-org behavior.
|
||||
|
||||
V1 should focus on:
|
||||
|
||||
- clear structure
|
||||
- delegation paths
|
||||
- UI visibility
|
||||
|
||||
## V1 Scope
|
||||
|
||||
Recommended V1 scope:
|
||||
|
||||
- explicit hierarchy role per agent
|
||||
- explicit team membership
|
||||
- simple reporting line
|
||||
- visible title/subtitle
|
||||
- delegation and meeting authority rules at a lightweight level
|
||||
|
||||
Keep V1 small enough that it improves office understanding immediately.
|
||||
|
||||
## Storage Model
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```ts
|
||||
type OfficePreference = {
|
||||
hierarchy?: {
|
||||
byAgentId: Record<string, AgentHierarchyProfile>;
|
||||
teams: OfficeTeam[];
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
This keeps the feature local, backend-neutral, and easy to evolve.
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
Do not include these initially:
|
||||
|
||||
- automatic org chart optimization
|
||||
- political simulation
|
||||
- compensation/economy systems
|
||||
- punitive management mechanics
|
||||
- heavy workflow bureaucracy
|
||||
|
||||
The system should clarify work, not create needless friction.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Define hierarchy profile and team schema.
|
||||
2. Add office-level persistence.
|
||||
3. Add UI for role/team assignment.
|
||||
4. Show titles/subtitles and team membership in office/agent UI.
|
||||
5. Apply lightweight authority rules to delegation and meeting actions.
|
||||
|
||||
## Existing Code Seams
|
||||
|
||||
This feature should likely align with:
|
||||
|
||||
- role/title flow already added for agents
|
||||
- desk progression data and UI
|
||||
- meeting room workflows
|
||||
- QA routing
|
||||
- bulletin board ownership/priority metadata
|
||||
|
||||
This is important because hierarchy should unify other office systems rather than stand apart from them.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
V1 is successful if:
|
||||
|
||||
- the office can represent who leads and who belongs where
|
||||
- delegation and review paths are clearer
|
||||
- titles/teams are visible in the office
|
||||
- hierarchy affects at least a small set of real office behaviors
|
||||
- the system remains understandable and editable
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Once V1 is stable, follow-up work can add:
|
||||
|
||||
- org chart views
|
||||
- department dashboards
|
||||
- automatic escalation paths
|
||||
- team-specific meeting rituals
|
||||
- richer approval chains
|
||||
- promotion recommendations
|
||||
|
||||
## Summary
|
||||
|
||||
Hierarchy and teams should give Claw3D a real organizational model.
|
||||
|
||||
That model should support delegation, ownership, and coordination without losing the clarity and playfulness of the office metaphor.
|
||||
@@ -0,0 +1,390 @@
|
||||
# Meeting Room Workflow Spec
|
||||
|
||||
> Third concrete office-system feature for Claw3D, building on existing standup support and extending it into a generalized meeting workflow model.
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the meeting room from a visual location into an operational workflow surface.
|
||||
|
||||
The meeting room should become the place where agents:
|
||||
|
||||
- gather
|
||||
- present updates
|
||||
- coordinate plans
|
||||
- resolve blockers
|
||||
- record decisions
|
||||
- create follow-up actions
|
||||
|
||||
## Product Position
|
||||
|
||||
The meeting room is not just a room.
|
||||
|
||||
It is a workflow type.
|
||||
|
||||
That means the system should support:
|
||||
|
||||
- visible in-world gathering
|
||||
- structured meeting phases
|
||||
- meeting outputs that affect the rest of the office
|
||||
|
||||
It should connect naturally to:
|
||||
|
||||
- standup
|
||||
- whiteboard
|
||||
- bulletin board
|
||||
- task board
|
||||
- QA/review systems later
|
||||
|
||||
## Existing Foundation
|
||||
|
||||
Claw3D already has meaningful meeting-related pieces:
|
||||
|
||||
- a meeting room in the office layout
|
||||
- standup meeting state and API routes
|
||||
- participant arrival handling
|
||||
- immersive standup board UI
|
||||
- agent movement into the meeting area
|
||||
|
||||
This spec should treat standup as the first implemented meeting type, not as a special one-off.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Meetings should generate office state, not just temporary visuals.
|
||||
|
||||
Every meeting should be able to produce:
|
||||
|
||||
- summaries
|
||||
- decisions
|
||||
- blockers
|
||||
- next actions
|
||||
- linked whiteboard notes
|
||||
- linked bulletin board items
|
||||
|
||||
That is what makes the office feel alive and useful.
|
||||
|
||||
## Meeting Types
|
||||
|
||||
Recommended initial types:
|
||||
|
||||
- `standup`
|
||||
- `planning`
|
||||
- `review`
|
||||
- `incident`
|
||||
- `sync`
|
||||
|
||||
### Standup
|
||||
|
||||
Purpose:
|
||||
|
||||
- what each agent is working on
|
||||
- blockers
|
||||
- immediate next step visibility
|
||||
|
||||
### Planning
|
||||
|
||||
Purpose:
|
||||
|
||||
- define approach
|
||||
- compare options
|
||||
- assign next actions
|
||||
|
||||
### Review
|
||||
|
||||
Purpose:
|
||||
|
||||
- assess work completed
|
||||
- gather feedback
|
||||
- approve or reject next move
|
||||
|
||||
### Incident
|
||||
|
||||
Purpose:
|
||||
|
||||
- coordinate under failure or urgency
|
||||
- assign responsibilities
|
||||
- capture current status and recovery path
|
||||
|
||||
### Sync
|
||||
|
||||
Purpose:
|
||||
|
||||
- lightweight multi-agent coordination
|
||||
- brief handoffs
|
||||
- cross-team visibility
|
||||
|
||||
## Workflow Model
|
||||
|
||||
Each meeting should have explicit phases.
|
||||
|
||||
Suggested phases:
|
||||
|
||||
- `scheduled`
|
||||
- `gathering`
|
||||
- `in_progress`
|
||||
- `decision`
|
||||
- `complete`
|
||||
- `archived`
|
||||
|
||||
### Scheduled
|
||||
|
||||
Meeting exists but has not started.
|
||||
|
||||
### Gathering
|
||||
|
||||
Agents are walking to the meeting room or otherwise being assembled.
|
||||
|
||||
### In Progress
|
||||
|
||||
Updates are being presented, questions asked, and information collected.
|
||||
|
||||
### Decision
|
||||
|
||||
The meeting is converging:
|
||||
|
||||
- decisions recorded
|
||||
- unresolved blockers identified
|
||||
- next actions prepared
|
||||
|
||||
### Complete
|
||||
|
||||
The outputs are finalized and written back into office systems.
|
||||
|
||||
### Archived
|
||||
|
||||
Meeting is preserved in history but no longer active.
|
||||
|
||||
## Suggested Data Model
|
||||
|
||||
V1 generalized meeting shape:
|
||||
|
||||
```ts
|
||||
type MeetingType =
|
||||
| "standup"
|
||||
| "planning"
|
||||
| "review"
|
||||
| "incident"
|
||||
| "sync";
|
||||
|
||||
type MeetingPhase =
|
||||
| "scheduled"
|
||||
| "gathering"
|
||||
| "in_progress"
|
||||
| "decision"
|
||||
| "complete"
|
||||
| "archived";
|
||||
|
||||
type MeetingActionItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
assignedAgentId?: string | null;
|
||||
linkedTaskId?: string | null;
|
||||
status: "open" | "done" | "dropped";
|
||||
};
|
||||
|
||||
type MeetingDecision = {
|
||||
id: string;
|
||||
text: string;
|
||||
authorType: "human" | "agent" | "system";
|
||||
authorId?: string | null;
|
||||
};
|
||||
|
||||
type OfficeMeeting = {
|
||||
id: string;
|
||||
type: MeetingType;
|
||||
phase: MeetingPhase;
|
||||
title: string;
|
||||
startedAt?: string | null;
|
||||
updatedAt: string;
|
||||
participantAgentIds: string[];
|
||||
arrivedAgentIds: string[];
|
||||
currentSpeakerAgentId?: string | null;
|
||||
summary?: string | null;
|
||||
blockers: string[];
|
||||
decisions: MeetingDecision[];
|
||||
actionItems: MeetingActionItem[];
|
||||
whiteboardDocumentId?: string | null;
|
||||
bulletinCardIds?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
## Relationship To Standup
|
||||
|
||||
The current standup system should become the first meeting implementation under this model.
|
||||
|
||||
That means:
|
||||
|
||||
- keep standup behavior working
|
||||
- preserve arrival and speaker sequencing
|
||||
- treat standup as a specialized meeting workflow
|
||||
- reuse the immersive standup screen as the first meeting immersive view
|
||||
|
||||
In practice:
|
||||
|
||||
- standup = `MeetingType: standup`
|
||||
- existing standup cards become structured meeting inputs
|
||||
- standup completion should emit durable outputs into whiteboard and bulletin board systems
|
||||
|
||||
## Whiteboard Integration
|
||||
|
||||
Every meaningful meeting should have a whiteboard relationship.
|
||||
|
||||
Possible behaviors:
|
||||
|
||||
- auto-create whiteboard document when meeting starts
|
||||
- write summary sections as the meeting progresses
|
||||
- capture blockers, decisions, and next actions into whiteboard blocks
|
||||
|
||||
Suggested mapping:
|
||||
|
||||
- meeting discussion -> whiteboard notes
|
||||
- decisions -> whiteboard decision blocks
|
||||
- next actions -> whiteboard action blocks
|
||||
|
||||
The whiteboard is the drafting surface during the meeting.
|
||||
|
||||
## Bulletin Board Integration
|
||||
|
||||
The bulletin board is the public output surface after the meeting.
|
||||
|
||||
Suggested mapping:
|
||||
|
||||
- important decision -> announcement card
|
||||
- blocker -> blocker card
|
||||
- action item with office-wide significance -> handoff card
|
||||
- meeting completion -> meeting note card
|
||||
|
||||
The meeting room should feed the bulletin board, not bypass it.
|
||||
|
||||
## Task Board Integration
|
||||
|
||||
Meetings should be able to seed or update tasks.
|
||||
|
||||
Examples:
|
||||
|
||||
- planning meeting creates task candidates
|
||||
- review meeting marks work ready for QA
|
||||
- incident meeting creates urgent recovery tasks
|
||||
|
||||
The task board remains the detailed execution layer.
|
||||
|
||||
The meeting room creates and updates intent.
|
||||
|
||||
## Human Interaction Model
|
||||
|
||||
The human should be able to:
|
||||
|
||||
- start a meeting
|
||||
- pick meeting type
|
||||
- pick participants
|
||||
- follow progress
|
||||
- intervene during the meeting
|
||||
- edit outcomes
|
||||
- confirm or reject generated next steps
|
||||
|
||||
The user should not lose control over the outputs just because the meeting is agent-driven.
|
||||
|
||||
## Agent Interaction Model
|
||||
|
||||
Agents should be able to:
|
||||
|
||||
- gather into the meeting room
|
||||
- take speaking turns
|
||||
- surface blockers
|
||||
- suggest next steps
|
||||
- add whiteboard content
|
||||
- create meeting-derived outputs when allowed
|
||||
|
||||
Longer term, hierarchy may affect who can:
|
||||
|
||||
- call meetings
|
||||
- approve decisions
|
||||
- assign action items
|
||||
|
||||
## Visual / Spatial Behavior
|
||||
|
||||
The meeting room should visibly change state during active meetings.
|
||||
|
||||
Possible signals:
|
||||
|
||||
- agents walk to seats
|
||||
- current speaker highlighting
|
||||
- board auto-opens or highlights
|
||||
- room status banner
|
||||
- meeting timer / phase indicator
|
||||
|
||||
The office should make it obvious that something coordinated is happening.
|
||||
|
||||
## V1 Scope
|
||||
|
||||
V1 should focus on turning standup into the first generalized meeting flow.
|
||||
|
||||
Recommended V1 scope:
|
||||
|
||||
- meeting type abstraction for standup
|
||||
- whiteboard output on meeting completion
|
||||
- bulletin board output on meeting completion
|
||||
- simple action-item capture
|
||||
- immersive meeting screen improvements
|
||||
|
||||
Do not try to build all meeting types at once.
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
- voice/video simulation
|
||||
- arbitrary meeting transcripts
|
||||
- real-time collaborative editing by many actors at once
|
||||
- department-specific meeting policies
|
||||
- advanced approval chains
|
||||
- multiplayer human facilitation
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Generalize standup data model into a broader meeting model.
|
||||
2. Keep standup UI working on top of that generalized model.
|
||||
3. Add whiteboard document creation/output for completed meetings.
|
||||
4. Add bulletin board output for decisions and blockers.
|
||||
5. Add action item seeding into task workflows.
|
||||
6. Introduce second meeting type, likely `planning`.
|
||||
|
||||
## Existing Code Seams
|
||||
|
||||
This work should likely align with:
|
||||
|
||||
- `src/features/office/hooks/useOfficeStandupController.ts`
|
||||
- `src/features/office/screens/StandupImmersiveScreen.tsx`
|
||||
- `src/app/api/office/standup/*`
|
||||
- retro office meeting-room positioning and agent movement
|
||||
- office state persistence
|
||||
|
||||
This is important because Claw3D already has the skeleton of a meeting system.
|
||||
|
||||
The right path is to extend it, not replace it.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
V1 is successful if:
|
||||
|
||||
- standup remains functional
|
||||
- standup now behaves like the first generalized meeting workflow
|
||||
- meeting completion can write useful results into whiteboard and bulletin board systems
|
||||
- users can see meeting outcomes affect the rest of the office
|
||||
- the system remains backend-neutral
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Once the workflow model is stable, follow-up work can add:
|
||||
|
||||
- planning meetings
|
||||
- review meetings
|
||||
- incident rooms
|
||||
- hierarchy-aware meeting permissions
|
||||
- department-specific meeting rituals
|
||||
- richer meeting summaries and archives
|
||||
|
||||
## Summary
|
||||
|
||||
The meeting room should become the office’s coordination engine.
|
||||
|
||||
Standup is the starting point, but the real goal is a general workflow where meetings create durable plans, blockers, decisions, and next actions that shape the whole office.
|
||||
@@ -0,0 +1,349 @@
|
||||
# Office Systems Roadmap
|
||||
|
||||
> Product roadmap for turning Claw3D from a gateway visualizer into a living agent operations environment.
|
||||
|
||||
## Core Direction
|
||||
|
||||
Claw3D should keep users inside the office.
|
||||
|
||||
That means companion tools should be brought into the space as rooms, surfaces, devices, and shared systems instead of pulling users out into separate interfaces.
|
||||
|
||||
The guiding principle is:
|
||||
|
||||
- do not spawn Claw3D inside another tool
|
||||
- bring the other tool into Claw3D
|
||||
|
||||
This is especially relevant for ideas like Moltbook. The better version is not "leave Claw3D to use Moltbook". The better version is:
|
||||
|
||||
- a bulletin board in the office
|
||||
- a whiteboard in meeting rooms
|
||||
- a desk computer app
|
||||
- a shared intranet terminal
|
||||
- a wall display in common spaces
|
||||
|
||||
## Product Goal
|
||||
|
||||
Claw3D should evolve into an agent operations environment with:
|
||||
|
||||
- visual presence
|
||||
- planning and task coordination
|
||||
- meetings and handoffs
|
||||
- review and QA
|
||||
- hierarchy and permissions
|
||||
- workplace state
|
||||
- progression and identity
|
||||
|
||||
The office should feel like a real place where work happens, not only a dashboard for remote agent calls.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Keep primary workflows in-world when possible.
|
||||
- Prefer physical metaphors that make the office easier to understand.
|
||||
- Separate real operational systems from cosmetic flavor.
|
||||
- Build useful features first, then layer on simulation and style.
|
||||
- Preserve backend neutrality so these systems work across OpenClaw, Hermes, Vera, and future providers.
|
||||
|
||||
## V1: Useful Office Systems
|
||||
|
||||
These should be the first systems because they add product value immediately and fit the existing office concept naturally.
|
||||
|
||||
### Bulletin Board
|
||||
|
||||
Purpose:
|
||||
|
||||
- shared goals
|
||||
- current sprint priorities
|
||||
- blockers
|
||||
- announcements
|
||||
- handoff notes
|
||||
|
||||
Possible behaviors:
|
||||
|
||||
- sticky notes or task cards pinned by agents or humans
|
||||
- cards linked to sessions, agents, or tasks
|
||||
- quick visibility into what the office is trying to accomplish
|
||||
|
||||
Why it matters:
|
||||
|
||||
- low ambiguity
|
||||
- high utility
|
||||
- strong visual fit for the office
|
||||
|
||||
### Whiteboard
|
||||
|
||||
Purpose:
|
||||
|
||||
- brainstorming
|
||||
- architecture notes
|
||||
- meeting notes
|
||||
- rough plans
|
||||
- idea capture
|
||||
|
||||
Possible behaviors:
|
||||
|
||||
- text notes
|
||||
- grouped cards
|
||||
- simple sketches or structured plan areas
|
||||
- human and agent authored content
|
||||
|
||||
Why it matters:
|
||||
|
||||
- good bridge between conversation and execution
|
||||
- natural place for planning artifacts
|
||||
|
||||
### Meeting Room Workflows
|
||||
|
||||
Purpose:
|
||||
|
||||
- standups
|
||||
- planning
|
||||
- coordination
|
||||
- decision making
|
||||
- status reviews
|
||||
|
||||
Possible behaviors:
|
||||
|
||||
- gather selected agents into a meeting
|
||||
- produce summary, decisions, and next actions
|
||||
- write results to bulletin board or whiteboard
|
||||
- trigger structured follow-up tasks
|
||||
|
||||
Why it matters:
|
||||
|
||||
- gives multi-agent coordination a visible home
|
||||
- makes the office feel operational instead of decorative
|
||||
|
||||
### QA Department
|
||||
|
||||
Purpose:
|
||||
|
||||
- review
|
||||
- testing
|
||||
- bug triage
|
||||
- release readiness
|
||||
|
||||
Possible behaviors:
|
||||
|
||||
- route tasks or runs to QA agents
|
||||
- visualize test queues
|
||||
- track failures and review outcomes
|
||||
- require QA signoff before release-style actions
|
||||
|
||||
Why it matters:
|
||||
|
||||
- this is real product value, not only flavor
|
||||
- it matches how users already think about software teams
|
||||
|
||||
### Desk / CPU Progression
|
||||
|
||||
Purpose:
|
||||
|
||||
- make role maturity visible
|
||||
- tie capability to office presence
|
||||
|
||||
Possible behaviors:
|
||||
|
||||
- interns start with minimal desk access
|
||||
- probationary agents have limited tools or workspace
|
||||
- promoted agents unlock desk computers, tools, or context budget
|
||||
- contractors get restricted environments
|
||||
|
||||
Why it matters:
|
||||
|
||||
- strong visual progression
|
||||
- easy to understand
|
||||
- creates room for permissions and capability systems later
|
||||
|
||||
## V2: Management Systems
|
||||
|
||||
These systems add organizational structure once the basic office workflows are useful.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
Possible levels:
|
||||
|
||||
- human owner
|
||||
- CEO / lead orchestrator
|
||||
- managers / bosses
|
||||
- employees
|
||||
- contractors
|
||||
- interns
|
||||
|
||||
Possible effects:
|
||||
|
||||
- delegation rights
|
||||
- approval authority
|
||||
- visibility across teams
|
||||
- access to spaces and tools
|
||||
|
||||
### Departments
|
||||
|
||||
Examples:
|
||||
|
||||
- Engineering
|
||||
- QA
|
||||
- Research
|
||||
- Ops
|
||||
- Design
|
||||
- Support
|
||||
|
||||
Possible effects:
|
||||
|
||||
- room ownership
|
||||
- task routing
|
||||
- dashboards by department
|
||||
- workload balancing
|
||||
|
||||
### Permission Lanes
|
||||
|
||||
Possible controls:
|
||||
|
||||
- context budget
|
||||
- tool access
|
||||
- file access
|
||||
- approval requirements
|
||||
- concurrency
|
||||
- agent spawning / dismissal rights
|
||||
|
||||
Why it matters:
|
||||
|
||||
- lets the office represent real operational constraints
|
||||
- reduces "all agents are identical" flatness
|
||||
|
||||
### Office Rituals
|
||||
|
||||
Examples:
|
||||
|
||||
- daily standup
|
||||
- sprint planning
|
||||
- review/demo
|
||||
- retrospective
|
||||
- incident response
|
||||
|
||||
Why it matters:
|
||||
|
||||
- converts routine coordination into visible office behavior
|
||||
|
||||
## V3: Simulation Systems
|
||||
|
||||
These are the fun layers, but they should sit on top of useful product systems rather than replace them.
|
||||
|
||||
### Agent State Model
|
||||
|
||||
Avoid fake emotions first. Start with operational states:
|
||||
|
||||
- focused
|
||||
- idle
|
||||
- blocked
|
||||
- overloaded
|
||||
- waiting
|
||||
- cooling down
|
||||
- degraded
|
||||
|
||||
Possible effects:
|
||||
|
||||
- response speed
|
||||
- delegation tendency
|
||||
- context budget
|
||||
- summarization pressure
|
||||
- task throughput
|
||||
|
||||
This can later evolve into a more playful "wellbeing" or "comfort" layer without losing technical meaning.
|
||||
|
||||
### Workplace Culture
|
||||
|
||||
Examples:
|
||||
|
||||
- recognition
|
||||
- probation periods
|
||||
- promotions
|
||||
- competitions
|
||||
- events
|
||||
|
||||
Use carefully:
|
||||
|
||||
- good for flavor and identity
|
||||
- should not obscure the operational state of the system
|
||||
|
||||
### Shared Office Memory
|
||||
|
||||
Examples:
|
||||
|
||||
- bulletin archives
|
||||
- meeting minutes
|
||||
- org notes
|
||||
- playbooks
|
||||
- team history
|
||||
|
||||
Why it matters:
|
||||
|
||||
- gives the office continuity across sessions
|
||||
- helps explain why teams get better over time
|
||||
|
||||
## Moltbook Integration Direction
|
||||
|
||||
Moltbook should be integrated into Claw3D, not the other way around.
|
||||
|
||||
Best forms:
|
||||
|
||||
- office bulletin board
|
||||
- intranet terminal
|
||||
- desk CPU app
|
||||
- wall monitor
|
||||
- break-room or lobby information surface
|
||||
|
||||
Bad form:
|
||||
|
||||
- forcing users to leave Claw3D for core team coordination workflows
|
||||
|
||||
The office should remain the primary interaction layer.
|
||||
|
||||
## Candidate Feature Order
|
||||
|
||||
Recommended sequence:
|
||||
|
||||
1. Bulletin board
|
||||
2. Whiteboard
|
||||
3. Meeting room workflows
|
||||
4. QA department
|
||||
5. Desk / CPU progression
|
||||
6. Hierarchy and departments
|
||||
7. Agent operational state model
|
||||
8. Culture / sim systems
|
||||
9. Theme skins
|
||||
|
||||
## Theme / Skin Strategy
|
||||
|
||||
Skins should come after the office has enough systems worth skinning.
|
||||
|
||||
Mechanics should stay consistent while art, labels, props, and room names vary.
|
||||
|
||||
Possible theme packs:
|
||||
|
||||
- Office Space
|
||||
- The Office
|
||||
- Parks & Rec
|
||||
- The I.T. Crowd
|
||||
|
||||
Examples:
|
||||
|
||||
- conference room becomes town hall, bullpen, annex, or ops room
|
||||
- bulletin board becomes notice board, incident wall, municipal board, or sprint wall
|
||||
- QA area becomes testing lab, audit desk, or review bullpen
|
||||
|
||||
## Immediate Next Deliverables
|
||||
|
||||
If this roadmap is used for implementation planning, the best next concrete docs/tasks are:
|
||||
|
||||
1. Bulletin board system spec
|
||||
2. Whiteboard interaction spec
|
||||
3. Meeting room workflow spec
|
||||
4. QA department workflow spec
|
||||
|
||||
Those four would create the strongest foundation for future hierarchy, progression, and simulation layers.
|
||||
|
||||
## Summary
|
||||
|
||||
Claw3D gets stronger when the office becomes the place where work actually happens.
|
||||
|
||||
The best next step is not expanding external tooling around the office. It is bringing planning, meetings, reviews, and shared memory into the office itself.
|
||||
@@ -0,0 +1,464 @@
|
||||
# QA Department Spec
|
||||
|
||||
> Fourth concrete office-system feature for Claw3D, completing the first real office loop: plan, coordinate, execute, review.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a QA department workflow to Claw3D so the office can visibly review, test, triage, and sign off on work before it is treated as complete.
|
||||
|
||||
The QA department should make review state legible in-world.
|
||||
|
||||
It is where the office asks:
|
||||
|
||||
- does this actually work?
|
||||
- what failed?
|
||||
- what is blocked?
|
||||
- what is safe to ship?
|
||||
|
||||
## Product Position
|
||||
|
||||
QA should not be just flavor.
|
||||
|
||||
It should be an operational system that connects:
|
||||
|
||||
- tasks
|
||||
- agent work output
|
||||
- reviews
|
||||
- approvals
|
||||
- regressions
|
||||
- release-readiness
|
||||
|
||||
The QA department is the office’s verification layer.
|
||||
|
||||
## Why This Feature Matters
|
||||
|
||||
Without a QA layer, the office can generate and coordinate work but not convincingly validate it.
|
||||
|
||||
QA adds:
|
||||
|
||||
- visible review state
|
||||
- feedback loops
|
||||
- bug triage
|
||||
- approval pressure where needed
|
||||
- a clearer path from "done writing" to "done safely"
|
||||
|
||||
It also pairs naturally with:
|
||||
|
||||
- bulletin board blockers
|
||||
- meeting room review workflows
|
||||
- task board status
|
||||
- approval systems
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
The QA department should handle:
|
||||
|
||||
- review intake
|
||||
- test/result tracking
|
||||
- bug triage
|
||||
- regression visibility
|
||||
- release gate / readiness signal
|
||||
|
||||
## Primary Use Cases
|
||||
|
||||
### Review Queue
|
||||
|
||||
Examples:
|
||||
|
||||
- a task is ready for QA
|
||||
- an agent requests review
|
||||
- a release candidate needs signoff
|
||||
|
||||
### Bug Triage
|
||||
|
||||
Examples:
|
||||
|
||||
- classify failures
|
||||
- route issues to the right owner
|
||||
- mark severity
|
||||
- surface blockers to the office
|
||||
|
||||
### Regression Detection
|
||||
|
||||
Examples:
|
||||
|
||||
- recent change broke existing behavior
|
||||
- previously passing workflow now fails
|
||||
- approval flow or adapter integration regressed
|
||||
|
||||
### Approval-Aware Review
|
||||
|
||||
Examples:
|
||||
|
||||
- code/run needs human approval before release-like action
|
||||
- QA can recommend approval but not finalize it
|
||||
- owners or leads can override or sign off
|
||||
|
||||
### Release Readiness
|
||||
|
||||
Examples:
|
||||
|
||||
- green / yellow / red office-level signal
|
||||
- unresolved blockers prevent completion
|
||||
- review summary appears on bulletin board
|
||||
|
||||
## V1 Scope
|
||||
|
||||
V1 should focus on clear office-level QA workflows, not a full CI system.
|
||||
|
||||
Recommended V1 scope:
|
||||
|
||||
- QA queue
|
||||
- QA status per task or work item
|
||||
- bug / blocker recording
|
||||
- review outcome states
|
||||
- office-visible readiness signal
|
||||
|
||||
## Suggested Workflow Model
|
||||
|
||||
Recommended QA states:
|
||||
|
||||
- `queued`
|
||||
- `in_review`
|
||||
- `changes_requested`
|
||||
- `blocked`
|
||||
- `approved`
|
||||
- `failed`
|
||||
- `verified`
|
||||
|
||||
### Queued
|
||||
|
||||
Work has entered QA but has not been actively reviewed yet.
|
||||
|
||||
### In Review
|
||||
|
||||
A QA agent or human reviewer is assessing the work.
|
||||
|
||||
### Changes Requested
|
||||
|
||||
Work is not acceptable yet and must be revised.
|
||||
|
||||
### Blocked
|
||||
|
||||
QA cannot proceed because a dependency, approval, or missing artifact prevents review.
|
||||
|
||||
### Approved
|
||||
|
||||
Review is positive, but final release/ship behavior may still depend on a higher-level approval model.
|
||||
|
||||
### Failed
|
||||
|
||||
Verification found concrete failure.
|
||||
|
||||
### Verified
|
||||
|
||||
The work passed the required QA checks and is complete from the department’s perspective.
|
||||
|
||||
## Suggested Data Model
|
||||
|
||||
V1 shape:
|
||||
|
||||
```ts
|
||||
type QaStatus =
|
||||
| "queued"
|
||||
| "in_review"
|
||||
| "changes_requested"
|
||||
| "blocked"
|
||||
| "approved"
|
||||
| "failed"
|
||||
| "verified";
|
||||
|
||||
type QaSeverity = "low" | "medium" | "high" | "critical";
|
||||
|
||||
type QaIssue = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
severity: QaSeverity;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authorType: "human" | "agent" | "system";
|
||||
authorId?: string | null;
|
||||
linkedTaskId?: string | null;
|
||||
linkedAgentId?: string | null;
|
||||
linkedSessionKey?: string | null;
|
||||
resolved: boolean;
|
||||
};
|
||||
|
||||
type QaReviewItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: QaStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
assignedReviewerAgentId?: string | null;
|
||||
linkedTaskId?: string | null;
|
||||
linkedAgentId?: string | null;
|
||||
linkedSessionKey?: string | null;
|
||||
summary?: string | null;
|
||||
issues: QaIssue[];
|
||||
};
|
||||
|
||||
type QaDepartmentState = {
|
||||
items: QaReviewItem[];
|
||||
readiness: "green" | "yellow" | "red";
|
||||
updatedAt?: string;
|
||||
};
|
||||
```
|
||||
|
||||
## Relationship To Existing Systems
|
||||
|
||||
The QA department should plug into systems Claw3D already has.
|
||||
|
||||
### Task Board / Kanban
|
||||
|
||||
The QA department should consume work from the task board.
|
||||
|
||||
Examples:
|
||||
|
||||
- task moves into a review-ready state
|
||||
- QA item is created or updated
|
||||
- blocked QA creates blocker visibility back on the bulletin board
|
||||
|
||||
Suggested relationship:
|
||||
|
||||
- task board = execution status
|
||||
- QA department = verification status
|
||||
|
||||
### Bulletin Board
|
||||
|
||||
The bulletin board should show the important QA outcomes.
|
||||
|
||||
Examples:
|
||||
|
||||
- "Build blocked on QA"
|
||||
- "Regression found in Hermes adapter flow"
|
||||
- "Release candidate verified"
|
||||
|
||||
Suggested card mapping:
|
||||
|
||||
- critical QA issue -> blocker card
|
||||
- release-ready signal -> announcement card
|
||||
- changes requested -> handoff card
|
||||
|
||||
### Meeting Room
|
||||
|
||||
Review meetings should naturally feed into QA.
|
||||
|
||||
Examples:
|
||||
|
||||
- planning meeting creates work
|
||||
- execution completes
|
||||
- review meeting sends selected items into QA
|
||||
- QA findings can be discussed in a follow-up review meeting
|
||||
|
||||
This makes the meeting room and QA department part of one loop instead of separate ideas.
|
||||
|
||||
### Approvals
|
||||
|
||||
Claw3D already has approval-related surfaces.
|
||||
|
||||
The QA department should integrate with them conceptually, even if V1 is mostly local office state.
|
||||
|
||||
Important distinction:
|
||||
|
||||
- QA approval = "this looks good from verification"
|
||||
- release approval = "a human or higher authority allows the next action"
|
||||
|
||||
Those are related but not identical.
|
||||
|
||||
### GitHub / Review Surfaces
|
||||
|
||||
Claw3D already has review-adjacent UI, including GitHub-oriented immersive screens.
|
||||
|
||||
The QA department should be able to:
|
||||
|
||||
- reflect review outcomes
|
||||
- ingest review summaries
|
||||
- show whether work is waiting for review or returned with changes requested
|
||||
|
||||
## In-World UX
|
||||
|
||||
The QA department should feel like a place in the office.
|
||||
|
||||
Possible visual forms:
|
||||
|
||||
- QA lab
|
||||
- testing bullpen
|
||||
- release desk
|
||||
- audit wall
|
||||
|
||||
Behavior:
|
||||
|
||||
- queue visible in-world
|
||||
- blocked items stand out clearly
|
||||
- verified items visibly clear from the queue
|
||||
- readiness state visible at a glance
|
||||
|
||||
The room should communicate office health, not just hold another panel.
|
||||
|
||||
## Secondary UI
|
||||
|
||||
Also provide a non-spatial UI surface.
|
||||
|
||||
Good options:
|
||||
|
||||
- HQ sidebar panel
|
||||
- immersive QA screen
|
||||
- release/readiness panel
|
||||
|
||||
Users should be able to inspect:
|
||||
|
||||
- queued reviews
|
||||
- open issues
|
||||
- who owns each item
|
||||
- overall readiness state
|
||||
|
||||
## V1 Automation
|
||||
|
||||
Useful automations:
|
||||
|
||||
- create a QA item when a task enters review-ready state
|
||||
- create blocker cards for high-severity QA issues
|
||||
- update readiness color based on unresolved critical/high issues
|
||||
- generate a short QA summary when an item leaves review
|
||||
|
||||
Keep automation conservative.
|
||||
|
||||
Avoid flooding the system with low-value noise.
|
||||
|
||||
## Storage Model
|
||||
|
||||
V1 can be stored in office preferences, similar to bulletin board and whiteboard systems.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```ts
|
||||
type OfficePreference = {
|
||||
qaDepartment?: QaDepartmentState;
|
||||
};
|
||||
```
|
||||
|
||||
This keeps the feature:
|
||||
|
||||
- backend-neutral
|
||||
- easy to persist
|
||||
- easy to evolve later
|
||||
|
||||
## Human Interaction Model
|
||||
|
||||
The human should be able to:
|
||||
|
||||
- open the QA queue
|
||||
- inspect a review item
|
||||
- mark status changes
|
||||
- add issues
|
||||
- resolve issues
|
||||
- promote or reject readiness
|
||||
|
||||
Humans should remain the final arbiter when needed, especially for ship/release-style outcomes.
|
||||
|
||||
## Agent Interaction Model
|
||||
|
||||
QA agents should be able to:
|
||||
|
||||
- review work items
|
||||
- generate findings
|
||||
- summarize likely regressions
|
||||
- mark items as changes requested or verified
|
||||
- surface blockers
|
||||
|
||||
Longer term:
|
||||
|
||||
- specialized QA agents may exist by area
|
||||
- adapter QA
|
||||
- UI QA
|
||||
- release QA
|
||||
- regression QA
|
||||
|
||||
## Readiness Signal
|
||||
|
||||
The department should publish an office-level readiness state:
|
||||
|
||||
- `green`
|
||||
- `yellow`
|
||||
- `red`
|
||||
|
||||
Suggested meaning:
|
||||
|
||||
- green = no blocking QA issues
|
||||
- yellow = warnings / pending review / moderate unresolved issues
|
||||
- red = blocking failures or critical unresolved issues
|
||||
|
||||
This signal should be visible outside the QA room as well.
|
||||
|
||||
For example:
|
||||
|
||||
- bulletin board card
|
||||
- office status banner
|
||||
- release desk indicator
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
Do not include these initially:
|
||||
|
||||
- full CI orchestration
|
||||
- external test runner infrastructure
|
||||
- rich flake analytics
|
||||
- cross-repo release orchestration
|
||||
- advanced approval hierarchies
|
||||
- fully automated release pipelines
|
||||
|
||||
V1 should be office workflow first.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Define QA review item and issue schema.
|
||||
2. Add local persisted QA department state.
|
||||
3. Build a simple QA queue panel.
|
||||
4. Add readiness signal.
|
||||
5. Connect task board / review-ready states to QA queue creation.
|
||||
6. Emit bulletin board blockers or announcements from QA outcomes.
|
||||
|
||||
## Existing Code Seams
|
||||
|
||||
This work should likely align with:
|
||||
|
||||
- task board state and transitions
|
||||
- approval/review UI surfaces
|
||||
- GitHub immersive review screens
|
||||
- office performance / approvals analytics
|
||||
- bulletin board and meeting room outputs from the new docs
|
||||
|
||||
The key is to avoid building QA as an isolated toy feature.
|
||||
|
||||
It should be another operational loop in the same office system.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
V1 is successful if:
|
||||
|
||||
- the office can visibly route work into QA
|
||||
- QA findings can block or clear work in a legible way
|
||||
- users can inspect review items and issues
|
||||
- readiness state is visible at the office level
|
||||
- QA outcomes can feed the bulletin board
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Once V1 is stable, follow-up work can add:
|
||||
|
||||
- QA meeting rituals
|
||||
- release room / release wall
|
||||
- specialized QA subteams
|
||||
- automated regression summaries
|
||||
- richer review analytics
|
||||
- policy-aware signoff chains
|
||||
|
||||
## Summary
|
||||
|
||||
The QA department should make verification a first-class part of office life.
|
||||
|
||||
It closes the loop between planning, execution, and trustworthy completion.
|
||||
@@ -0,0 +1,423 @@
|
||||
# Whiteboard Spec
|
||||
|
||||
> Second concrete office-system feature for Claw3D, designed to work alongside the bulletin board.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a whiteboard system inside the office for collaborative planning, meeting notes, and draft idea shaping.
|
||||
|
||||
The whiteboard is where the office thinks.
|
||||
|
||||
The bulletin board is where the office posts what matters.
|
||||
|
||||
## Product Position
|
||||
|
||||
The whiteboard should not duplicate the bulletin board.
|
||||
|
||||
Use the distinction:
|
||||
|
||||
- bulletin board = visible office signals
|
||||
- whiteboard = active drafting and planning surface
|
||||
|
||||
The whiteboard is best for:
|
||||
|
||||
- brainstorming
|
||||
- architecture outlines
|
||||
- meeting notes
|
||||
- draft task breakdowns
|
||||
- org planning
|
||||
- decision framing
|
||||
|
||||
It is not a Kanban replacement and not a polished document editor.
|
||||
|
||||
## Why This Feature Matters
|
||||
|
||||
The office already has:
|
||||
|
||||
- standup logic
|
||||
- meeting room space
|
||||
- whiteboard props in the retro office
|
||||
- task board and planning-adjacent systems
|
||||
|
||||
What is missing is a shared in-world planning surface.
|
||||
|
||||
The whiteboard creates that surface.
|
||||
|
||||
## Primary Use Cases
|
||||
|
||||
### Meeting Notes
|
||||
|
||||
Examples:
|
||||
|
||||
- standup talking points
|
||||
- decisions made during a meeting
|
||||
- action items
|
||||
- unresolved questions
|
||||
|
||||
### Brainstorming
|
||||
|
||||
Examples:
|
||||
|
||||
- possible approaches to a feature
|
||||
- tradeoff comparisons
|
||||
- rough implementation ideas
|
||||
- product concept sketches in text form
|
||||
|
||||
### Architecture Planning
|
||||
|
||||
Examples:
|
||||
|
||||
- component breakdown
|
||||
- adapter/provider mapping
|
||||
- system boundaries
|
||||
- workflow diagrams in structured text
|
||||
|
||||
### Org Planning
|
||||
|
||||
Examples:
|
||||
|
||||
- team structure drafts
|
||||
- role definitions
|
||||
- department responsibilities
|
||||
- handoff chains
|
||||
|
||||
### Session-to-Plan Bridge
|
||||
|
||||
Examples:
|
||||
|
||||
- summarize an agent conversation into a board section
|
||||
- turn standup outputs into grouped notes
|
||||
- capture a working draft before turning it into bulletin board items or tasks
|
||||
|
||||
## V1 Scope
|
||||
|
||||
V1 should be structured, not freehand.
|
||||
|
||||
That means:
|
||||
|
||||
- text blocks
|
||||
- sections
|
||||
- cards / note clusters
|
||||
- ordering
|
||||
- lightweight templates
|
||||
|
||||
Do not start with arbitrary drawing tools.
|
||||
|
||||
## Whiteboard Model
|
||||
|
||||
Suggested V1 shape:
|
||||
|
||||
```ts
|
||||
type WhiteboardBlockType =
|
||||
| "heading"
|
||||
| "note"
|
||||
| "decision"
|
||||
| "question"
|
||||
| "action"
|
||||
| "group";
|
||||
|
||||
type WhiteboardBlock = {
|
||||
id: string;
|
||||
type: WhiteboardBlockType;
|
||||
title?: string;
|
||||
body?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
authorType: "human" | "agent" | "system";
|
||||
authorId?: string | null;
|
||||
authorName?: string | null;
|
||||
linkedAgentId?: string | null;
|
||||
linkedSessionKey?: string | null;
|
||||
linkedTaskId?: string | null;
|
||||
color?: string | null;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
type WhiteboardDocument = {
|
||||
id: string;
|
||||
title: string;
|
||||
mode: "planning" | "meeting" | "architecture" | "org" | "freeform";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archived: boolean;
|
||||
blocks: WhiteboardBlock[];
|
||||
};
|
||||
```
|
||||
|
||||
## V1 Interaction Model
|
||||
|
||||
V1 interactions:
|
||||
|
||||
- create whiteboard
|
||||
- rename whiteboard
|
||||
- add/edit/delete blocks
|
||||
- reorder blocks
|
||||
- collapse/expand groups
|
||||
- link a block to an agent, task, or session
|
||||
- archive whiteboard
|
||||
- duplicate whiteboard
|
||||
|
||||
Optional but useful:
|
||||
|
||||
- convert a block into a bulletin-board card
|
||||
- convert a block into a task seed
|
||||
|
||||
## Templates
|
||||
|
||||
Templates are important because they make the feature useful immediately.
|
||||
|
||||
Recommended V1 templates:
|
||||
|
||||
- `Meeting Notes`
|
||||
- `Standup Review`
|
||||
- `Planning Session`
|
||||
- `Architecture Draft`
|
||||
- `Org Planning`
|
||||
|
||||
### Example: Meeting Notes Template
|
||||
|
||||
Sections:
|
||||
|
||||
- attendees
|
||||
- current topic
|
||||
- decisions
|
||||
- blockers
|
||||
- next actions
|
||||
|
||||
### Example: Planning Session Template
|
||||
|
||||
Sections:
|
||||
|
||||
- problem
|
||||
- options
|
||||
- risks
|
||||
- chosen direction
|
||||
- tasks
|
||||
|
||||
## Relationship To Existing Systems
|
||||
|
||||
The whiteboard should integrate with what Claw3D already has.
|
||||
|
||||
### Standup
|
||||
|
||||
The standup controller already exists.
|
||||
|
||||
The whiteboard should support:
|
||||
|
||||
- auto-creating a meeting notes board for an active standup
|
||||
- writing participant summaries to blocks
|
||||
- collecting blockers and next actions into dedicated sections
|
||||
|
||||
### Bulletin Board
|
||||
|
||||
The whiteboard should feed the bulletin board, not replace it.
|
||||
|
||||
Examples:
|
||||
|
||||
- convert a decision block into an announcement card
|
||||
- convert a blocker block into a blocker card
|
||||
- convert a next-action block into a handoff card
|
||||
|
||||
### Task Board / Kanban
|
||||
|
||||
The whiteboard is where a plan is shaped before it becomes a tracked workflow.
|
||||
|
||||
Examples:
|
||||
|
||||
- rough task breakdown on whiteboard
|
||||
- selected action blocks converted into actual task records
|
||||
- blocked tasks reflected back to the bulletin board
|
||||
|
||||
### Company Builder / Org Planning
|
||||
|
||||
The whiteboard is a natural fit for:
|
||||
|
||||
- team structure drafts
|
||||
- department planning
|
||||
- role relationship mapping
|
||||
|
||||
This is especially useful before company-builder output becomes actual agents.
|
||||
|
||||
## In-World UX
|
||||
|
||||
The whiteboard should exist as a real office surface.
|
||||
|
||||
Recommended forms:
|
||||
|
||||
- meeting-room whiteboard
|
||||
- wall-mounted planning board
|
||||
- design room / architecture board in future themes
|
||||
|
||||
Behavior:
|
||||
|
||||
- clicking the board opens an immersive planning surface
|
||||
- active meetings can auto-focus or highlight the whiteboard
|
||||
- whiteboard state should feel like part of the room, not a random modal
|
||||
|
||||
## Sidebar / Secondary Access
|
||||
|
||||
The user should also be able to open the whiteboard from a panel or shortcut.
|
||||
|
||||
Good options:
|
||||
|
||||
- HQ sidebar tab
|
||||
- meeting controls
|
||||
- standup panel
|
||||
|
||||
This is especially important when users want direct access without camera movement.
|
||||
|
||||
## Storage Model
|
||||
|
||||
Like the bulletin board, V1 should be persisted locally in office preferences.
|
||||
|
||||
Suggested shape:
|
||||
|
||||
```ts
|
||||
type OfficePreference = {
|
||||
whiteboards?: {
|
||||
documents: WhiteboardDocument[];
|
||||
activeDocumentId?: string | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Storage should be keyed by gateway URL / office context so each connected office can keep its own working state.
|
||||
|
||||
## JSON Canvas Compatibility
|
||||
|
||||
JSON Canvas is a good interoperability target for the whiteboard, but it should not define the product by itself.
|
||||
|
||||
Recommended stance:
|
||||
|
||||
- use Claw3D's own whiteboard model as the primary domain model
|
||||
- support export/import to JSON Canvas as a compatibility layer
|
||||
- avoid turning the whiteboard into a generic infinite-canvas editor before the office workflow is proven
|
||||
|
||||
Why:
|
||||
|
||||
- Claw3D needs stronger links to meetings, bulletin board items, tasks, agents, and sessions
|
||||
- the whiteboard is a workflow surface, not only a canvas
|
||||
- structured planning is more important than unconstrained canvas freedom in V1
|
||||
|
||||
Good use of JSON Canvas:
|
||||
|
||||
- export planning boards
|
||||
- import external draft canvases
|
||||
- map blocks/groups into JSON Canvas nodes
|
||||
- preserve links where practical
|
||||
|
||||
Bad use of JSON Canvas:
|
||||
|
||||
- letting a generic canvas model dictate the first product UX
|
||||
- replacing office-native planning behavior with a broad but shallow editor
|
||||
|
||||
## Authoring Rules
|
||||
|
||||
Allowed authors:
|
||||
|
||||
- human
|
||||
- agent
|
||||
- system
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- human edits are fully editable
|
||||
- system-generated sections should remain editable but visibly marked
|
||||
- agent-authored blocks should show provenance
|
||||
|
||||
That balance keeps the board useful without feeling rigid.
|
||||
|
||||
## V1 Automation
|
||||
|
||||
Useful automations:
|
||||
|
||||
- create a whiteboard automatically when a standup meeting starts
|
||||
- seed a whiteboard from a planning command or meeting ritual
|
||||
- let an agent summarize a session into selected board blocks
|
||||
|
||||
Important:
|
||||
|
||||
- automation should create structure, not spam content
|
||||
- the user should remain able to edit the board freely
|
||||
|
||||
## Visual Structure
|
||||
|
||||
V1 should look like a structured planning board, not a blank canvas.
|
||||
|
||||
Possible presentation:
|
||||
|
||||
- left column for sections
|
||||
- center canvas for block editing
|
||||
- right rail for linked agents/sessions/tasks
|
||||
|
||||
Or:
|
||||
|
||||
- grouped lanes by section with text cards inside them
|
||||
|
||||
The design should prioritize clarity over novelty.
|
||||
|
||||
## Out of Scope For V1
|
||||
|
||||
Do not include these initially:
|
||||
|
||||
- freehand drawing tools
|
||||
- multiplayer cursor presence
|
||||
- arbitrary shapes/connectors
|
||||
- full diagramming toolkit
|
||||
- external document sync
|
||||
- rich media embedding
|
||||
- advanced permissions by department
|
||||
|
||||
Those can come later if the structured board proves valuable.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Define whiteboard document and block schema.
|
||||
2. Add office preference persistence.
|
||||
3. Build a simple whiteboard panel UI with templates.
|
||||
4. Connect the in-world whiteboard object to open the panel.
|
||||
5. Add standup seeding / meeting integration.
|
||||
6. Add conversions into bulletin-board cards and task seeds.
|
||||
|
||||
## Existing Code Seams
|
||||
|
||||
This feature should align with:
|
||||
|
||||
- standup systems in `src/features/office/hooks/useOfficeStandupController.ts`
|
||||
- standup API routes under `src/app/api/office/standup`
|
||||
- retro office whiteboard objects and room interactions
|
||||
- office settings persistence
|
||||
- task board seeding concepts already present in office task flows
|
||||
|
||||
This reduces implementation risk and keeps the feature tied to real office mechanics.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
V1 is successful if:
|
||||
|
||||
- the user can open a whiteboard from inside the office
|
||||
- a meeting or planning session can write structured notes to it
|
||||
- the board can link to agents, sessions, and tasks
|
||||
- users can turn whiteboard outputs into bulletin board items or task seeds
|
||||
- the system works independently of OpenClaw-specific behavior
|
||||
|
||||
## Future Extensions
|
||||
|
||||
Once V1 is working, the whiteboard can evolve into:
|
||||
|
||||
- diagram mode
|
||||
- relationship mapping
|
||||
- architecture views
|
||||
- agent collaboration sessions
|
||||
- department-specific whiteboards
|
||||
- persistent planning archives
|
||||
- richer visual theming by office skin
|
||||
|
||||
## Summary
|
||||
|
||||
The whiteboard should become Claw3D’s active planning surface.
|
||||
|
||||
It is where meetings, drafts, and rough plans take shape before they become tasks, bulletin items, or office decisions.
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'claw3d',
|
||||
script: 'npm',
|
||||
args: 'run dev',
|
||||
cwd: '/root/.openclaw/workspace/Claw3D',
|
||||
env: {
|
||||
PORT: 4001,
|
||||
STUDIO_ACCESS_TOKEN: '',
|
||||
REQUIRE_STUDIO_TOKEN: 'false'
|
||||
},
|
||||
watch: false,
|
||||
autorestart: true
|
||||
}]
|
||||
};
|
||||
Binary file not shown.
+58
-1
@@ -1,5 +1,62 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"img-src 'self' data: blob: http: https:",
|
||||
"font-src 'self' data: https:",
|
||||
"style-src 'self' 'unsafe-inline' https:",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
|
||||
"connect-src 'self' ws: wss: http: https:",
|
||||
"media-src 'self' blob: data: http: https:",
|
||||
"worker-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
"upgrade-insecure-requests",
|
||||
].join("; "),
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(self), geolocation=(), browsing-topics=()",
|
||||
},
|
||||
{
|
||||
key: "Cross-Origin-Resource-Policy",
|
||||
value: "same-origin",
|
||||
},
|
||||
];
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
securityHeaders.push({
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=31536000; includeSubDomains",
|
||||
});
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+12
-218
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"name": "claw3d",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claw3d",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@vercel/otel": "^2.1.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -1956,143 +1955,6 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.211.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz",
|
||||
"integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation": {
|
||||
"version": "0.211.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz",
|
||||
"integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.211.0",
|
||||
"import-in-the-middle": "^2.0.0",
|
||||
"require-in-the-middle": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-logs": {
|
||||
"version": "0.211.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.211.0.tgz",
|
||||
"integrity": "sha512-O5nPwzgg2JHzo59kpQTPUOTzFi0Nv5LxryG27QoXBciX3zWM3z83g+SNOHhiQVYRWFSxoWn1JM2TGD5iNjOwdA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.211.0",
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.4.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-metrics": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz",
|
||||
"integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz",
|
||||
@@ -3541,9 +3403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3914,24 +3776,6 @@
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/otel": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/otel/-/otel-2.1.0.tgz",
|
||||
"integrity": "sha512-Zwu2Cu4t46DzBnY1DQSTxZ4MBLVfYsOjnlWuZuLRWnmVPX+SNrVHbs3ssiJ6uvY1J1JJswor4zSn8mHYxzYeBA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.9.0 <2.0.0",
|
||||
"@opentelemetry/api-logs": ">=0.200.0 <0.300.0",
|
||||
"@opentelemetry/instrumentation": ">=0.200.0 <0.300.0",
|
||||
"@opentelemetry/resources": ">=2.0.0 <3.0.0",
|
||||
"@opentelemetry/sdk-logs": ">=0.200.0 <0.300.0",
|
||||
"@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0",
|
||||
"@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
@@ -4053,6 +3897,7 @@
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -4061,16 +3906,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-import-attributes": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
|
||||
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"acorn": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-jsx": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
|
||||
@@ -4092,9 +3927,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4449,9 +4284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4720,13 +4555,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/cjs-module-lexer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
|
||||
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -6525,19 +6353,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/import-in-the-middle": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.5.tgz",
|
||||
"integrity": "sha512-0InH9/4oDCBRzWXhpOqusspLBrVfK1vPvbn9Wxl8DAQ8yyx5fWJRETICSwkiAMaYntjJAMBP1R4B6cQnEUYVEA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-attributes": "^1.9.5",
|
||||
"cjs-module-lexer": "^2.2.0",
|
||||
"module-details-from-path": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -8593,13 +8408,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/module-details-from-path": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -9514,20 +9322,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-in-the-middle": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
|
||||
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.3.5",
|
||||
"module-details-from-path": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
|
||||
+3
-2
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "claw3d",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "node server/index.js --dev",
|
||||
"dev:https": "node server/index.js --dev --https",
|
||||
"hermes-adapter": "node server/hermes-gateway-adapter.js",
|
||||
"demo-gateway": "node server/demo-gateway-adapter.js",
|
||||
"build": "next build",
|
||||
"start": "node server/index.js",
|
||||
"lint": "eslint .",
|
||||
@@ -21,7 +23,6 @@
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@vercel/otel": "^2.1.0",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3100",
|
||||
},
|
||||
webServer: {
|
||||
command: "PORT=3100 npm run dev",
|
||||
port: 3100,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: path.resolve("./tests/fixtures/openclaw-empty-state"),
|
||||
NEXT_PUBLIC_GATEWAY_URL: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bash
|
||||
# clawd3d-start — Start all Clawd3D services, auto-resolving port conflicts.
|
||||
#
|
||||
# Setup (once):
|
||||
# echo 'alias clawd3d="/absolute/path/to/Claw3D/scripts/clawd3d-start.sh"' >> ~/.zshrc
|
||||
# source ~/.zshrc
|
||||
#
|
||||
# Then just run: clawd3d
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
CLAWD3D_DIR="$(cd -- "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)"
|
||||
LOG_DIR="/tmp/clawd3d-logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
log() { echo -e "${GREEN}[clawd3d]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[clawd3d]${NC} $*"; }
|
||||
info() { echo -e "${BLUE}[clawd3d]${NC} $*"; }
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Returns PIDs *listening* on a port (excludes client connections), or empty.
|
||||
pids_on_port() { lsof -ti:"$1" -sTCP:LISTEN 2>/dev/null || true; }
|
||||
|
||||
# True if the port is free.
|
||||
port_free() { [ -z "$(pids_on_port "$1")" ]; }
|
||||
|
||||
# Find first free port >= $1.
|
||||
find_free_port() {
|
||||
local p=$1
|
||||
while ! port_free "$p"; do p=$((p + 1)); done
|
||||
echo "$p"
|
||||
}
|
||||
|
||||
# True if every PID on $port matches the grep pattern in its command line.
|
||||
port_owned_by() {
|
||||
local port=$1 pattern=$2
|
||||
local pids
|
||||
pids=$(pids_on_port "$port")
|
||||
[ -z "$pids" ] && return 1
|
||||
while IFS= read -r pid; do
|
||||
local cmd
|
||||
cmd=$(ps -p "$pid" -o command= 2>/dev/null || true)
|
||||
if ! echo "$cmd" | grep -qE "$pattern"; then
|
||||
return 1
|
||||
fi
|
||||
done <<< "$pids"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── 1. Hermes gateway (API, default 8642) ────────────────────────────────────
|
||||
HERMES_PORT=8642
|
||||
if ! port_free $HERMES_PORT; then
|
||||
if port_owned_by $HERMES_PORT "hermes"; then
|
||||
warn "Hermes gateway already running on :$HERMES_PORT — reusing."
|
||||
else
|
||||
HERMES_PORT=$(find_free_port $((HERMES_PORT + 1)))
|
||||
warn "Port 8642 taken by another process → using :$HERMES_PORT for Hermes."
|
||||
log "Starting Hermes gateway on :$HERMES_PORT..."
|
||||
nohup env API_SERVER_PORT="$HERMES_PORT" hermes gateway run \
|
||||
> "$LOG_DIR/hermes-gateway.log" 2>&1 &
|
||||
sleep 2
|
||||
fi
|
||||
else
|
||||
log "Starting Hermes gateway on :$HERMES_PORT..."
|
||||
nohup hermes gateway run > "$LOG_DIR/hermes-gateway.log" 2>&1 &
|
||||
sleep 2
|
||||
fi
|
||||
HERMES_API_URL="http://localhost:$HERMES_PORT"
|
||||
|
||||
# ── 2. Hermes adapter (WebSocket bridge, default 18789) ──────────────────────
|
||||
ADAPTER_PORT=18789
|
||||
if ! port_free $ADAPTER_PORT; then
|
||||
if port_owned_by $ADAPTER_PORT "node.*hermes-gateway-adapter"; then
|
||||
warn "Hermes adapter already running on :$ADAPTER_PORT — reusing."
|
||||
else
|
||||
ADAPTER_PORT=$(find_free_port $((ADAPTER_PORT + 1)))
|
||||
warn "Port 18789 taken by another process → using :$ADAPTER_PORT for adapter."
|
||||
log "Starting Hermes adapter on :$ADAPTER_PORT..."
|
||||
cd "$CLAWD3D_DIR"
|
||||
nohup env HERMES_ADAPTER_PORT="$ADAPTER_PORT" HERMES_API_URL="$HERMES_API_URL" \
|
||||
npm run hermes-adapter > "$LOG_DIR/hermes-adapter.log" 2>&1 &
|
||||
sleep 1
|
||||
fi
|
||||
else
|
||||
log "Starting Hermes adapter on :$ADAPTER_PORT..."
|
||||
cd "$CLAWD3D_DIR"
|
||||
nohup env HERMES_ADAPTER_PORT="$ADAPTER_PORT" HERMES_API_URL="$HERMES_API_URL" \
|
||||
npm run hermes-adapter > "$LOG_DIR/hermes-adapter.log" 2>&1 &
|
||||
sleep 1
|
||||
fi
|
||||
GATEWAY_WS_URL="ws://localhost:$ADAPTER_PORT"
|
||||
|
||||
# ── 3. Next.js dev server (default 3000) ─────────────────────────────────────
|
||||
APP_PORT=3000
|
||||
if ! port_free $APP_PORT; then
|
||||
if port_owned_by $APP_PORT "node.*next|next-server|server/index\.js"; then
|
||||
warn "Clawd3D dev server already running on :$APP_PORT — reusing."
|
||||
else
|
||||
APP_PORT=$(find_free_port $((APP_PORT + 1)))
|
||||
warn "Port 3000 taken by another process → using :$APP_PORT for Clawd3D."
|
||||
log "Starting Clawd3D dev server on :$APP_PORT..."
|
||||
cd "$CLAWD3D_DIR"
|
||||
nohup env PORT="$APP_PORT" NEXT_PUBLIC_GATEWAY_URL="$GATEWAY_WS_URL" \
|
||||
npm run dev > "$LOG_DIR/clawd3d-dev.log" 2>&1 &
|
||||
fi
|
||||
else
|
||||
log "Starting Clawd3D dev server on :$APP_PORT..."
|
||||
cd "$CLAWD3D_DIR"
|
||||
nohup env PORT="$APP_PORT" NEXT_PUBLIC_GATEWAY_URL="$GATEWAY_WS_URL" \
|
||||
npm run dev > "$LOG_DIR/clawd3d-dev.log" 2>&1 &
|
||||
fi
|
||||
|
||||
# ── 4. Wait until the app responds ───────────────────────────────────────────
|
||||
log "Waiting for Clawd3D to be ready at :$APP_PORT..."
|
||||
ready=0
|
||||
for i in $(seq 1 90); do
|
||||
if curl -sf "http://localhost:$APP_PORT" > /dev/null 2>&1; then
|
||||
ready=1; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ready" -eq 0 ]; then
|
||||
warn "Timed out waiting for :$APP_PORT — check $LOG_DIR/clawd3d-dev.log"
|
||||
fi
|
||||
|
||||
# ── 5. Open browser ───────────────────────────────────────────────────────────
|
||||
open "http://localhost:$APP_PORT"
|
||||
|
||||
echo ""
|
||||
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
log " Clawd3D → http://localhost:$APP_PORT"
|
||||
info " Gateway WS → $GATEWAY_WS_URL"
|
||||
info " Hermes API → $HERMES_API_URL"
|
||||
info " Logs → $LOG_DIR/"
|
||||
log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
+76
-10
@@ -1,3 +1,6 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
|
||||
const parseCookies = (header) => {
|
||||
const raw = typeof header === "string" ? header : "";
|
||||
if (!raw.trim()) return {};
|
||||
@@ -13,34 +16,98 @@ const parseCookies = (header) => {
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Constant-time string comparison to prevent timing attacks. */
|
||||
const safeCompare = (a, b) => {
|
||||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||||
const bufA = Buffer.from(a, "utf8");
|
||||
const bufB = Buffer.from(b, "utf8");
|
||||
if (bufA.length !== bufB.length) {
|
||||
// Compare against self to burn constant time, then return false
|
||||
crypto.timingSafeEqual(bufA, bufA);
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
};
|
||||
|
||||
/** Simple in-memory rate limiter for auth attempts. */
|
||||
const createRateLimiter = (maxAttempts = 10, windowMs = 60_000) => {
|
||||
const attempts = new Map();
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of attempts) {
|
||||
if (now - entry.start > windowMs) attempts.delete(key);
|
||||
}
|
||||
}, windowMs);
|
||||
cleanup.unref();
|
||||
|
||||
return {
|
||||
isLimited(ip) {
|
||||
const entry = attempts.get(ip);
|
||||
if (!entry) return false;
|
||||
return entry.count >= maxAttempts;
|
||||
},
|
||||
recordFailure(ip) {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(ip);
|
||||
if (!entry || now - entry.start > windowMs) {
|
||||
attempts.set(ip, { count: 1, start: now });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
},
|
||||
reset(ip) {
|
||||
attempts.delete(ip);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function createAccessGate(options) {
|
||||
const token = String(options?.token ?? "").trim();
|
||||
const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access";
|
||||
|
||||
const enabled = Boolean(token);
|
||||
const rateLimiter = createRateLimiter(10, 60_000);
|
||||
|
||||
const isAuthorized = (req) => {
|
||||
if (!enabled) return true;
|
||||
const getAuthState = (req) => {
|
||||
if (!enabled) return { authorized: true, limited: false };
|
||||
const ip = req.socket?.remoteAddress || "unknown";
|
||||
const cookieHeader = req.headers?.cookie;
|
||||
const cookies = parseCookies(cookieHeader);
|
||||
return cookies[cookieName] === token;
|
||||
const authorized = safeCompare(cookies[cookieName] || "", token);
|
||||
if (authorized) {
|
||||
rateLimiter.reset(ip);
|
||||
return { authorized: true, limited: false };
|
||||
}
|
||||
if (rateLimiter.isLimited(ip)) {
|
||||
return { authorized: false, limited: true };
|
||||
}
|
||||
rateLimiter.recordFailure(ip);
|
||||
return { authorized: false, limited: rateLimiter.isLimited(ip) };
|
||||
};
|
||||
|
||||
const handleHttp = (req, res) => {
|
||||
if (!enabled) return false;
|
||||
if (!isAuthorized(req)) {
|
||||
const auth = getAuthState(req);
|
||||
if (!auth.authorized) {
|
||||
const statusCode = auth.limited ? 429 : 401;
|
||||
if (String(req.url || "/").startsWith("/api/")) {
|
||||
res.statusCode = 401;
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: "Studio access token required. Send the configured Studio access cookie and retry.",
|
||||
error: auth.limited
|
||||
? "Too many failed studio access attempts. Wait a minute and retry."
|
||||
: "Studio access token required. Send the configured Studio access cookie and retry.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.statusCode = 401;
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Studio access token required. Set the studio_access cookie to access this page.");
|
||||
res.end(
|
||||
auth.limited
|
||||
? "Too many failed studio access attempts. Wait a minute and retry."
|
||||
: "Studio access token required. Set the studio_access cookie to access this page."
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -49,11 +116,10 @@ function createAccessGate(options) {
|
||||
|
||||
const allowUpgrade = (req) => {
|
||||
if (!enabled) return true;
|
||||
return isAuthorized(req);
|
||||
return getAuthState(req).authorized;
|
||||
};
|
||||
|
||||
return { enabled, handleHttp, allowUpgrade };
|
||||
}
|
||||
|
||||
module.exports = { createAccessGate };
|
||||
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"use strict";
|
||||
|
||||
const http = require("http");
|
||||
const { randomUUID } = require("crypto");
|
||||
const { WebSocketServer } = require("ws");
|
||||
|
||||
const ADAPTER_PORT = parseInt(process.env.DEMO_ADAPTER_PORT || "18789", 10);
|
||||
const MAIN_KEY = "main";
|
||||
const MODELS = [{ id: "demo/mock-office", name: "Mock Office", provider: "demo" }];
|
||||
|
||||
const agents = new Map([
|
||||
[
|
||||
"demo-orchestrator",
|
||||
{
|
||||
id: "demo-orchestrator",
|
||||
name: "Avery",
|
||||
role: "Orchestrator",
|
||||
workspace: "/demo/orchestrator",
|
||||
},
|
||||
],
|
||||
[
|
||||
"demo-researcher",
|
||||
{
|
||||
id: "demo-researcher",
|
||||
name: "Mika",
|
||||
role: "Research",
|
||||
workspace: "/demo/research",
|
||||
},
|
||||
],
|
||||
[
|
||||
"demo-builder",
|
||||
{
|
||||
id: "demo-builder",
|
||||
name: "Rune",
|
||||
role: "Builder",
|
||||
workspace: "/demo/builder",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const files = new Map();
|
||||
const sessionSettings = new Map();
|
||||
const conversationHistory = new Map();
|
||||
const activeRuns = new Map();
|
||||
const activeSendEventFns = new Set();
|
||||
|
||||
function randomId() {
|
||||
return randomUUID().replace(/-/g, "");
|
||||
}
|
||||
|
||||
function sessionKeyFor(agentId) {
|
||||
return `agent:${agentId}:${MAIN_KEY}`;
|
||||
}
|
||||
|
||||
function getHistory(sessionKey) {
|
||||
if (!conversationHistory.has(sessionKey)) {
|
||||
conversationHistory.set(sessionKey, []);
|
||||
}
|
||||
return conversationHistory.get(sessionKey);
|
||||
}
|
||||
|
||||
function clearHistory(sessionKey) {
|
||||
conversationHistory.delete(sessionKey);
|
||||
}
|
||||
|
||||
function resOk(id, payload) {
|
||||
return { type: "res", id, ok: true, payload: payload ?? {} };
|
||||
}
|
||||
|
||||
function resErr(id, code, message) {
|
||||
return { type: "res", id, ok: false, error: { code, message } };
|
||||
}
|
||||
|
||||
function broadcastEvent(frame) {
|
||||
for (const send of activeSendEventFns) {
|
||||
try {
|
||||
send(frame);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function agentListPayload() {
|
||||
return [...agents.values()].map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
workspace: agent.workspace,
|
||||
identity: { name: agent.name, emoji: "🤖" },
|
||||
role: agent.role,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDemoReply(agent, message) {
|
||||
const normalized = message.trim();
|
||||
const opening =
|
||||
agent.role === "Orchestrator"
|
||||
? `${agent.name} here. Demo office is live and the team is synced.`
|
||||
: `${agent.name} reporting in from the ${agent.role.toLowerCase()} desk.`;
|
||||
const action =
|
||||
agent.role === "Research"
|
||||
? "I would break this down into sources, constraints, and next questions."
|
||||
: agent.role === "Builder"
|
||||
? "I would turn that into concrete implementation steps and validation."
|
||||
: "I can coordinate the team, route work, and summarize progress.";
|
||||
return `${opening} You said: "${normalized}". ${action}`;
|
||||
}
|
||||
|
||||
async function handleMethod(method, params, id, sendEvent) {
|
||||
const p = params || {};
|
||||
|
||||
switch (method) {
|
||||
case "agents.list":
|
||||
return resOk(id, { defaultId: "demo-orchestrator", mainKey: MAIN_KEY, agents: agentListPayload() });
|
||||
|
||||
case "agents.create": {
|
||||
const name = typeof p.name === "string" && p.name.trim() ? p.name.trim() : "Demo Agent";
|
||||
const role = typeof p.role === "string" ? p.role.trim() : "";
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "demo-agent";
|
||||
const agentId = `${slug}-${randomId().slice(0, 6)}`;
|
||||
agents.set(agentId, {
|
||||
id: agentId,
|
||||
name,
|
||||
role,
|
||||
workspace: `/demo/${slug}`,
|
||||
});
|
||||
broadcastEvent({
|
||||
type: "event",
|
||||
event: "presence",
|
||||
payload: { sessions: { recent: [], byAgent: [] } },
|
||||
});
|
||||
return resOk(id, { agentId, name, workspace: `/demo/${slug}` });
|
||||
}
|
||||
|
||||
case "agents.update": {
|
||||
const agentId = typeof p.agentId === "string" ? p.agentId.trim() : "";
|
||||
const agent = agents.get(agentId);
|
||||
if (!agent) return resErr(id, "not_found", `Agent ${agentId} not found`);
|
||||
if (typeof p.name === "string" && p.name.trim()) agent.name = p.name.trim();
|
||||
if (typeof p.role === "string") agent.role = p.role.trim();
|
||||
return resOk(id, { ok: true, removedBindings: 0 });
|
||||
}
|
||||
|
||||
case "agents.delete": {
|
||||
const agentId = typeof p.agentId === "string" ? p.agentId.trim() : "";
|
||||
if (agentId && agents.has(agentId) && agentId !== "demo-orchestrator") {
|
||||
agents.delete(agentId);
|
||||
clearHistory(sessionKeyFor(agentId));
|
||||
}
|
||||
return resOk(id, { ok: true, removedBindings: 0 });
|
||||
}
|
||||
|
||||
case "agents.files.get": {
|
||||
const key = `${p.agentId || "demo-orchestrator"}/${p.name || ""}`;
|
||||
const content = files.get(key);
|
||||
return resOk(id, { file: content !== undefined ? { content } : { missing: true } });
|
||||
}
|
||||
|
||||
case "agents.files.set": {
|
||||
const key = `${p.agentId || "demo-orchestrator"}/${p.name || ""}`;
|
||||
files.set(key, typeof p.content === "string" ? p.content : "");
|
||||
return resOk(id, {});
|
||||
}
|
||||
|
||||
case "config.get":
|
||||
return resOk(id, {
|
||||
config: { gateway: { reload: { mode: "hot" } } },
|
||||
hash: "demo-gateway",
|
||||
exists: true,
|
||||
path: "/demo/config.json",
|
||||
});
|
||||
|
||||
case "config.patch":
|
||||
case "config.set":
|
||||
return resOk(id, { hash: "demo-gateway" });
|
||||
|
||||
case "exec.approvals.get":
|
||||
return resOk(id, {
|
||||
path: "",
|
||||
exists: true,
|
||||
hash: "demo-approvals",
|
||||
file: { version: 1, defaults: { security: "full", ask: "off", autoAllowSkills: true }, agents: {} },
|
||||
});
|
||||
|
||||
case "exec.approvals.set":
|
||||
return resOk(id, { hash: "demo-approvals" });
|
||||
|
||||
case "exec.approval.resolve":
|
||||
return resOk(id, { ok: true });
|
||||
|
||||
case "models.list":
|
||||
return resOk(id, { models: MODELS });
|
||||
|
||||
case "skills.status":
|
||||
return resOk(id, { skills: [] });
|
||||
|
||||
case "cron.list":
|
||||
return resOk(id, { jobs: [] });
|
||||
|
||||
case "cron.add":
|
||||
case "cron.run":
|
||||
case "cron.remove":
|
||||
return resErr(id, "unsupported_method", `Demo runtime does not support ${method}.`);
|
||||
|
||||
case "sessions.list": {
|
||||
const sessions = [...agents.values()].map((agent) => {
|
||||
const sessionKey = sessionKeyFor(agent.id);
|
||||
const history = getHistory(sessionKey);
|
||||
const settings = sessionSettings.get(sessionKey) || {};
|
||||
return {
|
||||
key: sessionKey,
|
||||
agentId: agent.id,
|
||||
updatedAt: history.length > 0 ? Date.now() : null,
|
||||
displayName: "Main",
|
||||
origin: { label: agent.name, provider: "demo" },
|
||||
model: settings.model || MODELS[0].id,
|
||||
modelProvider: "demo",
|
||||
};
|
||||
});
|
||||
return resOk(id, { sessions });
|
||||
}
|
||||
|
||||
case "sessions.preview": {
|
||||
const keys = Array.isArray(p.keys) ? p.keys : [];
|
||||
const limit = typeof p.limit === "number" ? p.limit : 8;
|
||||
const maxChars = typeof p.maxChars === "number" ? p.maxChars : 240;
|
||||
const previews = keys.map((key) => {
|
||||
const history = getHistory(key);
|
||||
if (history.length === 0) return { key, status: "empty", items: [] };
|
||||
const items = history.slice(-limit).map((msg) => ({
|
||||
role: msg.role === "assistant" ? "assistant" : "user",
|
||||
text: String(msg.content || "").slice(0, maxChars),
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
return { key, status: "ok", items };
|
||||
});
|
||||
return resOk(id, { ts: Date.now(), previews });
|
||||
}
|
||||
|
||||
case "sessions.patch": {
|
||||
const key = typeof p.key === "string" ? p.key : sessionKeyFor("demo-orchestrator");
|
||||
const current = sessionSettings.get(key) || {};
|
||||
const next = { ...current };
|
||||
if (p.model !== undefined) next.model = p.model;
|
||||
if (p.thinkingLevel !== undefined) next.thinkingLevel = p.thinkingLevel;
|
||||
sessionSettings.set(key, next);
|
||||
return resOk(id, {
|
||||
ok: true,
|
||||
key,
|
||||
entry: { thinkingLevel: next.thinkingLevel },
|
||||
resolved: { model: next.model || MODELS[0].id, modelProvider: "demo" },
|
||||
});
|
||||
}
|
||||
|
||||
case "sessions.reset": {
|
||||
const key = typeof p.key === "string" ? p.key : sessionKeyFor("demo-orchestrator");
|
||||
clearHistory(key);
|
||||
return resOk(id, { ok: true });
|
||||
}
|
||||
|
||||
case "chat.send": {
|
||||
const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey : sessionKeyFor("demo-orchestrator");
|
||||
const agentId = sessionKey.startsWith("agent:") ? sessionKey.split(":")[1] : "demo-orchestrator";
|
||||
const agent = agents.get(agentId) || agents.get("demo-orchestrator");
|
||||
const message = typeof p.message === "string" ? p.message.trim() : String(p.message || "").trim();
|
||||
const runId = typeof p.idempotencyKey === "string" && p.idempotencyKey ? p.idempotencyKey : randomId();
|
||||
if (!message) return resOk(id, { status: "no-op", runId });
|
||||
|
||||
const reply = buildDemoReply(agent, message);
|
||||
let aborted = false;
|
||||
activeRuns.set(runId, {
|
||||
runId,
|
||||
sessionKey,
|
||||
agentId,
|
||||
abort() {
|
||||
aborted = true;
|
||||
},
|
||||
});
|
||||
|
||||
setImmediate(async () => {
|
||||
let seq = 0;
|
||||
const emitChat = (state, extra) => {
|
||||
sendEvent({
|
||||
type: "event",
|
||||
event: "chat",
|
||||
seq: seq++,
|
||||
payload: { runId, sessionKey, state, ...extra },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const words = reply.split(" ");
|
||||
let partial = "";
|
||||
for (const word of words) {
|
||||
if (aborted) break;
|
||||
partial = partial ? `${partial} ${word}` : word;
|
||||
emitChat("delta", { message: { role: "assistant", content: partial } });
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
emitChat("aborted", {});
|
||||
return;
|
||||
}
|
||||
|
||||
const history = getHistory(sessionKey);
|
||||
history.push({ role: "user", content: message });
|
||||
history.push({ role: "assistant", content: reply });
|
||||
emitChat("final", { stopReason: "end_turn", message: { role: "assistant", content: reply } });
|
||||
sendEvent({
|
||||
type: "event",
|
||||
event: "presence",
|
||||
seq: seq++,
|
||||
payload: {
|
||||
sessions: {
|
||||
recent: [{ key: sessionKey, updatedAt: Date.now() }],
|
||||
byAgent: [{ agentId, recent: [{ key: sessionKey, updatedAt: Date.now() }] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
activeRuns.delete(runId);
|
||||
}
|
||||
});
|
||||
|
||||
return resOk(id, { status: "started", runId });
|
||||
}
|
||||
|
||||
case "chat.abort": {
|
||||
const runId = typeof p.runId === "string" ? p.runId.trim() : "";
|
||||
const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey.trim() : "";
|
||||
let aborted = 0;
|
||||
if (runId) {
|
||||
const handle = activeRuns.get(runId);
|
||||
if (handle) {
|
||||
handle.abort();
|
||||
activeRuns.delete(runId);
|
||||
aborted += 1;
|
||||
}
|
||||
} else if (sessionKey) {
|
||||
for (const [activeRunId, handle] of activeRuns.entries()) {
|
||||
if (handle.sessionKey !== sessionKey) continue;
|
||||
handle.abort();
|
||||
activeRuns.delete(activeRunId);
|
||||
aborted += 1;
|
||||
}
|
||||
}
|
||||
return resOk(id, { ok: true, aborted });
|
||||
}
|
||||
|
||||
case "chat.history": {
|
||||
const sessionKey = typeof p.sessionKey === "string" ? p.sessionKey : sessionKeyFor("demo-orchestrator");
|
||||
return resOk(id, { sessionKey, messages: getHistory(sessionKey) });
|
||||
}
|
||||
|
||||
case "agent.wait": {
|
||||
const runId = typeof p.runId === "string" ? p.runId : "";
|
||||
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 30000;
|
||||
const start = Date.now();
|
||||
while (activeRuns.has(runId) && Date.now() - start < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return resOk(id, { status: activeRuns.has(runId) ? "running" : "done" });
|
||||
}
|
||||
|
||||
case "status": {
|
||||
const recent = [...agents.keys()].flatMap((agentId) => {
|
||||
const key = sessionKeyFor(agentId);
|
||||
const history = getHistory(key);
|
||||
return history.length > 0 ? [{ key, updatedAt: Date.now() }] : [];
|
||||
});
|
||||
return resOk(id, {
|
||||
sessions: {
|
||||
recent,
|
||||
byAgent: [...agents.keys()].map((agentId) => ({
|
||||
agentId,
|
||||
recent: recent.filter((entry) => entry.key.includes(`:${agentId}:`)),
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
case "wake":
|
||||
return resOk(id, { ok: true });
|
||||
|
||||
default:
|
||||
return resOk(id, {});
|
||||
}
|
||||
}
|
||||
|
||||
function startAdapter() {
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("Claw3D Demo Gateway Adapter\n");
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
wss.on("connection", (ws) => {
|
||||
let connected = false;
|
||||
let globalSeq = 0;
|
||||
|
||||
const send = (frame) => {
|
||||
if (ws.readyState !== ws.OPEN) return;
|
||||
ws.send(JSON.stringify(frame));
|
||||
};
|
||||
|
||||
const sendEventFn = (frame) => {
|
||||
if (frame.type === "event" && typeof frame.seq !== "number") {
|
||||
frame.seq = globalSeq++;
|
||||
}
|
||||
send(frame);
|
||||
};
|
||||
|
||||
activeSendEventFns.add(sendEventFn);
|
||||
send({ type: "event", event: "connect.challenge", payload: { nonce: randomId() } });
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(raw.toString("utf8"));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!frame || typeof frame !== "object" || frame.type !== "req") return;
|
||||
const { id, method, params } = frame;
|
||||
if (typeof id !== "string" || typeof method !== "string") return;
|
||||
|
||||
if (method === "connect") {
|
||||
connected = true;
|
||||
send({
|
||||
type: "res",
|
||||
id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
protocol: 3,
|
||||
adapterType: "demo",
|
||||
features: {
|
||||
methods: [
|
||||
"agents.list",
|
||||
"agents.create",
|
||||
"agents.delete",
|
||||
"agents.update",
|
||||
"sessions.list",
|
||||
"sessions.preview",
|
||||
"sessions.patch",
|
||||
"sessions.reset",
|
||||
"chat.send",
|
||||
"chat.abort",
|
||||
"chat.history",
|
||||
"agent.wait",
|
||||
"status",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"config.patch",
|
||||
"agents.files.get",
|
||||
"agents.files.set",
|
||||
"exec.approvals.get",
|
||||
"exec.approvals.set",
|
||||
"exec.approval.resolve",
|
||||
"wake",
|
||||
"skills.status",
|
||||
"models.list",
|
||||
"cron.list",
|
||||
],
|
||||
events: ["chat", "presence", "heartbeat"],
|
||||
},
|
||||
snapshot: {
|
||||
health: {
|
||||
agents: [...agents.values()].map((agent) => ({
|
||||
agentId: agent.id,
|
||||
name: agent.name,
|
||||
isDefault: agent.id === "demo-orchestrator",
|
||||
})),
|
||||
defaultAgentId: "demo-orchestrator",
|
||||
},
|
||||
sessionDefaults: { mainKey: MAIN_KEY },
|
||||
},
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
policy: { tickIntervalMs: 30000 },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
send(resErr(id, "not_connected", "Send connect first."));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
send(await handleMethod(method, params, id, sendEventFn));
|
||||
} catch (error) {
|
||||
send(resErr(id, "internal_error", error instanceof Error ? error.message : "Internal error"));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => activeSendEventFns.delete(sendEventFn));
|
||||
ws.on("error", () => activeSendEventFns.delete(sendEventFn));
|
||||
});
|
||||
|
||||
httpServer.listen(ADAPTER_PORT, "127.0.0.1", () => {
|
||||
console.log(`[demo-gateway] Listening on ws://localhost:${ADAPTER_PORT}`);
|
||||
console.log("[demo-gateway] No OpenClaw or Hermes required.");
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startAdapter();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleMethod,
|
||||
startAdapter,
|
||||
};
|
||||
+189
-7
@@ -1,5 +1,17 @@
|
||||
const { Buffer } = require("node:buffer");
|
||||
const { WebSocket, WebSocketServer } = require("ws");
|
||||
|
||||
const DEFAULT_UPSTREAM_HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Maximum frame payload size (256 KB). */
|
||||
const MAX_FRAME_SIZE = 256 * 1024;
|
||||
|
||||
/** Sustained frame rate per connection. */
|
||||
const MAX_FRAMES_PER_SECOND = 60;
|
||||
|
||||
/** Allow short startup bursts before rate limiting. */
|
||||
const MAX_FRAME_BURST = 120;
|
||||
|
||||
const buildErrorResponse = (id, code, message) => {
|
||||
return {
|
||||
type: "res",
|
||||
@@ -19,6 +31,60 @@ const safeJsonParse = (raw) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Per-connection token bucket rate limiter. */
|
||||
const createFrameRateLimiter = (
|
||||
maxPerSecond = MAX_FRAMES_PER_SECOND,
|
||||
maxBurst = MAX_FRAME_BURST
|
||||
) => {
|
||||
let tokens = maxBurst;
|
||||
let lastRefillAt = Date.now();
|
||||
|
||||
const refill = () => {
|
||||
const now = Date.now();
|
||||
const elapsedMs = Math.max(0, now - lastRefillAt);
|
||||
if (elapsedMs <= 0) return;
|
||||
const replenished = (elapsedMs / 1000) * maxPerSecond;
|
||||
tokens = Math.min(maxBurst, tokens + replenished);
|
||||
lastRefillAt = now;
|
||||
};
|
||||
|
||||
return {
|
||||
check() {
|
||||
refill();
|
||||
if (tokens < 1) {
|
||||
return false;
|
||||
}
|
||||
tokens -= 1;
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
// No-op: token bucket has no timers to clean up.
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate upstream URL against an allowlist.
|
||||
* If UPSTREAM_ALLOWLIST env var is set, only those hosts are permitted.
|
||||
* Format: comma-separated hostnames, e.g. "gateway.percival-labs.ai,localhost"
|
||||
*/
|
||||
const isUpstreamAllowed = (url) => {
|
||||
const allowlist = (process.env.UPSTREAM_ALLOWLIST || "").trim();
|
||||
if (!allowlist) {
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const allowed = allowlist
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return allowed.includes(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePathname = (url) => {
|
||||
const raw = typeof url === "string" ? url : "";
|
||||
const idx = raw.indexOf("?");
|
||||
@@ -85,6 +151,7 @@ function createGatewayProxy(options) {
|
||||
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
|
||||
log = () => {},
|
||||
logError = (msg, err) => console.error(msg, err),
|
||||
upstreamHandshakeTimeoutMs = DEFAULT_UPSTREAM_HANDSHAKE_TIMEOUT_MS,
|
||||
} = options || {};
|
||||
|
||||
const { verifyClient } = options || {};
|
||||
@@ -100,15 +167,23 @@ function createGatewayProxy(options) {
|
||||
let upstreamReady = false;
|
||||
let upstreamUrl = "";
|
||||
let upstreamToken = "";
|
||||
let upstreamAdapterType = "openclaw";
|
||||
let connectRequestId = null;
|
||||
let connectResponseSent = false;
|
||||
let pendingConnectFrame = null;
|
||||
let pendingUpstreamSetupError = null;
|
||||
let closed = false;
|
||||
const frameRateLimiter = createFrameRateLimiter();
|
||||
let upstreamHandshakeTimeoutId = null;
|
||||
|
||||
const closeBoth = (code, reason) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
frameRateLimiter.destroy();
|
||||
if (upstreamHandshakeTimeoutId !== null) {
|
||||
clearTimeout(upstreamHandshakeTimeoutId);
|
||||
upstreamHandshakeTimeoutId = null;
|
||||
}
|
||||
try {
|
||||
browserWs.close(code, reason);
|
||||
} catch {}
|
||||
@@ -137,7 +212,8 @@ function createGatewayProxy(options) {
|
||||
hasNonEmptyDeviceToken(frame.params) ||
|
||||
hasCompleteDeviceAuth(frame.params);
|
||||
|
||||
if (!upstreamToken && !browserHasAuth) {
|
||||
const requiresToken = upstreamAdapterType === "openclaw";
|
||||
if (requiresToken && !upstreamToken && !browserHasAuth) {
|
||||
sendConnectError(
|
||||
"studio.gateway_token_missing",
|
||||
"Upstream gateway token is not configured on the Studio host."
|
||||
@@ -168,6 +244,10 @@ function createGatewayProxy(options) {
|
||||
const settings = await loadUpstreamSettings();
|
||||
upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : "";
|
||||
upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : "";
|
||||
upstreamAdapterType =
|
||||
typeof settings?.adapterType === "string" && settings.adapterType.trim()
|
||||
? settings.adapterType.trim().toLowerCase()
|
||||
: "openclaw";
|
||||
} catch (err) {
|
||||
logError("Failed to load upstream gateway settings.", err);
|
||||
pendingUpstreamSetupError = {
|
||||
@@ -185,6 +265,14 @@ function createGatewayProxy(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isUpstreamAllowed(upstreamUrl)) {
|
||||
pendingUpstreamSetupError = {
|
||||
code: "studio.gateway_url_blocked",
|
||||
message: "Upstream gateway URL is not in the allowed hosts list.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let upstreamOrigin = "";
|
||||
try {
|
||||
upstreamOrigin = resolveOriginForUpstream(upstreamUrl);
|
||||
@@ -196,9 +284,30 @@ function createGatewayProxy(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
upstreamWs = new WebSocket(upstreamUrl, { origin: upstreamOrigin });
|
||||
upstreamWs = new WebSocket(upstreamUrl, {
|
||||
origin: upstreamOrigin,
|
||||
handshakeTimeout: upstreamHandshakeTimeoutMs,
|
||||
});
|
||||
|
||||
upstreamHandshakeTimeoutId = setTimeout(() => {
|
||||
const timeoutError = {
|
||||
code: "studio.upstream_timeout",
|
||||
message: "Timed out connecting Studio to the upstream gateway WebSocket.",
|
||||
};
|
||||
pendingUpstreamSetupError = timeoutError;
|
||||
try {
|
||||
upstreamWs?.terminate();
|
||||
} catch {}
|
||||
if (connectRequestId) {
|
||||
sendConnectError(timeoutError.code, timeoutError.message);
|
||||
}
|
||||
}, upstreamHandshakeTimeoutMs);
|
||||
|
||||
upstreamWs.on("open", () => {
|
||||
if (upstreamHandshakeTimeoutId !== null) {
|
||||
clearTimeout(upstreamHandshakeTimeoutId);
|
||||
upstreamHandshakeTimeoutId = null;
|
||||
}
|
||||
upstreamReady = true;
|
||||
maybeForwardPendingConnect();
|
||||
});
|
||||
@@ -216,22 +325,63 @@ function createGatewayProxy(options) {
|
||||
}
|
||||
});
|
||||
|
||||
upstreamWs.on("close", (ev) => {
|
||||
const reason = typeof ev?.reason === "string" ? ev.reason : "";
|
||||
upstreamWs.on("close", (code, reasonBuffer) => {
|
||||
if (upstreamHandshakeTimeoutId !== null) {
|
||||
clearTimeout(upstreamHandshakeTimeoutId);
|
||||
upstreamHandshakeTimeoutId = null;
|
||||
}
|
||||
const reason =
|
||||
typeof reasonBuffer === "string"
|
||||
? reasonBuffer
|
||||
: Buffer.isBuffer(reasonBuffer)
|
||||
? reasonBuffer.toString()
|
||||
: "";
|
||||
log(
|
||||
`[gateway-proxy] upstream closed code=${code} reason=${reason || "(none)"} hadConnect=${Boolean(connectRequestId)} responseSent=${connectResponseSent}`
|
||||
);
|
||||
if (!connectRequestId) {
|
||||
pendingUpstreamSetupError ||= {
|
||||
code: "studio.upstream_closed",
|
||||
message: `Upstream gateway closed (${code}): ${reason}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!connectResponseSent && connectRequestId) {
|
||||
connectResponseSent = true;
|
||||
sendToBrowser(
|
||||
buildErrorResponse(
|
||||
connectRequestId,
|
||||
"studio.upstream_closed",
|
||||
`Upstream gateway closed (${ev.code}): ${reason}`
|
||||
code === 1008 ? "studio.upstream_rejected" : "studio.upstream_closed",
|
||||
code === 1008
|
||||
? `Upstream gateway rejected connect (${code}): ${reason || "no reason provided"}`
|
||||
: `Upstream gateway closed (${code}): ${reason}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
closeBoth(1012, "upstream closed");
|
||||
});
|
||||
|
||||
upstreamWs.on("error", (err) => {
|
||||
if (upstreamHandshakeTimeoutId !== null) {
|
||||
clearTimeout(upstreamHandshakeTimeoutId);
|
||||
upstreamHandshakeTimeoutId = null;
|
||||
}
|
||||
logError("Upstream gateway WebSocket error.", err);
|
||||
if (!connectRequestId) {
|
||||
pendingUpstreamSetupError ||= {
|
||||
code: "studio.upstream_error",
|
||||
message: "Failed to connect to upstream gateway WebSocket.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pendingUpstreamSetupError?.code === "studio.upstream_timeout" &&
|
||||
pendingUpstreamSetupError?.message
|
||||
) {
|
||||
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
||||
return;
|
||||
}
|
||||
sendConnectError(
|
||||
"studio.upstream_error",
|
||||
"Failed to connect to upstream gateway WebSocket."
|
||||
@@ -244,7 +394,29 @@ function createGatewayProxy(options) {
|
||||
void startUpstream();
|
||||
|
||||
browserWs.on("message", async (raw) => {
|
||||
const parsed = safeJsonParse(String(raw ?? ""));
|
||||
const rawStr = String(raw ?? "");
|
||||
const rawByteLength = Buffer.byteLength(rawStr, "utf8");
|
||||
|
||||
// Frame size limit
|
||||
if (rawByteLength > MAX_FRAME_SIZE) {
|
||||
closeBoth(1009, "frame too large");
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (!frameRateLimiter.check()) {
|
||||
log(
|
||||
"[gateway-proxy] proxy rate limit hit (>" +
|
||||
MAX_FRAMES_PER_SECOND +
|
||||
" frames/s sustained, burst " +
|
||||
MAX_FRAME_BURST +
|
||||
")"
|
||||
);
|
||||
closeBoth(1008, "rate limit exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = safeJsonParse(rawStr);
|
||||
if (!parsed || !isObject(parsed)) {
|
||||
closeBoth(1003, "invalid json");
|
||||
return;
|
||||
@@ -261,6 +433,15 @@ function createGatewayProxy(options) {
|
||||
return;
|
||||
}
|
||||
connectRequestId = id;
|
||||
const params = isObject(parsed.params) ? parsed.params : null;
|
||||
const client = params && isObject(params.client) ? params.client : null;
|
||||
log(
|
||||
`[gateway-proxy] connect frame client.id=${
|
||||
typeof client?.id === "string" ? client.id : "n/a"
|
||||
} client.mode=${
|
||||
typeof client?.mode === "string" ? client.mode : "n/a"
|
||||
} hasToken=${hasNonEmptyToken(params)} hasDevice=${hasCompleteDeviceAuth(params)}`
|
||||
);
|
||||
if (pendingUpstreamSetupError) {
|
||||
sendConnectError(pendingUpstreamSetupError.code, pendingUpstreamSetupError.message);
|
||||
return;
|
||||
@@ -285,6 +466,7 @@ function createGatewayProxy(options) {
|
||||
});
|
||||
|
||||
browserWs.on("close", () => {
|
||||
log("[gateway-proxy] browser disconnected");
|
||||
closeBoth(1000, "client closed");
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -91,8 +91,10 @@ async function main() {
|
||||
const proxy = createGatewayProxy({
|
||||
loadUpstreamSettings: async () => {
|
||||
const settings = loadUpstreamGatewaySettings(process.env);
|
||||
return { url: settings.url, token: settings.token };
|
||||
return { url: settings.url, token: settings.token, adapterType: settings.adapterType };
|
||||
},
|
||||
log: (message) => console.info(message),
|
||||
logError: (message, error) => console.error(message, error),
|
||||
allowWs: (req) => {
|
||||
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
|
||||
return true;
|
||||
|
||||
@@ -76,7 +76,7 @@ const readOpenclawGatewayDefaults = (env = process.env) => {
|
||||
if (!token) return null;
|
||||
const url = port ? `ws://localhost:${port}` : "";
|
||||
if (!url) return null;
|
||||
return { url, token };
|
||||
return { url, token, adapterType: "openclaw" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -88,12 +88,17 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
|
||||
const gateway = parsed && typeof parsed === "object" ? parsed.gateway : null;
|
||||
const url = typeof gateway?.url === "string" ? gateway.url.trim() : "";
|
||||
const token = typeof gateway?.token === "string" ? gateway.token.trim() : "";
|
||||
if (!token) {
|
||||
const adapterType =
|
||||
typeof gateway?.adapterType === "string" && gateway.adapterType.trim()
|
||||
? gateway.adapterType.trim()
|
||||
: "openclaw";
|
||||
if (!token && adapterType === "openclaw") {
|
||||
const defaults = readOpenclawGatewayDefaults(env);
|
||||
if (defaults) {
|
||||
return {
|
||||
url: url || defaults.url,
|
||||
token: defaults.token,
|
||||
adapterType,
|
||||
settingsPath,
|
||||
};
|
||||
}
|
||||
@@ -101,6 +106,7 @@ const loadUpstreamGatewaySettings = (env = process.env) => {
|
||||
return {
|
||||
url: url || DEFAULT_GATEWAY_URL,
|
||||
token,
|
||||
adapterType,
|
||||
settingsPath,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,6 +62,12 @@ const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime
|
||||
return { resolved, mime };
|
||||
};
|
||||
|
||||
const isWithinAllowedRoot = (targetPath: string, allowedRoot: string): boolean => {
|
||||
const relative = path.relative(allowedRoot, targetPath);
|
||||
if (!relative) return true;
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
};
|
||||
|
||||
const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => {
|
||||
const { trimmed, mime } = validateRawMediaPath(raw);
|
||||
|
||||
@@ -83,15 +89,32 @@ const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: strin
|
||||
return { remotePath: trimmed, mime };
|
||||
};
|
||||
|
||||
const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
const readLocalMedia = async (
|
||||
resolvedPath: string,
|
||||
allowedRoot: string
|
||||
): Promise<{ bytes: Buffer; size: number }> => {
|
||||
const entry = await fs.lstat(resolvedPath);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error("symlinked media paths are not allowed");
|
||||
}
|
||||
|
||||
const [realResolvedPath, realAllowedRoot] = await Promise.all([
|
||||
fs.realpath(resolvedPath),
|
||||
fs.realpath(allowedRoot).catch(() => path.resolve(allowedRoot)),
|
||||
]);
|
||||
|
||||
if (!isWithinAllowedRoot(realResolvedPath, realAllowedRoot)) {
|
||||
throw new Error(`Refusing to read media outside ${realAllowedRoot}`);
|
||||
}
|
||||
|
||||
const stat = await fs.stat(realResolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("path is not a file");
|
||||
}
|
||||
if (stat.size > MAX_MEDIA_BYTES) {
|
||||
throw new Error(`media file too large (${stat.size} bytes)`);
|
||||
}
|
||||
const buf = await fs.readFile(resolvedPath);
|
||||
const buf = await fs.readFile(realResolvedPath);
|
||||
return { bytes: buf, size: stat.size };
|
||||
};
|
||||
|
||||
@@ -166,7 +189,8 @@ export async function GET(request: Request) {
|
||||
|
||||
if (!sshTarget) {
|
||||
const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath);
|
||||
const { bytes, size } = await readLocalMedia(resolved);
|
||||
const allowedRoot = path.join(os.homedir(), ".openclaw");
|
||||
const { bytes, size } = await readLocalMedia(resolved, allowedRoot);
|
||||
const body = new Blob([Uint8Array.from(bytes)], { type: mime });
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type CustomRuntimeRequestBody = {
|
||||
runtimeUrl?: string;
|
||||
pathname?: string;
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
const isRuntimeUrlAllowed = (runtimeUrl: string): boolean => {
|
||||
const rawAllowlist = (
|
||||
process.env.CUSTOM_RUNTIME_ALLOWLIST ||
|
||||
process.env.UPSTREAM_ALLOWLIST ||
|
||||
""
|
||||
).trim();
|
||||
if (!rawAllowlist) {
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(runtimeUrl);
|
||||
const allowed = rawAllowlist
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return allowed.includes(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeRuntimeUrl = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("runtimeUrl is required.");
|
||||
}
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "ws:") {
|
||||
parsed.protocol = "http:";
|
||||
} else if (parsed.protocol === "wss:") {
|
||||
parsed.protocol = "https:";
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("runtimeUrl must use http, https, ws, or wss.");
|
||||
}
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
const normalized = parsed.toString().replace(/\/$/, "");
|
||||
if (!isRuntimeUrlAllowed(normalized)) {
|
||||
throw new Error("runtimeUrl is not in the allowed hosts list.");
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizePathname = (value: unknown): string => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error("pathname is required.");
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
};
|
||||
|
||||
const normalizeMethod = (value: unknown): "GET" | "POST" => {
|
||||
if (typeof value !== "string") return "GET";
|
||||
const upper = value.trim().toUpperCase();
|
||||
if (upper === "POST") return "POST";
|
||||
return "GET";
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let payload;
|
||||
try {
|
||||
payload = (await request.json()) as CustomRuntimeRequestBody;
|
||||
} catch (error) {
|
||||
console.error("[runtime/custom] Invalid JSON request body.", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON request body." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeUrl = normalizeRuntimeUrl(payload.runtimeUrl ?? "");
|
||||
const pathname = normalizePathname(payload.pathname);
|
||||
const method = normalizeMethod(payload.method);
|
||||
const response = await fetch(`${runtimeUrl}${pathname}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(method === "POST" ? { "Content-Type": "application/json" } : null),
|
||||
},
|
||||
body: method === "POST" ? JSON.stringify(payload.body ?? {}) : undefined,
|
||||
cache: "no-store",
|
||||
});
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("content-type") ?? "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Custom runtime proxy failed.";
|
||||
const status =
|
||||
message === "runtimeUrl is required." ||
|
||||
message === "pathname is required." ||
|
||||
message === "runtimeUrl must use http, https, ws, or wss." ||
|
||||
message === "runtimeUrl is not in the allowed hosts list."
|
||||
? 400
|
||||
: 502;
|
||||
console.error("[runtime/custom] Proxy request failed.", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
status === 400
|
||||
? message
|
||||
: "Custom runtime proxy failed.",
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,11 @@ export async function GET() {
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = (await request.json()) as unknown;
|
||||
const rawBody = await request.text();
|
||||
if (!rawBody.trim()) {
|
||||
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||
}
|
||||
const body = JSON.parse(rawBody) as unknown;
|
||||
if (!isPatch(body)) {
|
||||
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isTaskBoardSource, isTaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import { archiveSharedTask, listSharedTasks, upsertSharedTask } from "@/lib/tasks/shared-store";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
Response.json(body, {
|
||||
status,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
|
||||
const errorJson = (message: string, status: number) =>
|
||||
json({ error: message }, status);
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return json({ tasks: listSharedTasks() });
|
||||
} catch (error) {
|
||||
console.error("[task-store] GET failed:", error);
|
||||
return errorJson("Internal error reading task store.", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorJson("Invalid JSON payload.", 400);
|
||||
}
|
||||
if (!isRecord(body) || !isRecord(body.task)) {
|
||||
return errorJson("Task payload is required.", 400);
|
||||
}
|
||||
const task = body.task;
|
||||
const id = typeof task.id === "string" ? task.id.trim() : "";
|
||||
const title = typeof task.title === "string" ? task.title.trim() : "";
|
||||
if (!id || !title) {
|
||||
return errorJson("Task id and title are required.", 400);
|
||||
}
|
||||
if (task.status !== undefined && !isTaskBoardStatus(task.status)) {
|
||||
return errorJson(`Invalid status: "${String(task.status)}".`, 400);
|
||||
}
|
||||
if (task.source !== undefined && !isTaskBoardSource(task.source)) {
|
||||
return errorJson(`Invalid source: "${String(task.source)}".`, 400);
|
||||
}
|
||||
try {
|
||||
return json({
|
||||
task: upsertSharedTask({ ...task, id, title }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[task-store] PUT failed:", error);
|
||||
return errorJson("Internal error writing task store.", 500);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorJson("Invalid JSON payload.", 400);
|
||||
}
|
||||
if (!isRecord(body)) {
|
||||
return errorJson("Task id is required.", 400);
|
||||
}
|
||||
const taskId = typeof body.id === "string" ? body.id.trim() : "";
|
||||
if (!taskId) {
|
||||
return errorJson("Task id is required.", 400);
|
||||
}
|
||||
try {
|
||||
const task = archiveSharedTask(taskId);
|
||||
if (!task) {
|
||||
return errorJson("Task not found.", 404);
|
||||
}
|
||||
return json({ task });
|
||||
} catch (error) {
|
||||
console.error("[task-store] DELETE failed:", error);
|
||||
return errorJson("Internal error archiving task.", 500);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
|
||||
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
||||
import { buildAgentAvatarPortraitDataUrl } from "@/lib/avatars/profilePortrait";
|
||||
|
||||
type AgentAvatarProps = {
|
||||
seed: string;
|
||||
name: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl?: string | null;
|
||||
size?: number;
|
||||
isSelected?: boolean;
|
||||
@@ -14,15 +17,17 @@ type AgentAvatarProps = {
|
||||
export const AgentAvatar = ({
|
||||
seed,
|
||||
name,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
size = 112,
|
||||
isSelected = false,
|
||||
}: AgentAvatarProps) => {
|
||||
const src = useMemo(() => {
|
||||
if (avatarProfile) return buildAgentAvatarPortraitDataUrl(avatarProfile);
|
||||
const trimmed = avatarUrl?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
return buildAvatarDataUrl(seed);
|
||||
}, [avatarUrl, seed]);
|
||||
}, [avatarProfile, avatarUrl, seed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Square, Trash2, X } from "lucide-react";
|
||||
import { Check, ChevronRight, Clock, Mic, Pencil, Square, Trash2, X } from "lucide-react";
|
||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||
import { isNearBottom } from "@/lib/dom";
|
||||
@@ -52,6 +53,10 @@ const ASSISTANT_GUTTER_CLASS = "pl-[44px]";
|
||||
const ASSISTANT_MAX_WIDTH_DEFAULT_CLASS = "max-w-[68ch]";
|
||||
const ASSISTANT_MAX_WIDTH_EXPANDED_CLASS = "max-w-[1120px]";
|
||||
const CHAT_TOP_THRESHOLD_PX = 8;
|
||||
const CHAT_SELECT_STYLE = {
|
||||
backgroundColor: "#17120a",
|
||||
color: "#ffffff",
|
||||
} as const;
|
||||
const EMPTY_CHAT_INTRO_MESSAGES = [
|
||||
"How can I help you today?",
|
||||
"What should we accomplish today?",
|
||||
@@ -120,7 +125,7 @@ type AgentChatPanelProps = {
|
||||
stopBusy: boolean;
|
||||
stopDisabledReason?: string | null;
|
||||
onLoadMoreHistory: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onRename?: (name: string) => Promise<boolean>;
|
||||
onNewSession?: () => Promise<void> | void;
|
||||
onModelChange: (value: string | null) => void;
|
||||
@@ -361,6 +366,7 @@ const UserMessageCard = memo(function UserMessageCard({
|
||||
|
||||
const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
name,
|
||||
timestampMs,
|
||||
@@ -372,6 +378,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
streaming,
|
||||
}: {
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
name: string;
|
||||
timestampMs?: number;
|
||||
@@ -398,7 +405,13 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
<div className="w-full self-start">
|
||||
<div className={`relative w-full ${widthClass} ${ASSISTANT_GUTTER_CLASS}`}>
|
||||
<div className="absolute left-[4px] top-[2px]">
|
||||
<AgentAvatar seed={avatarSeed} name={name} avatarUrl={avatarUrl} size={22} />
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={name}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
size={22}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-0.5">
|
||||
<div className="type-meta min-w-0 truncate font-mono text-foreground/90">
|
||||
@@ -500,11 +513,13 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
|
||||
|
||||
const AssistantIntroCard = memo(function AssistantIntroCard({
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
name,
|
||||
title,
|
||||
}: {
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -513,7 +528,13 @@ const AssistantIntroCard = memo(function AssistantIntroCard({
|
||||
<div className="w-full self-start">
|
||||
<div className={`relative w-full ${ASSISTANT_MAX_WIDTH_DEFAULT_CLASS} ${ASSISTANT_GUTTER_CLASS}`}>
|
||||
<div className="absolute left-[4px] top-[2px]">
|
||||
<AgentAvatar seed={avatarSeed} name={name} avatarUrl={avatarUrl} size={22} />
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={name}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
size={22}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-0.5">
|
||||
<div className="type-meta min-w-0 truncate font-mono text-foreground/90">
|
||||
@@ -535,6 +556,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
agentId,
|
||||
name,
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
chatItems,
|
||||
running,
|
||||
@@ -543,6 +565,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
agentId: string;
|
||||
name: string;
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
chatItems: AgentChatItem[];
|
||||
running: boolean;
|
||||
@@ -567,6 +590,7 @@ const AgentChatFinalItems = memo(function AgentChatFinalItems({
|
||||
<AssistantMessageCard
|
||||
key={`chat-${agentId}-assistant-${index}`}
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
timestampMs={block.timestampMs ?? (streaming ? runStartedAt ?? undefined : undefined)}
|
||||
@@ -585,6 +609,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
agentId,
|
||||
name,
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
avatarUrl,
|
||||
status,
|
||||
historyMaybeTruncated,
|
||||
@@ -607,6 +632,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
agentId: string;
|
||||
name: string;
|
||||
avatarSeed: string;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
avatarUrl: string | null;
|
||||
status: AgentRecord["status"];
|
||||
historyMaybeTruncated: boolean;
|
||||
@@ -766,6 +792,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
{!hasTranscriptContent ? (
|
||||
<AssistantIntroCard
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
title={emptyStateTitle}
|
||||
@@ -776,6 +803,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
agentId={agentId}
|
||||
name={name}
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
chatItems={chatItems}
|
||||
running={status === "running"}
|
||||
@@ -784,6 +812,7 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
|
||||
{showLiveAssistantCard ? (
|
||||
<AssistantMessageCard
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={avatarUrl}
|
||||
name={name}
|
||||
timestampMs={runStartedAt ?? undefined}
|
||||
@@ -961,10 +990,10 @@ const AgentChatComposer = memo(function AgentChatComposer({
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<InlineHoverTooltip text="Choose model">
|
||||
<select
|
||||
className="ui-input ui-control-important h-6 min-w-0 rounded-md px-1.5 text-[10px] font-semibold text-foreground"
|
||||
className="ui-input ui-control-important h-6 min-w-0 rounded-md border-white/10 px-1.5 text-[10px] font-semibold text-white"
|
||||
aria-label="Model"
|
||||
value={modelValue}
|
||||
style={{ width: `${modelSelectWidthCh}ch` }}
|
||||
style={{ ...CHAT_SELECT_STYLE, width: `${modelSelectWidthCh}ch` }}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value.trim();
|
||||
onModelChange(nextValue ? nextValue : null);
|
||||
@@ -984,10 +1013,10 @@ const AgentChatComposer = memo(function AgentChatComposer({
|
||||
{allowThinking ? (
|
||||
<InlineHoverTooltip text="Select reasoning effort">
|
||||
<select
|
||||
className="ui-input ui-control-important h-6 rounded-md px-1.5 text-[10px] font-semibold text-foreground"
|
||||
className="ui-input ui-control-important h-6 rounded-md border-white/10 px-1.5 text-[10px] font-semibold text-white"
|
||||
aria-label="Thinking"
|
||||
value={thinkingValue}
|
||||
style={{ width: `${thinkingSelectWidthCh}ch` }}
|
||||
style={{ ...CHAT_SELECT_STYLE, width: `${thinkingSelectWidthCh}ch` }}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value.trim();
|
||||
onThinkingChange(nextValue ? nextValue : null);
|
||||
@@ -1341,6 +1370,7 @@ export const AgentChatPanel = ({
|
||||
const allowThinking = selectedModel?.reasoning !== false;
|
||||
|
||||
const avatarSeed = agent.avatarSeed ?? agent.agentId;
|
||||
const avatarProfile = agent.avatarProfile ?? null;
|
||||
const emptyStateTitle = useMemo(
|
||||
() => resolveEmptyChatIntroMessage(agent.agentId, agent.sessionEpoch),
|
||||
[agent.agentId, agent.sessionEpoch]
|
||||
@@ -1473,6 +1503,7 @@ export const AgentChatPanel = ({
|
||||
<AgentAvatar
|
||||
seed={avatarSeed}
|
||||
name={agent.name}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={agent.avatarUrl ?? null}
|
||||
size={52}
|
||||
isSelected={isSelected}
|
||||
@@ -1561,8 +1592,20 @@ export const AgentChatPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
{onOpenSettings ? (
|
||||
<button
|
||||
className="nodrag ui-btn-icon ui-btn-icon-sm shrink-0"
|
||||
type="button"
|
||||
data-testid="agent-settings-toggle"
|
||||
aria-label="Open behavior"
|
||||
title="Open behavior"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-[color:var(--status-approval-fg)] transition hover:bg-[color:var(--status-approval-bg)] hover:text-[color:var(--status-approval-fg)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-white transition hover:bg-[color:var(--status-approval-bg)] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
data-testid="agent-new-session-toggle"
|
||||
aria-label="Start new session"
|
||||
@@ -1574,17 +1617,6 @@ export const AgentChatPanel = ({
|
||||
>
|
||||
{newSessionBusy ? "Starting..." : "New session"}
|
||||
</button>
|
||||
<button
|
||||
className="nodrag ui-btn-icon"
|
||||
style={{ "--ui-btn-icon-size": "1.25rem" } as React.CSSProperties}
|
||||
type="button"
|
||||
data-testid="agent-settings-toggle"
|
||||
aria-label="Open behavior"
|
||||
title="Behavior"
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
<Cog className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1594,6 +1626,7 @@ export const AgentChatPanel = ({
|
||||
agentId={agent.agentId}
|
||||
name={agent.name}
|
||||
avatarSeed={avatarSeed}
|
||||
avatarProfile={avatarProfile}
|
||||
avatarUrl={agent.avatarUrl ?? null}
|
||||
status={agent.status}
|
||||
historyMaybeTruncated={agent.historyMaybeTruncated}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
|
||||
import { X } from "lucide-react";
|
||||
import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics";
|
||||
|
||||
type ConnectionPanelProps = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
selectedAdapterType: StudioGatewayAdapterType;
|
||||
activeAdapterType: StudioGatewayAdapterType;
|
||||
localGatewayUrl?: string | null;
|
||||
localGatewayToken?: string | null;
|
||||
status: GatewayStatus;
|
||||
error: string | null;
|
||||
onGatewayUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onAdapterTypeChange: (value: StudioGatewayAdapterType) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -17,16 +23,37 @@ type ConnectionPanelProps = {
|
||||
export const ConnectionPanel = ({
|
||||
gatewayUrl,
|
||||
token,
|
||||
selectedAdapterType,
|
||||
activeAdapterType,
|
||||
localGatewayUrl = null,
|
||||
localGatewayToken = null,
|
||||
status,
|
||||
error,
|
||||
onGatewayUrlChange,
|
||||
onTokenChange,
|
||||
onAdapterTypeChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onClose,
|
||||
}: ConnectionPanelProps) => {
|
||||
const isConnected = status === "connected";
|
||||
const isConnecting = status === "connecting";
|
||||
const tokenOptional =
|
||||
selectedAdapterType === "hermes" ||
|
||||
selectedAdapterType === "demo" ||
|
||||
selectedAdapterType === "custom";
|
||||
const applyDemoPreset = () => {
|
||||
onAdapterTypeChange("demo");
|
||||
};
|
||||
const applyHermesPreset = () => {
|
||||
onAdapterTypeChange("hermes");
|
||||
};
|
||||
const applyCustomPreset = () => {
|
||||
onAdapterTypeChange("custom");
|
||||
};
|
||||
const applyOpenClawPreset = () => {
|
||||
onAdapterTypeChange("openclaw");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-up-delay flex flex-col gap-3">
|
||||
@@ -73,17 +100,52 @@ export const ConnectionPanel = ({
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||
Upstream token
|
||||
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
|
||||
<input
|
||||
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
placeholder="gateway token"
|
||||
placeholder={tokenOptional ? "optional token" : "gateway token"}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="font-mono">Selected backend: {selectedAdapterType}</span>
|
||||
<span className="font-mono">Active backend: {activeAdapterType}</span>
|
||||
<span>Each backend keeps its own saved URL and token.</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
type="button"
|
||||
onClick={applyDemoPreset}
|
||||
>
|
||||
Demo backend
|
||||
</button>
|
||||
<button
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
type="button"
|
||||
onClick={applyHermesPreset}
|
||||
>
|
||||
Hermes backend
|
||||
</button>
|
||||
<button
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
type="button"
|
||||
onClick={applyCustomPreset}
|
||||
>
|
||||
Custom backend
|
||||
</button>
|
||||
<button
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
type="button"
|
||||
onClick={applyOpenClawPreset}
|
||||
>
|
||||
OpenClaw backend
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="ui-alert-danger rounded-md px-4 py-2 text-sm">
|
||||
{error}
|
||||
|
||||
@@ -2,18 +2,21 @@ import { useMemo, useState } from "react";
|
||||
import { Check, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import type { StudioGatewaySettings } from "@/lib/studio/settings";
|
||||
import type { StudioGatewayAdapterType, StudioGatewaySettings } from "@/lib/studio/settings";
|
||||
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||
|
||||
type GatewayConnectScreenProps = {
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
selectedAdapterType: StudioGatewayAdapterType;
|
||||
activeAdapterType: StudioGatewayAdapterType;
|
||||
localGatewayDefaults: StudioGatewaySettings | null;
|
||||
status: GatewayStatus;
|
||||
error: string | null;
|
||||
showApprovalHint: boolean;
|
||||
onGatewayUrlChange: (value: string) => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onAdapterTypeChange: (value: StudioGatewayAdapterType) => void;
|
||||
onUseLocalDefaults: () => void;
|
||||
onConnect: () => void;
|
||||
};
|
||||
@@ -30,17 +33,24 @@ const resolveLocalGatewayPort = (gatewayUrl: string): number => {
|
||||
export const GatewayConnectScreen = ({
|
||||
gatewayUrl,
|
||||
token,
|
||||
selectedAdapterType,
|
||||
activeAdapterType,
|
||||
localGatewayDefaults,
|
||||
status,
|
||||
error,
|
||||
showApprovalHint,
|
||||
onGatewayUrlChange,
|
||||
onTokenChange,
|
||||
onAdapterTypeChange,
|
||||
onUseLocalDefaults,
|
||||
onConnect,
|
||||
}: GatewayConnectScreenProps) => {
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const tokenOptional =
|
||||
selectedAdapterType === "hermes" ||
|
||||
selectedAdapterType === "demo" ||
|
||||
selectedAdapterType === "custom";
|
||||
const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
|
||||
const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]);
|
||||
const localGatewayCommand = useMemo(
|
||||
@@ -51,6 +61,22 @@ export const GatewayConnectScreen = ({
|
||||
() => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`,
|
||||
[localPort]
|
||||
);
|
||||
const localDemoCommand = useMemo(
|
||||
() => `npm run demo-gateway`,
|
||||
[]
|
||||
);
|
||||
const useDemoPreset = () => {
|
||||
onAdapterTypeChange("demo");
|
||||
};
|
||||
const useHermesPreset = () => {
|
||||
onAdapterTypeChange("hermes");
|
||||
};
|
||||
const useOpenClawPreset = () => {
|
||||
onAdapterTypeChange("openclaw");
|
||||
};
|
||||
const useCustomPreset = () => {
|
||||
onAdapterTypeChange("custom");
|
||||
};
|
||||
const statusCopy = useMemo(() => {
|
||||
if (status === "connecting" && isLocal) {
|
||||
return `Local gateway detected on port ${localPort}. Connecting…`;
|
||||
@@ -133,14 +159,14 @@ export const GatewayConnectScreen = ({
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/90">
|
||||
Upstream token
|
||||
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
|
||||
<div className="relative">
|
||||
<input
|
||||
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
|
||||
type={showToken ? "text" : "password"}
|
||||
value={token}
|
||||
onChange={(event) => onTokenChange(event.target.value)}
|
||||
placeholder="gateway token"
|
||||
placeholder={tokenOptional ? "optional token" : "gateway token"}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
@@ -168,13 +194,13 @@ export const GatewayConnectScreen = ({
|
||||
</button>
|
||||
|
||||
{status === "connecting" ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<RunningAvatarLoader size={16} trackWidth={32} inline />
|
||||
Connecting…
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
|
||||
{showApprovalHint ? (
|
||||
{showApprovalHint && selectedAdapterType === "openclaw" ? (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-xs text-muted-foreground">
|
||||
<p className="leading-snug">
|
||||
If the first connection attempt did not work, go to your OpenClaw computer and approve this
|
||||
@@ -208,7 +234,45 @@ export const GatewayConnectScreen = ({
|
||||
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-muted-foreground">
|
||||
Remote gateway (recommended)
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-foreground/90">Default: enter your URL and token to connect.</p>
|
||||
<p className="mt-2 text-sm text-foreground/90">
|
||||
Choose a backend, then connect to its gateway URL.
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-[11px] text-muted-foreground">
|
||||
Selected backend: {selectedAdapterType} | Active backend: {activeAdapterType}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Each backend keeps its own saved URL and token.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
onClick={useDemoPreset}
|
||||
>
|
||||
Demo backend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
onClick={useHermesPreset}
|
||||
>
|
||||
Hermes backend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
onClick={useCustomPreset}
|
||||
>
|
||||
Custom backend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
|
||||
onClick={useOpenClawPreset}
|
||||
>
|
||||
OpenClaw backend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{remoteForm}
|
||||
</div>
|
||||
@@ -224,6 +288,41 @@ export const GatewayConnectScreen = ({
|
||||
</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{commandField}
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<p className="text-xs font-medium text-foreground">Just want to see the office?</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||
Run <span className="font-mono text-foreground">{localDemoCommand}</span> to start a built-in mock gateway with demo agents.
|
||||
Then choose <span className="font-mono text-foreground">Demo backend</span> and connect.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<p className="text-xs font-medium text-foreground">Using Hermes locally?</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||
Run <span className="font-mono text-foreground">npm run hermes-adapter</span>, then choose
|
||||
<span className="font-mono text-foreground"> Hermes backend</span>. The default local URL is
|
||||
<span className="font-mono text-foreground"> ws://localhost:18789</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<p className="text-xs font-medium text-foreground">Using a custom runtime locally?</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||
Choose <span className="font-mono text-foreground">Custom backend</span> and point the URL
|
||||
at your orchestrator or runtime boundary, for example
|
||||
<span className="font-mono text-foreground"> http://localhost:7770</span>. Direct custom
|
||||
runtime chat flows are not wired into Studio yet in this slice, but the provider seam and
|
||||
metadata scaffold are now in place.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
||||
<p className="text-xs font-medium text-foreground">Opening Claw3D from another machine?</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||
Start Studio with <span className="font-mono text-foreground">HOST=0.0.0.0</span> (or a
|
||||
specific LAN/Tailscale host) and set
|
||||
<span className="font-mono text-foreground"> STUDIO_ACCESS_TOKEN</span> before exposing it
|
||||
beyond localhost. Gateway settings are stored on the Studio host, but OpenClaw device approval
|
||||
remains per browser/device.
|
||||
</p>
|
||||
</div>
|
||||
{localGatewayDefaults ? (
|
||||
<div className="ui-input rounded-md px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -7,10 +7,14 @@ import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { AgentIdentityFields } from "@/features/agents/components/AgentIdentityFields";
|
||||
import {
|
||||
AGENT_FILE_META,
|
||||
AGENT_FILE_PLACEHOLDERS,
|
||||
PERSONALITY_FILE_NAMES,
|
||||
type AgentFileName,
|
||||
} from "@/lib/agents/agentFiles";
|
||||
import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
|
||||
import {
|
||||
createEmptyPersonalityDraft,
|
||||
parsePersonalityFiles,
|
||||
serializePersonalityFiles,
|
||||
} from "@/lib/agents/personalityBuilder";
|
||||
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
|
||||
|
||||
export type AgentBrainPanelProps = {
|
||||
@@ -36,6 +40,30 @@ const AgentBrainPanelSection = ({
|
||||
</section>
|
||||
);
|
||||
|
||||
const AgentFileProvenance = ({
|
||||
path,
|
||||
workspace,
|
||||
}: {
|
||||
path: string | null;
|
||||
workspace: string | null;
|
||||
}) => {
|
||||
if (!path && !workspace) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-border/50 bg-black/20 px-3 py-2 text-[11px] text-muted-foreground">
|
||||
{workspace ? (
|
||||
<div>
|
||||
Workspace: <span className="font-mono text-foreground">{workspace}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{path ? (
|
||||
<div>
|
||||
File: <span className="font-mono text-foreground">{path}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentBrainPanel = ({
|
||||
client,
|
||||
agents,
|
||||
@@ -61,9 +89,14 @@ export const AgentBrainPanel = ({
|
||||
agentFilesError,
|
||||
setAgentFileContent,
|
||||
saveAgentFiles,
|
||||
initializeAgentFiles,
|
||||
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
||||
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const missingPersonalityFiles = useMemo(
|
||||
() => PERSONALITY_FILE_NAMES.filter((name) => !agentFiles[name].exists),
|
||||
[agentFiles]
|
||||
);
|
||||
|
||||
const setIdentityField = useCallback(
|
||||
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
|
||||
@@ -101,6 +134,19 @@ export const AgentBrainPanel = ({
|
||||
selectedAgent,
|
||||
]);
|
||||
|
||||
const handleInitializeMissingFiles = useCallback(async () => {
|
||||
if (!selectedAgent) return;
|
||||
setSaveError(null);
|
||||
const nextDraft = createEmptyPersonalityDraft();
|
||||
nextDraft.identity.name = selectedAgent.name.trim();
|
||||
nextDraft.identity.creature = selectedAgent.role?.trim() ?? "";
|
||||
const serialized = serializePersonalityFiles(nextDraft);
|
||||
const missingEntries = Object.fromEntries(
|
||||
missingPersonalityFiles.map((name) => [name, serialized[name]])
|
||||
) as Partial<Record<AgentFileName, string>>;
|
||||
await initializeAgentFiles(missingEntries);
|
||||
}, [initializeAgentFiles, missingPersonalityFiles, selectedAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
onUnsavedChangesChange?.(agentFilesDirty);
|
||||
}, [agentFilesDirty, onUnsavedChangesChange]);
|
||||
@@ -112,21 +158,36 @@ export const AgentBrainPanel = ({
|
||||
}, [onUnsavedChangesChange]);
|
||||
|
||||
const renderMarkdownEditor = useCallback(
|
||||
(name: Exclude<AgentFileName, "IDENTITY.md">) => (
|
||||
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
|
||||
<div className="text-xs text-muted-foreground">{AGENT_FILE_META[name].hint}</div>
|
||||
<textarea
|
||||
aria-label={AGENT_FILE_META[name].title}
|
||||
className="h-[min(56vh,480px)] w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||
value={agentFiles[name].content}
|
||||
placeholder={AGENT_FILE_PLACEHOLDERS[name]}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setAgentFileContent(name, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AgentBrainPanelSection>
|
||||
),
|
||||
(name: Exclude<AgentFileName, "IDENTITY.md">) => {
|
||||
const file = agentFiles[name];
|
||||
const trimmedContent = file.content.trim();
|
||||
const statusCopy = !file.exists
|
||||
? `This agent does not have a custom ${name} yet. Saving here will create the real workspace file.`
|
||||
: !trimmedContent
|
||||
? `This agent's ${name} exists, but it is currently empty.`
|
||||
: null;
|
||||
return (
|
||||
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
|
||||
<div className="text-xs text-muted-foreground">{AGENT_FILE_META[name].hint}</div>
|
||||
{statusCopy ? (
|
||||
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
{statusCopy}
|
||||
</div>
|
||||
) : null}
|
||||
<AgentFileProvenance path={file.path} workspace={file.workspace} />
|
||||
<textarea
|
||||
aria-label={AGENT_FILE_META[name].title}
|
||||
className="h-[min(56vh,480px)] w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||
value={file.content}
|
||||
placeholder={!file.exists ? `No ${name} yet.` : ""}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onChange={(event) => {
|
||||
setAgentFileContent(name, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</AgentBrainPanelSection>
|
||||
);
|
||||
},
|
||||
[agentFiles, agentFilesLoading, agentFilesSaving, setAgentFileContent],
|
||||
);
|
||||
|
||||
@@ -141,6 +202,10 @@ export const AgentBrainPanel = ({
|
||||
Changing <span className="font-medium text-foreground">Name</span> here also renames the live agent
|
||||
when you save.
|
||||
</div>
|
||||
<AgentFileProvenance
|
||||
path={agentFiles["IDENTITY.md"].path}
|
||||
workspace={agentFiles["IDENTITY.md"].workspace}
|
||||
/>
|
||||
<AgentIdentityFields
|
||||
values={draft.identity}
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
@@ -191,6 +256,18 @@ export const AgentBrainPanel = ({
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
|
||||
{missingPersonalityFiles.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-secondary px-3 py-2 text-xs"
|
||||
disabled={agentFilesLoading || agentFilesSaving}
|
||||
onClick={() => {
|
||||
void handleInitializeMissingFiles();
|
||||
}}
|
||||
>
|
||||
Initialize missing files
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="ui-btn-ghost px-3 py-2 text-xs"
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder";
|
||||
import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types";
|
||||
import type { SkillStatusReport } from "@/lib/skills/types";
|
||||
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
|
||||
|
||||
export type AgentSettingsPanelProps = {
|
||||
agent: AgentState;
|
||||
@@ -47,6 +48,7 @@ export type AgentSettingsPanelProps = {
|
||||
cronCreateBusy?: boolean;
|
||||
onCreateCronJob?: (draft: CronCreateDraft) => Promise<void> | void;
|
||||
controlUiUrl?: string | null;
|
||||
adapterType?: StudioGatewayAdapterType | null;
|
||||
skillsReport?: SkillStatusReport | null;
|
||||
skillsLoading?: boolean;
|
||||
skillsError?: string | null;
|
||||
@@ -248,6 +250,7 @@ export const AgentSettingsPanel = ({
|
||||
cronCreateBusy = false,
|
||||
onCreateCronJob = () => {},
|
||||
controlUiUrl = null,
|
||||
adapterType = "openclaw",
|
||||
skillsReport = null,
|
||||
skillsLoading = false,
|
||||
skillsError = null,
|
||||
@@ -267,6 +270,7 @@ export const AgentSettingsPanel = ({
|
||||
onSkillApiKeyChange = () => {},
|
||||
onSaveSkillApiKey = () => {},
|
||||
}: AgentSettingsPanelProps) => {
|
||||
const isOpenClawRuntime = adapterType === "openclaw";
|
||||
const initialPermissionsDraft =
|
||||
permissionsDraft ?? resolvePresetDefaultsForRole(resolveExecutionRoleFromAgent(agent));
|
||||
const [permissionsBaselineValue, setPermissionsBaselineValue] =
|
||||
@@ -785,16 +789,18 @@ export const AgentSettingsPanel = ({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<section className="sidebar-section" data-testid="agent-settings-heartbeat-coming-soon">
|
||||
<h3 className="sidebar-section-title">Heartbeats</h3>
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">
|
||||
Heartbeat automation controls are coming soon.
|
||||
</div>
|
||||
</section>
|
||||
{isOpenClawRuntime ? (
|
||||
<section className="sidebar-section" data-testid="agent-settings-heartbeat-coming-soon">
|
||||
<h3 className="sidebar-section-title">Heartbeats</h3>
|
||||
<div className="mt-3 text-[11px] text-muted-foreground">
|
||||
Heartbeat automation controls are coming soon.
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{mode === "advanced" ? (
|
||||
{mode === "advanced" && isOpenClawRuntime ? (
|
||||
<>
|
||||
<section className="sidebar-section mt-8" data-testid="agent-settings-control-ui">
|
||||
<h3 className="sidebar-section-title ui-text-danger">Danger Zone</h3>
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles";
|
||||
import {
|
||||
readGatewayAgentFile,
|
||||
writeGatewayAgentFile,
|
||||
writeGatewayAgentFiles,
|
||||
} from "@/lib/gateway/agentFiles";
|
||||
import {
|
||||
AGENT_FILE_NAMES,
|
||||
type AgentFileName,
|
||||
@@ -21,6 +25,7 @@ export type UseAgentFilesEditorResult = {
|
||||
agentFilesError: string | null;
|
||||
setAgentFileContent: (name: AgentFileName, value: string) => void;
|
||||
saveAgentFiles: () => Promise<boolean>;
|
||||
initializeAgentFiles: (files: Partial<Record<AgentFileName, string>>) => Promise<boolean>;
|
||||
discardAgentFileChanges: () => void;
|
||||
};
|
||||
|
||||
@@ -67,7 +72,13 @@ export const useAgentFilesEditor = (params: {
|
||||
const results = await Promise.all(
|
||||
AGENT_FILE_NAMES.map(async (name) => {
|
||||
const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, name });
|
||||
return { name, content: file.content, exists: file.exists };
|
||||
return {
|
||||
name,
|
||||
content: file.content,
|
||||
exists: file.exists,
|
||||
path: file.path,
|
||||
workspace: file.workspace,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -77,6 +88,8 @@ export const useAgentFilesEditor = (params: {
|
||||
nextState[file.name] = {
|
||||
content: file.content ?? "",
|
||||
exists: Boolean(file.exists),
|
||||
path: file.path ?? null,
|
||||
workspace: file.workspace ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,6 +136,8 @@ export const useAgentFilesEditor = (params: {
|
||||
nextState[name] = {
|
||||
content: agentFiles[name].content,
|
||||
exists: true,
|
||||
path: agentFiles[name].path,
|
||||
workspace: agentFiles[name].workspace,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,6 +154,55 @@ export const useAgentFilesEditor = (params: {
|
||||
}
|
||||
}, [agentFiles, agentId, client]);
|
||||
|
||||
const initializeAgentFiles = useCallback(
|
||||
async (files: Partial<Record<AgentFileName, string>>) => {
|
||||
setAgentFilesSaving(true);
|
||||
setAgentFilesError(null);
|
||||
|
||||
try {
|
||||
const trimmedAgentId = agentId?.trim();
|
||||
if (!trimmedAgentId) {
|
||||
setAgentFilesError("Agent ID is missing for this agent.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
setAgentFilesError("Gateway client is not available.");
|
||||
return false;
|
||||
}
|
||||
|
||||
await writeGatewayAgentFiles({
|
||||
client,
|
||||
agentId: trimmedAgentId,
|
||||
files,
|
||||
});
|
||||
|
||||
const nextState = cloneAgentFilesState(savedAgentFilesRef.current);
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
if (!isAgentFileName(name) || typeof content !== "string") continue;
|
||||
nextState[name] = {
|
||||
content,
|
||||
exists: true,
|
||||
path: nextState[name].path,
|
||||
workspace: nextState[name].workspace,
|
||||
};
|
||||
}
|
||||
|
||||
savedAgentFilesRef.current = nextState;
|
||||
setAgentFiles(nextState);
|
||||
setAgentFilesDirty(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to initialize agent files.";
|
||||
setAgentFilesError(message);
|
||||
return false;
|
||||
} finally {
|
||||
setAgentFilesSaving(false);
|
||||
}
|
||||
},
|
||||
[agentId, client, cloneAgentFilesState]
|
||||
);
|
||||
|
||||
const setAgentFileContent = useCallback((name: AgentFileName, value: string) => {
|
||||
if (!isAgentFileName(name)) return;
|
||||
|
||||
@@ -167,6 +231,7 @@ export const useAgentFilesEditor = (params: {
|
||||
agentFilesError,
|
||||
setAgentFileContent,
|
||||
saveAgentFiles,
|
||||
initializeAgentFiles,
|
||||
discardAgentFileChanges,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -63,6 +63,19 @@ type ExecApprovalsSnapshot = {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const parseIdentityNameFromContent = (content: string): string | null => {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (/^##\s+/.test(trimmed)) break;
|
||||
const normalized = trimmed.replace(/^[-*]\s*/, "");
|
||||
const match = /^name\s*:\s*(.+)$/i.exec(normalized);
|
||||
if (!match) continue;
|
||||
const value = match[1]?.trim().replace(/^[*_]+|[*_]+$/g, "").trim() ?? "";
|
||||
if (value) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveAgentsListFromHelloSnapshot = (snapshot: unknown): AgentsListResult | null => {
|
||||
if (!isRecord(snapshot)) return null;
|
||||
const health = isRecord(snapshot.health) ? snapshot.health : null;
|
||||
@@ -155,17 +168,61 @@ export async function hydrateAgentFleetFromGateway(params: {
|
||||
}
|
||||
}
|
||||
|
||||
let agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
|
||||
const helloSnapshotFallback = resolveAgentsListFromHelloSnapshot(
|
||||
params.client.getLastHello?.()?.snapshot
|
||||
);
|
||||
let agentsResult: AgentsListResult;
|
||||
try {
|
||||
agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
|
||||
} catch (err) {
|
||||
if (helloSnapshotFallback) {
|
||||
agentsResult = helloSnapshotFallback;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(agentsResult?.agents) || agentsResult.agents.length === 0) {
|
||||
const fallback = resolveAgentsListFromHelloSnapshot(params.client.getLastHello?.()?.snapshot);
|
||||
if (fallback) {
|
||||
agentsResult = fallback;
|
||||
if (helloSnapshotFallback) {
|
||||
agentsResult = helloSnapshotFallback;
|
||||
}
|
||||
}
|
||||
agentsResult = {
|
||||
...agentsResult,
|
||||
agents: agentsResult.agents.filter(
|
||||
(agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name)
|
||||
agents: await Promise.all(
|
||||
agentsResult.agents.map(async (agent) => {
|
||||
const identityName =
|
||||
typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||
if (identityName) {
|
||||
return agent;
|
||||
}
|
||||
try {
|
||||
const result = (await params.client.call("agents.files.get", {
|
||||
agentId: agent.id,
|
||||
name: "IDENTITY.md",
|
||||
})) as { file?: { missing?: unknown; content?: unknown } };
|
||||
const file = result?.file;
|
||||
const record =
|
||||
file && typeof file === "object" ? (file as Record<string, unknown>) : null;
|
||||
if (record?.missing === true || typeof record?.content !== "string") {
|
||||
return agent;
|
||||
}
|
||||
const recoveredName = parseIdentityNameFromContent(record.content);
|
||||
if (!recoveredName) {
|
||||
return agent;
|
||||
}
|
||||
return {
|
||||
...agent,
|
||||
identity: {
|
||||
...(agent.identity ?? {}),
|
||||
name: recoveredName,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return agent;
|
||||
}
|
||||
})
|
||||
).then((agents) =>
|
||||
agents.filter((agent) => !isTemporarySkillAgentName(agent.name ?? agent.identity?.name))
|
||||
),
|
||||
};
|
||||
const mainKey = agentsResult.mainKey?.trim() || "main";
|
||||
|
||||
@@ -23,6 +23,7 @@ type AgentsListResult = {
|
||||
agents: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
role?: string;
|
||||
identity?: {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
@@ -118,13 +119,23 @@ const normalizeExecAsk = (raw: string | null | undefined): ExecAsk | undefined =
|
||||
};
|
||||
|
||||
const resolveAgentName = (agent: AgentsListResult["agents"][number]) => {
|
||||
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||
if (fromList) return fromList;
|
||||
const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||
if (fromIdentity) return fromIdentity;
|
||||
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||
if (fromList) return fromList;
|
||||
return agent.id;
|
||||
};
|
||||
|
||||
const resolveRuntimeName = (agent: AgentsListResult["agents"][number]) => {
|
||||
const fromList = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||
return fromList || null;
|
||||
};
|
||||
|
||||
const resolveIdentityName = (agent: AgentsListResult["agents"][number]) => {
|
||||
const fromIdentity = typeof agent.identity?.name === "string" ? agent.identity.name.trim() : "";
|
||||
return fromIdentity || null;
|
||||
};
|
||||
|
||||
const resolveAgentAvatarUrl = (agent: AgentsListResult["agents"][number]) => {
|
||||
const candidate = agent.identity?.avatarUrl ?? agent.identity?.avatar ?? null;
|
||||
if (typeof candidate !== "string") return null;
|
||||
@@ -214,7 +225,11 @@ export const deriveHydrateAgentFleetResult = (
|
||||
const avatarSeed = persistedSeed ?? avatarProfile.seed ?? agent.id;
|
||||
const avatarUrl = resolveAgentAvatarUrl(agent);
|
||||
const name = resolveAgentName(agent);
|
||||
const runtimeName = resolveRuntimeName(agent);
|
||||
const identityName = resolveIdentityName(agent);
|
||||
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
|
||||
const sessionDisplayName =
|
||||
typeof mainSession?.displayName === "string" ? mainSession.displayName.trim() || null : null;
|
||||
const modelProvider =
|
||||
typeof mainSession?.modelProvider === "string" ? mainSession.modelProvider.trim() : "";
|
||||
const modelId = typeof mainSession?.model === "string" ? mainSession.model.trim() : "";
|
||||
@@ -251,6 +266,10 @@ export const deriveHydrateAgentFleetResult = (
|
||||
return {
|
||||
agentId: agent.id,
|
||||
name,
|
||||
runtimeName,
|
||||
identityName,
|
||||
sessionDisplayName,
|
||||
role: typeof agent.role === "string" && agent.role.trim() ? agent.role.trim() : null,
|
||||
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
||||
avatarSeed,
|
||||
avatarProfile,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
isWebchatSessionMutationBlockedError,
|
||||
syncGatewaySessionSettings,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
readGatewayAgentExecApprovals,
|
||||
upsertGatewayAgentExecApprovals,
|
||||
@@ -318,6 +321,32 @@ const upsertExecApprovalsPolicyForRole = async (params: {
|
||||
});
|
||||
};
|
||||
|
||||
const syncExecutionRoleSessionSettings = async (params: {
|
||||
client: GatewayClient;
|
||||
sessionKey: string;
|
||||
role: ExecutionRoleId;
|
||||
sandboxMode?: string | null;
|
||||
}) => {
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role: params.role,
|
||||
sandboxMode: params.sandboxMode ?? "",
|
||||
});
|
||||
try {
|
||||
await syncGatewaySessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isWebchatSessionMutationBlockedError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export async function updateAgentPermissionsViaStudio(params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
@@ -354,16 +383,11 @@ export async function updateAgentPermissionsViaStudio(params: {
|
||||
overrides: toolOverrides,
|
||||
});
|
||||
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
await syncGatewaySessionSettings({
|
||||
await syncExecutionRoleSessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
|
||||
if (params.loadAgents) {
|
||||
@@ -403,16 +427,11 @@ export async function updateExecutionRoleViaStudio(params: {
|
||||
overrides: toolOverrides,
|
||||
});
|
||||
|
||||
const execSettings = resolveSessionExecSettingsForRole({
|
||||
role: params.role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
await syncGatewaySessionSettings({
|
||||
await syncExecutionRoleSessionSettings({
|
||||
client: params.client,
|
||||
sessionKey: params.sessionKey,
|
||||
execHost: execSettings.execHost,
|
||||
execSecurity: execSettings.execSecurity,
|
||||
execAsk: execSettings.execAsk,
|
||||
role: params.role,
|
||||
sandboxMode: runtimeConfigContext.sandboxMode,
|
||||
});
|
||||
|
||||
await params.loadAgents();
|
||||
|
||||
@@ -22,6 +22,25 @@ type GatewayClientLike = {
|
||||
call: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const extractImmediateAssistantText = (payload: unknown): string | null => {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const value = payload as {
|
||||
text?: unknown;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
if (typeof value.text === "string" && value.text.trim()) {
|
||||
return value.text.trim();
|
||||
}
|
||||
if (typeof value.content === "string" && value.content.trim()) {
|
||||
return value.content.trim();
|
||||
}
|
||||
if (typeof value.message === "string" && value.message.trim()) {
|
||||
return value.message.trim();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveLatestTranscriptTimestampMs = (agent: AgentState): number | null => {
|
||||
const entries = agent.transcriptEntries;
|
||||
let latest: number | null = null;
|
||||
@@ -200,6 +219,33 @@ export async function sendChatMessageViaStudio(params: {
|
||||
}
|
||||
|
||||
if (resolveChatSendCompletionMode(sendResult, runId) === "terminal-immediate") {
|
||||
const assistantText = extractImmediateAssistantText(sendResult);
|
||||
if (assistantText) {
|
||||
const assistantTimestamp = now();
|
||||
params.dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: assistantText,
|
||||
transcript: {
|
||||
source: "local-send",
|
||||
runId,
|
||||
sessionKey: params.sessionKey,
|
||||
timestampMs: assistantTimestamp,
|
||||
role: "assistant",
|
||||
kind: "assistant",
|
||||
},
|
||||
});
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
lastResult: assistantText,
|
||||
latestPreview: assistantText,
|
||||
lastAssistantMessageAt: assistantTimestamp,
|
||||
lastActivityAt: assistantTimestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SettingsRouteTab } from "@/features/agents/operations/settingsRouteWorkflow";
|
||||
|
||||
export type SettingsSidebarEntry = {
|
||||
id: SettingsRouteTab;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const BASE_SETTINGS_SIDEBAR_ENTRIES: readonly SettingsSidebarEntry[] = [
|
||||
{ id: "personality", label: "Behavior" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "system", label: "System setup" },
|
||||
{ id: "automations", label: "Automations" },
|
||||
{ id: "advanced", label: "Advanced" },
|
||||
];
|
||||
|
||||
export const resolveSettingsSidebarEntries = (runtimeSupportsCron: boolean) =>
|
||||
BASE_SETTINGS_SIDEBAR_ENTRIES.filter(
|
||||
(entry) => runtimeSupportsCron || entry.id !== "automations"
|
||||
);
|
||||
@@ -54,6 +54,7 @@ type AgentForSettingsMutation = Pick<AgentState, "agentId" | "name" | "sessionKe
|
||||
export type UseAgentSettingsMutationControllerParams = {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
runtimeSupportsCron: boolean;
|
||||
isLocalGateway: boolean;
|
||||
agents: AgentForSettingsMutation[];
|
||||
hasCreateBlock: boolean;
|
||||
@@ -99,6 +100,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
useState<RestartingMutationBlockState | null>(null);
|
||||
const REMOTE_MUTATION_EXEC_TIMEOUT_MS = 45_000;
|
||||
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
|
||||
const CRON_UNSUPPORTED_MESSAGE = "This runtime does not support automations.";
|
||||
|
||||
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
|
||||
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
|
||||
@@ -218,6 +220,12 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
|
||||
const loadCronJobsForSettingsAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (!params.runtimeSupportsCron) {
|
||||
setSettingsCronJobs([]);
|
||||
setSettingsCronLoading(false);
|
||||
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const resolvedAgentId = agentId.trim();
|
||||
if (!resolvedAgentId) {
|
||||
setSettingsCronJobs([]);
|
||||
@@ -241,7 +249,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
setSettingsCronLoading(false);
|
||||
}
|
||||
},
|
||||
[params.client]
|
||||
[params.client, params.runtimeSupportsCron]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -466,6 +474,10 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
|
||||
const handleCreateCronJob = useCallback(
|
||||
async (agentId: string, draft: CronCreateDraft) => {
|
||||
if (!params.runtimeSupportsCron) {
|
||||
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const decision = planAgentSettingsMutation(
|
||||
{ kind: "create-cron-job", agentId },
|
||||
mutationContext
|
||||
@@ -499,11 +511,22 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, mutationContext, params.client]
|
||||
[
|
||||
cronCreateBusy,
|
||||
cronDeleteBusyJobId,
|
||||
cronRunBusyJobId,
|
||||
mutationContext,
|
||||
params.client,
|
||||
params.runtimeSupportsCron,
|
||||
]
|
||||
);
|
||||
|
||||
const handleRunCronJob = useCallback(
|
||||
async (agentId: string, jobId: string) => {
|
||||
if (!params.runtimeSupportsCron) {
|
||||
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const decision = planAgentSettingsMutation(
|
||||
{ kind: "run-cron-job", agentId, jobId },
|
||||
mutationContext
|
||||
@@ -530,11 +553,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current));
|
||||
}
|
||||
},
|
||||
[loadCronJobsForSettingsAgent, mutationContext, params.client]
|
||||
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.runtimeSupportsCron]
|
||||
);
|
||||
|
||||
const handleDeleteCronJob = useCallback(
|
||||
async (agentId: string, jobId: string) => {
|
||||
if (!params.runtimeSupportsCron) {
|
||||
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const decision = planAgentSettingsMutation(
|
||||
{ kind: "delete-cron-job", agentId, jobId },
|
||||
mutationContext
|
||||
@@ -564,7 +591,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
|
||||
setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current));
|
||||
}
|
||||
},
|
||||
[loadCronJobsForSettingsAgent, mutationContext, params.client]
|
||||
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.runtimeSupportsCron]
|
||||
);
|
||||
|
||||
const handleRenameAgent = useCallback(
|
||||
|
||||
@@ -21,6 +21,7 @@ const defaultLogError = (message: string, err: unknown) => {
|
||||
export type UseGatewayConfigSyncControllerParams = {
|
||||
client: GatewayClient;
|
||||
status: GatewayConnectionStatus;
|
||||
enabled?: boolean;
|
||||
settingsRouteActive: boolean;
|
||||
inspectSidebarAgentId: string | null;
|
||||
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
|
||||
@@ -49,6 +50,7 @@ export function useGatewayConfigSyncController(
|
||||
const {
|
||||
client,
|
||||
status,
|
||||
enabled = true,
|
||||
settingsRouteActive,
|
||||
inspectSidebarAgentId,
|
||||
gatewayConfigSnapshot,
|
||||
@@ -63,6 +65,7 @@ export function useGatewayConfigSyncController(
|
||||
const logError = params.logError ?? defaultLogError;
|
||||
|
||||
const refreshGatewayConfigSnapshot = useCallback(async () => {
|
||||
if (!enabled) return null;
|
||||
if (status !== "connected") return null;
|
||||
try {
|
||||
const snapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
|
||||
@@ -74,9 +77,17 @@ export function useGatewayConfigSyncController(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [client, isDisconnectLikeError, setGatewayConfigSnapshot, status, logError]);
|
||||
}, [client, enabled, isDisconnectLikeError, setGatewayConfigSnapshot, status, logError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return;
|
||||
setGatewayModels([]);
|
||||
setGatewayModelsError(null);
|
||||
setGatewayConfigSnapshot(null);
|
||||
}, [enabled, setGatewayConfigSnapshot, setGatewayModels, setGatewayModelsError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const repairIntent = resolveSandboxRepairIntent({
|
||||
status,
|
||||
attempted: sandboxRepairAttemptedRef.current,
|
||||
@@ -107,9 +118,10 @@ export function useGatewayConfigSyncController(
|
||||
await loadAgents();
|
||||
},
|
||||
});
|
||||
}, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
|
||||
}, [client, enabled, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (
|
||||
!shouldRefreshGatewayConfigForSettingsRoute({
|
||||
status,
|
||||
@@ -120,9 +132,12 @@ export function useGatewayConfigSyncController(
|
||||
return;
|
||||
}
|
||||
void refreshGatewayConfigSnapshot();
|
||||
}, [inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]);
|
||||
}, [enabled, inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const syncIntent = resolveGatewayModelsSyncIntent({ status });
|
||||
if (syncIntent.kind === "clear") {
|
||||
setGatewayModels([]);
|
||||
@@ -175,6 +190,7 @@ export function useGatewayConfigSyncController(
|
||||
setGatewayConfigSnapshot,
|
||||
setGatewayModels,
|
||||
setGatewayModelsError,
|
||||
enabled,
|
||||
status,
|
||||
logError,
|
||||
]);
|
||||
|
||||
@@ -17,9 +17,7 @@ import { EmptyStatePanel } from "@/features/agents/components/EmptyStatePanel";
|
||||
import {
|
||||
isHeartbeatPrompt,
|
||||
} from "@/lib/text/message-extract";
|
||||
import {
|
||||
useGatewayConnection,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import { useRuntimeConnection } from "@/lib/runtime/useRuntimeConnection";
|
||||
import {
|
||||
type GatewayModelChoice,
|
||||
type GatewayModelPolicySnapshot,
|
||||
@@ -111,6 +109,7 @@ import {
|
||||
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
|
||||
import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController";
|
||||
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
|
||||
import { resolveSettingsSidebarEntries } from "@/features/agents/operations/settingsSidebarTabs";
|
||||
import {
|
||||
SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM,
|
||||
parseSettingsRouteAgentIdFromQueryParam,
|
||||
@@ -221,11 +220,14 @@ const AgentsPageScreen = () => {
|
||||
const [settingsCoordinator] = useState(() => createStudioSettingsCoordinator());
|
||||
const {
|
||||
client,
|
||||
provider,
|
||||
status,
|
||||
connectPromptReady,
|
||||
shouldPromptForConnect,
|
||||
gatewayUrl,
|
||||
token,
|
||||
selectedAdapterType,
|
||||
activeAdapterType,
|
||||
localGatewayDefaults,
|
||||
error: gatewayError,
|
||||
connect,
|
||||
@@ -233,7 +235,12 @@ const AgentsPageScreen = () => {
|
||||
useLocalGatewayDefaults,
|
||||
setGatewayUrl,
|
||||
setToken,
|
||||
} = useGatewayConnection(settingsCoordinator);
|
||||
setSelectedAdapterType,
|
||||
supportsCapability,
|
||||
} = useRuntimeConnection(settingsCoordinator);
|
||||
const runtimeSupportsConfig = supportsCapability("config");
|
||||
const runtimeSupportsModels = supportsCapability("models");
|
||||
const runtimeSupportsCron = supportsCapability("cron");
|
||||
const {
|
||||
loaded: voiceRepliesLoaded,
|
||||
preference: voiceRepliesPreference,
|
||||
@@ -443,6 +450,10 @@ const AgentsPageScreen = () => {
|
||||
const settingsHeaderThinking =
|
||||
settingsHeaderThinkingRaw.charAt(0).toUpperCase() + settingsHeaderThinkingRaw.slice(1);
|
||||
const activeSettingsSidebarItem: SettingsSidebarItem = settingsSidebarItem;
|
||||
const settingsSidebarEntries = useMemo(
|
||||
() => resolveSettingsSidebarEntries(runtimeSupportsCron),
|
||||
[runtimeSupportsCron]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const selector = 'link[data-agent-favicon="true"]';
|
||||
@@ -472,7 +483,10 @@ const AgentsPageScreen = () => {
|
||||
const specialLatestUpdate = useMemo(() => {
|
||||
return createSpecialLatestUpdateOperation({
|
||||
callGateway: (method, params) => client.call(method, params),
|
||||
listCronJobs: () => listCronJobs(client, { includeDisabled: true }),
|
||||
listCronJobs: () =>
|
||||
runtimeSupportsCron
|
||||
? listCronJobs(client, { includeDisabled: true })
|
||||
: Promise.resolve({ jobs: [] }),
|
||||
resolveCronJobForAgent,
|
||||
formatCronJobDisplay,
|
||||
dispatchUpdateAgent: (agentId, patch) => {
|
||||
@@ -481,7 +495,7 @@ const AgentsPageScreen = () => {
|
||||
isDisconnectLikeError: isGatewayDisconnectLikeError,
|
||||
logError: (message) => console.error(message),
|
||||
});
|
||||
}, [client, dispatch, resolveCronJobForAgent]);
|
||||
}, [client, dispatch, resolveCronJobForAgent, runtimeSupportsCron]);
|
||||
|
||||
const refreshHeartbeatLatestUpdate = useCallback(() => {
|
||||
const agents = stateRef.current.agents;
|
||||
@@ -503,7 +517,7 @@ const AgentsPageScreen = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const commands = await runStudioBootstrapLoadOperation({
|
||||
client,
|
||||
client: provider,
|
||||
gatewayUrl,
|
||||
cachedConfigSnapshot: gatewayConfigSnapshot,
|
||||
loadStudioSettings,
|
||||
@@ -527,6 +541,7 @@ const AgentsPageScreen = () => {
|
||||
}
|
||||
}, [
|
||||
client,
|
||||
provider,
|
||||
dispatch,
|
||||
hydrateAgents,
|
||||
setError,
|
||||
@@ -547,6 +562,7 @@ const AgentsPageScreen = () => {
|
||||
const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({
|
||||
client,
|
||||
status,
|
||||
enabled: runtimeSupportsConfig && runtimeSupportsModels,
|
||||
settingsRouteActive,
|
||||
inspectSidebarAgentId,
|
||||
gatewayConfigSnapshot,
|
||||
@@ -561,6 +577,7 @@ const AgentsPageScreen = () => {
|
||||
const settingsMutationController = useAgentSettingsMutationController({
|
||||
client,
|
||||
status,
|
||||
runtimeSupportsCron,
|
||||
isLocalGateway,
|
||||
agents,
|
||||
hasCreateBlock: Boolean(createAgentBlock),
|
||||
@@ -792,7 +809,7 @@ const AgentsPageScreen = () => {
|
||||
loadMoreAgentHistory,
|
||||
clearHistoryInFlight,
|
||||
} = useRuntimeSyncController({
|
||||
client,
|
||||
client: provider,
|
||||
status,
|
||||
agents,
|
||||
focusedAgentId,
|
||||
@@ -815,7 +832,7 @@ const AgentsPageScreen = () => {
|
||||
queueLivePatch,
|
||||
clearPendingLivePatch,
|
||||
} = useChatInteractionController({
|
||||
client,
|
||||
client: provider,
|
||||
status,
|
||||
agents,
|
||||
dispatch,
|
||||
@@ -1396,10 +1413,15 @@ const AgentsPageScreen = () => {
|
||||
<ConnectionPanel
|
||||
gatewayUrl={gatewayUrl}
|
||||
token={token}
|
||||
selectedAdapterType={selectedAdapterType}
|
||||
activeAdapterType={activeAdapterType}
|
||||
localGatewayUrl={localGatewayDefaults?.url ?? null}
|
||||
localGatewayToken={localGatewayDefaults?.token ?? null}
|
||||
status={status}
|
||||
error={gatewayError}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onAdapterTypeChange={setSelectedAdapterType}
|
||||
onConnect={() => void connect()}
|
||||
onDisconnect={disconnect}
|
||||
onClose={() => setShowConnectionPanel(false)}
|
||||
@@ -1410,12 +1432,15 @@ const AgentsPageScreen = () => {
|
||||
<GatewayConnectScreen
|
||||
gatewayUrl={gatewayUrl}
|
||||
token={token}
|
||||
selectedAdapterType={selectedAdapterType}
|
||||
activeAdapterType={activeAdapterType}
|
||||
localGatewayDefaults={localGatewayDefaults}
|
||||
status={status}
|
||||
error={gatewayError}
|
||||
showApprovalHint={didAttemptGatewayConnect}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onAdapterTypeChange={setSelectedAdapterType}
|
||||
onUseLocalDefaults={useLocalGatewayDefaults}
|
||||
onConnect={() => void connect()}
|
||||
/>
|
||||
@@ -1462,10 +1487,15 @@ const AgentsPageScreen = () => {
|
||||
<ConnectionPanel
|
||||
gatewayUrl={gatewayUrl}
|
||||
token={token}
|
||||
selectedAdapterType={selectedAdapterType}
|
||||
activeAdapterType={activeAdapterType}
|
||||
localGatewayUrl={localGatewayDefaults?.url ?? null}
|
||||
localGatewayToken={localGatewayDefaults?.token ?? null}
|
||||
status={status}
|
||||
error={gatewayError}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onAdapterTypeChange={setSelectedAdapterType}
|
||||
onConnect={() => void connect()}
|
||||
onDisconnect={disconnect}
|
||||
onClose={() => setShowConnectionPanel(false)}
|
||||
@@ -1505,16 +1535,7 @@ const AgentsPageScreen = () => {
|
||||
</button>
|
||||
</div>
|
||||
<nav className="py-3">
|
||||
{(
|
||||
[
|
||||
{ id: "personality", label: "Behavior" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "system", label: "System setup" },
|
||||
{ id: "automations", label: "Automations" },
|
||||
{ id: "advanced", label: "Advanced" },
|
||||
] as const
|
||||
).map((entry) => {
|
||||
{settingsSidebarEntries.map((entry) => {
|
||||
const active = activeSettingsSidebarItem === entry.id;
|
||||
return (
|
||||
<button
|
||||
@@ -1670,7 +1691,8 @@ const AgentsPageScreen = () => {
|
||||
onDeleteCronJob={(jobId) =>
|
||||
settingsMutationController.handleDeleteCronJob(inspectSidebarAgent.agentId, jobId)
|
||||
}
|
||||
controlUiUrl={controlUiUrl}
|
||||
controlUiUrl={selectedAdapterType === "openclaw" ? controlUiUrl : null}
|
||||
adapterType={selectedAdapterType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,10 @@ export type FocusFilter = "all" | "running" | "approvals";
|
||||
export type AgentStoreSeed = {
|
||||
agentId: string;
|
||||
name: string;
|
||||
runtimeName?: string | null;
|
||||
identityName?: string | null;
|
||||
sessionDisplayName?: string | null;
|
||||
role?: string | null;
|
||||
sessionKey: string;
|
||||
avatarSeed?: string | null;
|
||||
avatarProfile?: AgentAvatarProfile | null;
|
||||
|
||||
@@ -194,7 +194,7 @@ export function CompanyBuilderModal({
|
||||
</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">Design an AI company from one prompt</h2>
|
||||
<p className="mt-1 text-sm text-white/55">
|
||||
Uses your connected OpenClaw runtime
|
||||
Uses your connected runtime
|
||||
{plannerAgentName ? ` via ${plannerAgentName}.` : "."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -329,7 +329,7 @@ export function CompanyBuilderModal({
|
||||
Company Actions
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-white/45">
|
||||
Generate the org, then create it in OpenClaw.
|
||||
Generate the org, then create it in your connected runtime.
|
||||
</p>
|
||||
</div>
|
||||
{replacesExistingAgents ? (
|
||||
@@ -341,7 +341,7 @@ export function CompanyBuilderModal({
|
||||
) : null}
|
||||
{!canUseAi ? (
|
||||
<p className="text-xs text-amber-200/80">
|
||||
Connect to OpenClaw and keep at least one available planning agent in the fleet
|
||||
Connect to a runtime and keep at least one available planning agent in the fleet
|
||||
to use AI suggestions.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -416,7 +416,7 @@ export function CompanyBuilderModal({
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Org structure</p>
|
||||
<p className="text-xs text-white/55">
|
||||
Edit the team before creating agents in OpenClaw.
|
||||
Edit the team before creating agents in your connected runtime.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -704,7 +704,7 @@ export function CompanyBuilderModal({
|
||||
{statusLine?.trim() || "Working on your company."}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-white/55">
|
||||
Claw3D is using your OpenClaw runtime right now. Please wait until this finishes.
|
||||
Claw3D is using your connected runtime right now. Please wait until this finishes.
|
||||
</p>
|
||||
<div className="mt-5 flex gap-2">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
@@ -897,7 +897,8 @@ export function CompanyBuilderModal({
|
||||
What should the company do?
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-white/55">
|
||||
As soon as you submit this, OpenClaw will improve the brief automatically.
|
||||
As soon as you submit this, Claw3D will improve the brief using your connected
|
||||
runtime.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -923,9 +924,12 @@ export function CompanyBuilderModal({
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-white/45">
|
||||
The improved brief becomes the main editable input for generation.
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-xs text-white/45">
|
||||
The improved brief becomes the main editable input for generation.
|
||||
</p>
|
||||
{error ? <p className="mt-2 text-xs text-red-200">{error}</p> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
|
||||
@@ -311,7 +311,7 @@ const normalizeRole = (value: ParsedCompanyRole, index: number): CompanyBuilderR
|
||||
export const buildImproveCompanyBriefPrompt = (businessDescription: string) =>
|
||||
[
|
||||
"You are helping a user describe the company they want to build inside Claw3D.",
|
||||
"Rewrite their brief so another OpenClaw agent can generate a clean org structure from it.",
|
||||
"Rewrite their brief so another connected runtime agent can generate a clean org structure from it.",
|
||||
"Keep the answer short, concrete, and useful.",
|
||||
"Return markdown with these sections only:",
|
||||
"## Company",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
export type HQSidebarTab =
|
||||
| "inbox"
|
||||
| "history"
|
||||
| "kanban"
|
||||
| "playbooks"
|
||||
| "analytics";
|
||||
|
||||
@@ -19,6 +20,7 @@ type HQSidebarProps = {
|
||||
onOpenCompanyBuilder?: () => void;
|
||||
inboxPanel: ReactNode;
|
||||
historyPanel: ReactNode;
|
||||
kanbanPanel: ReactNode;
|
||||
playbooksPanel: ReactNode;
|
||||
analyticsPanel: ReactNode;
|
||||
};
|
||||
@@ -26,11 +28,12 @@ type HQSidebarProps = {
|
||||
const TAB_LABELS: Record<HQSidebarTab, string> = {
|
||||
inbox: "Inbox",
|
||||
history: "History",
|
||||
kanban: "Kanban",
|
||||
playbooks: "Playbooks",
|
||||
analytics: "Analytics",
|
||||
};
|
||||
|
||||
const PRIMARY_TABS: HQSidebarTab[] = ["inbox", "history", "playbooks"];
|
||||
const PRIMARY_TABS: HQSidebarTab[] = ["inbox", "history", "kanban", "playbooks"];
|
||||
|
||||
export function HQSidebar({
|
||||
open,
|
||||
@@ -43,6 +46,7 @@ export function HQSidebar({
|
||||
onOpenCompanyBuilder,
|
||||
inboxPanel,
|
||||
historyPanel,
|
||||
kanbanPanel,
|
||||
playbooksPanel,
|
||||
analyticsPanel,
|
||||
}: HQSidebarProps) {
|
||||
@@ -53,9 +57,12 @@ export function HQSidebar({
|
||||
? inboxPanel
|
||||
: activeTab === "history"
|
||||
? historyPanel
|
||||
: activeTab === "kanban"
|
||||
? kanbanPanel
|
||||
: activeTab === "playbooks"
|
||||
? playbooksPanel
|
||||
: analyticsPanel;
|
||||
const boardLikeWidth = activeTab === "kanban";
|
||||
|
||||
return (
|
||||
<aside className="pointer-events-none fixed inset-y-0 right-0 z-20 flex justify-end">
|
||||
@@ -108,7 +115,11 @@ export function HQSidebar({
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="pointer-events-auto flex h-full w-56 flex-col border-l border-cyan-500/20 bg-black/85 shadow-2xl backdrop-blur">
|
||||
<div
|
||||
className={`pointer-events-auto flex h-full flex-col border-l border-cyan-500/20 bg-black/85 shadow-2xl backdrop-blur ${
|
||||
boardLikeWidth ? "w-[min(94vw,1180px)]" : "w-56"
|
||||
}`}
|
||||
>
|
||||
<div className="border-b border-cyan-500/15 px-4 py-3">
|
||||
<div className="font-mono text-[10px] font-semibold tracking-[0.32em] text-cyan-300/80">
|
||||
{analyticsOnly ? "ANALYTICS" : "HEADQUARTERS"}
|
||||
@@ -151,7 +162,7 @@ export function HQSidebar({
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Headquarters panels"
|
||||
className="grid grid-cols-3 border-b border-cyan-500/15"
|
||||
className="grid grid-cols-4 border-b border-cyan-500/15"
|
||||
>
|
||||
{PRIMARY_TABS.map((tab) => {
|
||||
const isActive = tab === activeTab;
|
||||
|
||||
@@ -111,6 +111,7 @@ const DatePickerField = ({
|
||||
export function AnalyticsPanel({
|
||||
client,
|
||||
status,
|
||||
approvalsEnabled = true,
|
||||
agents,
|
||||
runLog,
|
||||
gatewayUrl,
|
||||
@@ -119,6 +120,7 @@ export function AnalyticsPanel({
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
approvalsEnabled?: boolean;
|
||||
agents: AgentState[];
|
||||
runLog: RunRecord[];
|
||||
gatewayUrl: string;
|
||||
@@ -142,7 +144,12 @@ export function AnalyticsPanel({
|
||||
settingsCoordinator,
|
||||
});
|
||||
|
||||
const approvalMetrics = useApprovalMetrics({ client, status, agents });
|
||||
const approvalMetrics = useApprovalMetrics({
|
||||
client,
|
||||
status,
|
||||
enabled: approvalsEnabled,
|
||||
agents,
|
||||
});
|
||||
const performance = usePerformanceAnalytics({
|
||||
agents,
|
||||
runLog,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
type KanbanDisabledPanelProps = {
|
||||
onClose: () => void;
|
||||
onInstall: () => void;
|
||||
installing?: boolean;
|
||||
progressPercent?: number;
|
||||
progressMessage?: string | null;
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
export function KanbanDisabledPanel({
|
||||
onClose,
|
||||
onInstall,
|
||||
installing = false,
|
||||
progressPercent = 0,
|
||||
progressMessage = null,
|
||||
errorMessage = null,
|
||||
}: KanbanDisabledPanelProps) {
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
|
||||
<div className="w-full max-w-sm rounded-3xl border border-slate-700/40 bg-slate-950/95 p-8 text-center shadow-2xl">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border border-slate-700/40 bg-slate-800/60 px-2 text-center text-sm font-semibold uppercase tracking-[0.12em] text-slate-200">
|
||||
Kanban
|
||||
</div>
|
||||
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.24em] text-slate-500">
|
||||
Task Manager
|
||||
</div>
|
||||
<h2 className="mt-1 text-xl font-semibold text-white">Kanban Skill Not Installed</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-400">
|
||||
Install the <span className="text-cyan-400">TASK-MANAGER</span> skill to let your
|
||||
agents capture work as tasks and open the Kanban desk.
|
||||
</p>
|
||||
|
||||
{installing ? (
|
||||
<div className="mt-5 rounded-2xl border border-cyan-500/20 bg-cyan-500/5 p-4 text-left">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-cyan-300/80">
|
||||
Installing
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-cyan-100/70">
|
||||
{Math.max(0, Math.min(100, Math.round(progressPercent)))}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-800/90">
|
||||
<div
|
||||
className="h-full rounded-full bg-cyan-400 transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-300">
|
||||
{progressMessage?.trim() || "Installing the task-manager skill."}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-slate-500">
|
||||
Once it's installed, Claw3D will refresh the task-manager state.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-500/20 bg-rose-500/8 px-4 py-3 text-sm text-rose-200">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-cyan-500 px-5 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-cyan-400 active:scale-95 disabled:cursor-not-allowed disabled:bg-cyan-700/60 disabled:text-slate-200"
|
||||
onClick={onInstall}
|
||||
disabled={installing}
|
||||
>
|
||||
{installing ? "Installing TASK-MANAGER skill..." : "Install TASK-MANAGER skill"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-slate-700/40 px-5 py-2.5 text-sm text-slate-400 transition hover:bg-slate-800/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={onClose}
|
||||
disabled={installing}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -140,11 +140,13 @@ const formatRelativeDateTime = (timestampMs?: number) => {
|
||||
export function PlaybooksPanel({
|
||||
client,
|
||||
status,
|
||||
cronEnabled = true,
|
||||
agents,
|
||||
standup,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
cronEnabled?: boolean;
|
||||
agents: AgentState[];
|
||||
standup: OfficeStandupController;
|
||||
}) {
|
||||
@@ -217,8 +219,10 @@ export function PlaybooksPanel({
|
||||
}, [standup.config, standupAgentId]);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
if (status !== "connected") {
|
||||
if (!cronEnabled || status !== "connected") {
|
||||
setJobs([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -235,13 +239,17 @@ export function PlaybooksPanel({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, status]);
|
||||
}, [client, cronEnabled, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!cronEnabled) {
|
||||
setError("This runtime does not expose scheduled playbooks.");
|
||||
return;
|
||||
}
|
||||
if (!activeTemplate) return;
|
||||
const agent = agentById.get(selectedAgentId);
|
||||
if (!agent) {
|
||||
@@ -265,10 +273,14 @@ export function PlaybooksPanel({
|
||||
} finally {
|
||||
setCreateBusy(false);
|
||||
}
|
||||
}, [activeTemplate, agentById, client, loadJobs, nameOverride, selectedAgentId]);
|
||||
}, [activeTemplate, agentById, client, cronEnabled, loadJobs, nameOverride, selectedAgentId]);
|
||||
|
||||
const handleRunNow = useCallback(
|
||||
async (jobId: string) => {
|
||||
if (!cronEnabled) {
|
||||
setError("This runtime does not expose scheduled playbooks.");
|
||||
return;
|
||||
}
|
||||
setRunBusyJobId(jobId);
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
@@ -282,11 +294,15 @@ export function PlaybooksPanel({
|
||||
setRunBusyJobId(null);
|
||||
}
|
||||
},
|
||||
[client, loadJobs]
|
||||
[client, cronEnabled, loadJobs]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (jobId: string) => {
|
||||
if (!cronEnabled) {
|
||||
setError("This runtime does not expose scheduled playbooks.");
|
||||
return;
|
||||
}
|
||||
setDeleteBusyJobId(jobId);
|
||||
setError(null);
|
||||
setActionMessage(null);
|
||||
@@ -300,7 +316,7 @@ export function PlaybooksPanel({
|
||||
setDeleteBusyJobId(null);
|
||||
}
|
||||
},
|
||||
[client, loadJobs]
|
||||
[client, cronEnabled, loadJobs]
|
||||
);
|
||||
|
||||
const handleSaveStandupConfig = useCallback(async () => {
|
||||
@@ -385,11 +401,17 @@ export function PlaybooksPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadJobs()}
|
||||
disabled={!cronEnabled}
|
||||
className="rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{!cronEnabled ? (
|
||||
<div className="mt-2 font-mono text-[11px] text-white/35">
|
||||
This runtime does not expose scheduled playbooks.
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div className="mt-2 font-mono text-[11px] text-rose-300">{error}</div> : null}
|
||||
{actionMessage ? (
|
||||
<div className="mt-2 font-mono text-[11px] text-emerald-300">{actionMessage}</div>
|
||||
|
||||
@@ -2,11 +2,19 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
|
||||
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
|
||||
|
||||
type SettingsPanelProps = {
|
||||
gatewayStatus?: string;
|
||||
gatewayUrl?: string;
|
||||
gatewayToken?: string;
|
||||
selectedAdapterType?: StudioGatewayAdapterType;
|
||||
activeAdapterType?: StudioGatewayAdapterType;
|
||||
onGatewayDisconnect?: () => void;
|
||||
onGatewayConnect?: () => void;
|
||||
onGatewayUrlChange?: (value: string) => void;
|
||||
onGatewayTokenChange?: (value: string) => void;
|
||||
onGatewayAdapterTypeChange?: (value: StudioGatewayAdapterType) => void;
|
||||
onOpenOnboarding?: () => void;
|
||||
officeTitle: string;
|
||||
officeTitleLoaded: boolean;
|
||||
@@ -36,7 +44,14 @@ type SettingsPanelProps = {
|
||||
export function SettingsPanel({
|
||||
gatewayStatus,
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
selectedAdapterType = "openclaw",
|
||||
activeAdapterType = "openclaw",
|
||||
onGatewayDisconnect,
|
||||
onGatewayConnect,
|
||||
onGatewayUrlChange,
|
||||
onGatewayTokenChange,
|
||||
onGatewayAdapterTypeChange,
|
||||
onOpenOnboarding,
|
||||
officeTitle,
|
||||
officeTitleLoaded,
|
||||
@@ -63,10 +78,17 @@ export function SettingsPanel({
|
||||
onVoiceRepliesPreview,
|
||||
}: SettingsPanelProps) {
|
||||
const normalizedGatewayUrl = gatewayUrl?.trim() ?? "";
|
||||
const normalizedGatewayToken = gatewayToken ?? "";
|
||||
const gatewayStateLabel = gatewayStatus
|
||||
? gatewayStatus.charAt(0).toUpperCase() + gatewayStatus.slice(1)
|
||||
: "Unknown";
|
||||
const gatewayDisconnectDisabled = gatewayStatus !== "connected";
|
||||
const isGatewayConnected = gatewayStatus === "connected";
|
||||
const gatewayDisconnectDisabled = !isGatewayConnected;
|
||||
const gatewayConnectDisabled = normalizedGatewayUrl.length === 0;
|
||||
const tokenOptional =
|
||||
selectedAdapterType === "hermes" ||
|
||||
selectedAdapterType === "demo" ||
|
||||
selectedAdapterType === "custom";
|
||||
const [remoteOfficeTokenDraft, setRemoteOfficeTokenDraft] = useState("");
|
||||
|
||||
return (
|
||||
@@ -101,28 +123,100 @@ export function SettingsPanel({
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-white">Gateway</div>
|
||||
<div className="mt-1 text-[10px] text-white/75">
|
||||
Current studio connection and endpoint details.
|
||||
Switch the active backend and update its saved endpoint details.
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
||||
{gatewayStateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[10px] text-cyan-100/80">
|
||||
{normalizedGatewayUrl || "No gateway URL configured."}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["demo", "Demo"],
|
||||
["hermes", "Hermes"],
|
||||
["custom", "Custom"],
|
||||
["openclaw", "OpenClaw"],
|
||||
] as const
|
||||
).map(([adapterType, label]) => {
|
||||
const selected = selectedAdapterType === adapterType;
|
||||
return (
|
||||
<button
|
||||
key={adapterType}
|
||||
type="button"
|
||||
onClick={() => onGatewayAdapterTypeChange?.(adapterType)}
|
||||
className={`rounded-md border px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] transition-colors ${
|
||||
selected
|
||||
? "border-cyan-400/35 bg-cyan-500/12 text-cyan-50"
|
||||
: "border-cyan-500/10 bg-black/20 text-white/75 hover:border-cyan-400/25 hover:text-cyan-50"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Upstream URL
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={gatewayUrl ?? ""}
|
||||
onChange={(event) => onGatewayUrlChange?.(event.target.value)}
|
||||
placeholder={
|
||||
selectedAdapterType === "custom"
|
||||
? "http://localhost:7770"
|
||||
: "ws://localhost:18789"
|
||||
}
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={normalizedGatewayToken}
|
||||
onChange={(event) => onGatewayTokenChange?.(event.target.value)}
|
||||
placeholder={tokenOptional ? "optional token" : "gateway token"}
|
||||
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[10px] text-white/60">
|
||||
<span className="font-mono">
|
||||
Selected backend: {selectedAdapterType}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
Active backend: {activeAdapterType}
|
||||
</span>
|
||||
<span>Each backend keeps its own saved URL and token.</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="text-[10px] text-white/60">
|
||||
Disconnecting returns you to the gateway connect screen.
|
||||
Connect to apply the selected backend, or disconnect to return to the connection screen.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGatewayConnect?.()}
|
||||
disabled={gatewayConnectDisabled}
|
||||
className="rounded-md border border-cyan-500/20 bg-cyan-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-50 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{gatewayStatus === "connecting" ? "Connecting..." : "Connect"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGatewayDisconnect?.()}
|
||||
disabled={gatewayDisconnectDisabled}
|
||||
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Disconnect gateway
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGatewayDisconnect?.()}
|
||||
disabled={gatewayDisconnectDisabled}
|
||||
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Disconnect gateway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||
|
||||
@@ -502,7 +502,12 @@ export function SkillsMarketplacePanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={primaryAction.run}
|
||||
disabled={marketplace.busySkillKey === entry.skill.skillKey}
|
||||
disabled={
|
||||
marketplace.busySkillKey === entry.skill.skillKey ||
|
||||
(packageOnly && !marketplace.selectedAgentId) ||
|
||||
(primaryAction.label === "Open settings" &&
|
||||
!marketplace.selectedAgentId)
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/25 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/40 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
<PrimaryIcon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { TaskBoardView } from "@/features/office/tasks/TaskBoardView";
|
||||
import type { TaskBoardCard, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
|
||||
export function TaskBoardPanel({
|
||||
agents,
|
||||
cardsByStatus,
|
||||
selectedCard,
|
||||
activeRuns,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
cronError,
|
||||
taskCaptureDebug,
|
||||
onCreateCard,
|
||||
onMoveCard,
|
||||
onSelectCard,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onRefreshCronJobs,
|
||||
}: {
|
||||
agents: AgentState[];
|
||||
cardsByStatus: Record<TaskBoardStatus, TaskBoardCard[]>;
|
||||
selectedCard: TaskBoardCard | null;
|
||||
activeRuns: Array<{ runId: string; agentId: string; label: string }>;
|
||||
cronJobs: CronJobSummary[];
|
||||
cronLoading: boolean;
|
||||
cronError: string | null;
|
||||
taskCaptureDebug?: ComponentProps<typeof TaskBoardView>["taskCaptureDebug"];
|
||||
onCreateCard: () => void;
|
||||
onMoveCard: (cardId: string, status: TaskBoardStatus) => void;
|
||||
onSelectCard: (cardId: string | null) => void;
|
||||
onUpdateCard: (cardId: string, patch: Partial<TaskBoardCard>) => void;
|
||||
onDeleteCard: (cardId: string) => void;
|
||||
onRefreshCronJobs: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TaskBoardView
|
||||
title="Kanban"
|
||||
subtitle="Manual tasks, inferred requests, and scheduled playbooks."
|
||||
agents={agents}
|
||||
cardsByStatus={cardsByStatus}
|
||||
selectedCard={selectedCard}
|
||||
activeRuns={activeRuns}
|
||||
cronJobs={cronJobs}
|
||||
cronLoading={cronLoading}
|
||||
cronError={cronError}
|
||||
taskCaptureDebug={taskCaptureDebug}
|
||||
onCreateCard={onCreateCard}
|
||||
onMoveCard={onMoveCard}
|
||||
onSelectCard={onSelectCard}
|
||||
onUpdateCard={onUpdateCard}
|
||||
onDeleteCard={onDeleteCard}
|
||||
onRefreshCronJobs={onRefreshCronJobs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -36,20 +36,24 @@ const MAX_APPROVAL_RECORDS = 300;
|
||||
export const useApprovalMetrics = ({
|
||||
client,
|
||||
status,
|
||||
enabled = true,
|
||||
agents,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
enabled?: boolean;
|
||||
agents: AgentState[];
|
||||
}) => {
|
||||
const [records, setRecords] = useState<ApprovalRecord[]>([]);
|
||||
const agentsRef = useRef(agents);
|
||||
const visibleRecords = useMemo(() => (enabled ? records : []), [enabled, records]);
|
||||
|
||||
useEffect(() => {
|
||||
agentsRef.current = agents;
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (status !== "connected") return;
|
||||
return client.onEvent((event) => {
|
||||
const requested = parseExecApprovalRequested(event);
|
||||
@@ -105,11 +109,11 @@ export const useApprovalMetrics = ({
|
||||
return [fallbackRecord, ...current].slice(0, MAX_APPROVAL_RECORDS);
|
||||
});
|
||||
});
|
||||
}, [client, status]);
|
||||
}, [client, enabled, status]);
|
||||
|
||||
const byAgent = useMemo(() => {
|
||||
const metrics = new Map<string, ApprovalAgentMetrics>();
|
||||
for (const record of records) {
|
||||
for (const record of visibleRecords) {
|
||||
const agentId = record.agentId?.trim() ?? "";
|
||||
if (!agentId) continue;
|
||||
const current = metrics.get(agentId) ?? {
|
||||
@@ -135,20 +139,20 @@ export const useApprovalMetrics = ({
|
||||
}
|
||||
return left.agentId.localeCompare(right.agentId);
|
||||
});
|
||||
}, [records]);
|
||||
}, [visibleRecords]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return {
|
||||
requestedCount: records.length,
|
||||
resolvedCount: records.filter((record) => record.decision !== null).length,
|
||||
deniedCount: records.filter((record) => record.decision === "deny").length,
|
||||
allowOnceCount: records.filter((record) => record.decision === "allow-once").length,
|
||||
allowAlwaysCount: records.filter((record) => record.decision === "allow-always").length,
|
||||
requestedCount: visibleRecords.length,
|
||||
resolvedCount: visibleRecords.filter((record) => record.decision !== null).length,
|
||||
deniedCount: visibleRecords.filter((record) => record.decision === "deny").length,
|
||||
allowOnceCount: visibleRecords.filter((record) => record.decision === "allow-once").length,
|
||||
allowAlwaysCount: visibleRecords.filter((record) => record.decision === "allow-always").length,
|
||||
};
|
||||
}, [records]);
|
||||
}, [visibleRecords]);
|
||||
|
||||
return {
|
||||
records,
|
||||
records: visibleRecords,
|
||||
byAgent,
|
||||
totals,
|
||||
};
|
||||
|
||||
@@ -35,10 +35,12 @@ const isSkillEnabledForAgent = (params: {
|
||||
export const useOfficeSkillTriggers = ({
|
||||
client,
|
||||
status,
|
||||
enabled = true,
|
||||
agents,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
enabled?: boolean;
|
||||
agents: AgentState[];
|
||||
}) => {
|
||||
const requestIdRef = useRef(0);
|
||||
@@ -55,7 +57,10 @@ export const useOfficeSkillTriggers = ({
|
||||
[agentIdsKey],
|
||||
);
|
||||
const shouldLoadTriggers =
|
||||
status === "connected" && stableAgentIds.length > 0 && packagedTriggers.length > 0;
|
||||
enabled &&
|
||||
status === "connected" &&
|
||||
stableAgentIds.length > 0 &&
|
||||
packagedTriggers.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoadTriggers) {
|
||||
|
||||
@@ -30,6 +30,7 @@ type MarketplaceMessage = {
|
||||
export const useOfficeSkillsMarketplace = ({
|
||||
client,
|
||||
status,
|
||||
enabled = true,
|
||||
agents,
|
||||
preferredAgentId,
|
||||
onSkillActivityStart,
|
||||
@@ -37,6 +38,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
enabled?: boolean;
|
||||
agents: AgentState[];
|
||||
preferredAgentId?: string | null;
|
||||
onSkillActivityStart?: (agentId: string) => void;
|
||||
@@ -88,7 +90,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
const loadMarketplace = useCallback(
|
||||
async (agentId: string) => {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
if (!resolvedAgentId || status !== "connected") {
|
||||
if (!enabled || !resolvedAgentId || status !== "connected") {
|
||||
setSkillsReport(null);
|
||||
setSkillsAllowlist(undefined);
|
||||
setLoading(false);
|
||||
@@ -132,11 +134,11 @@ export const useOfficeSkillsMarketplace = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, status],
|
||||
[client, enabled, status],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAgentId || status !== "connected") {
|
||||
if (!enabled || !selectedAgentId || status !== "connected") {
|
||||
requestIdRef.current += 1;
|
||||
setSkillsReport(null);
|
||||
setSkillsAllowlist(undefined);
|
||||
@@ -144,14 +146,15 @@ export const useOfficeSkillsMarketplace = ({
|
||||
return;
|
||||
}
|
||||
void loadMarketplace(selectedAgentId);
|
||||
}, [loadMarketplace, selectedAgentId, status]);
|
||||
}, [enabled, loadMarketplace, selectedAgentId, status]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!enabled) return;
|
||||
if (!selectedAgentId) {
|
||||
return;
|
||||
}
|
||||
await loadMarketplace(selectedAgentId);
|
||||
}, [loadMarketplace, selectedAgentId]);
|
||||
}, [enabled, loadMarketplace, selectedAgentId]);
|
||||
|
||||
const runSkillMutation = useCallback(
|
||||
async (params: {
|
||||
@@ -162,6 +165,13 @@ export const useOfficeSkillsMarketplace = ({
|
||||
const agentId = selectedAgentId?.trim() ?? "";
|
||||
const report = skillsReport;
|
||||
const normalizedSkillKey = params.skillKey.trim();
|
||||
if (!enabled) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: "This runtime does not expose skill management.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!agentId || !report) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
@@ -201,13 +211,13 @@ export const useOfficeSkillsMarketplace = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
[loadMarketplace, onSkillActivityEnd, onSkillActivityStart, selectedAgentId, skillsReport],
|
||||
[enabled, loadMarketplace, onSkillActivityEnd, onSkillActivityStart, selectedAgentId, skillsReport],
|
||||
);
|
||||
|
||||
const handleSetSkillEnabled = useCallback(
|
||||
async (skillName: string, enabled: boolean) => {
|
||||
const entry =
|
||||
skillsReport?.skills.find(
|
||||
skillsReport?.skills?.find(
|
||||
(skill) => skill.name.trim() === skillName.trim(),
|
||||
) ?? null;
|
||||
await runSkillMutation({
|
||||
@@ -276,12 +286,128 @@ export const useOfficeSkillsMarketplace = ({
|
||||
source: packagedSkill.installSource,
|
||||
workspaceDir: report.workspaceDir,
|
||||
managedSkillsDir: report.managedSkillsDir,
|
||||
agentId: selectedAgent?.agentId ?? undefined,
|
||||
agentName: selectedAgent?.name ?? undefined,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[client, runSkillMutation]
|
||||
[client, runSkillMutation, selectedAgent]
|
||||
);
|
||||
|
||||
const handleInstallPackagedSkillAndEnable = useCallback(
|
||||
async (params: {
|
||||
skillKey: string;
|
||||
agentId?: string | null;
|
||||
onProgress?: (progress: { percent: number; message: string }) => void;
|
||||
}) => {
|
||||
const packagedSkill = getPackagedSkillBySkillKey(params.skillKey);
|
||||
if (!packagedSkill) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: `No packaged marketplace skill was found for ${params.skillKey.trim() || "that entry"}.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const targetAgentId = params.agentId?.trim() || selectedAgentId?.trim() || "";
|
||||
if (!targetAgentId) {
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: "Select an agent before installing marketplace skills.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAgentId(targetAgentId);
|
||||
setBusySkillKey(packagedSkill.skillKey);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
onSkillActivityStart?.(targetAgentId);
|
||||
try {
|
||||
params.onProgress?.({
|
||||
percent: 12,
|
||||
message: "Preparing the workspace skill install.",
|
||||
});
|
||||
const initialReport = await loadAgentSkillStatus(client, targetAgentId);
|
||||
params.onProgress?.({
|
||||
percent: 38,
|
||||
message: "Installing task-manager into the workspace.",
|
||||
});
|
||||
await installPackagedSkillViaGatewayAgent({
|
||||
client,
|
||||
request: {
|
||||
packageId: packagedSkill.packageId,
|
||||
source: packagedSkill.installSource,
|
||||
workspaceDir: initialReport.workspaceDir,
|
||||
managedSkillsDir: initialReport.managedSkillsDir,
|
||||
agentId: targetAgentId,
|
||||
agentName:
|
||||
agents.find((agent) => agent.agentId === targetAgentId)?.name ?? undefined,
|
||||
},
|
||||
});
|
||||
params.onProgress?.({
|
||||
percent: 62,
|
||||
message: "Enabling task-manager for this gateway.",
|
||||
});
|
||||
await updateSkill(client, { skillKey: packagedSkill.skillKey, enabled: true });
|
||||
params.onProgress?.({
|
||||
percent: 78,
|
||||
message: "Enabling task-manager for the main agent.",
|
||||
});
|
||||
const refreshedReport = await loadAgentSkillStatus(client, targetAgentId);
|
||||
await setAgentSkillEnabled({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
skillName: packagedSkill.name,
|
||||
enabled: true,
|
||||
visibleSkills: refreshedReport.skills,
|
||||
});
|
||||
params.onProgress?.({
|
||||
percent: 92,
|
||||
message: "Refreshing skill state in Claw3D.",
|
||||
});
|
||||
await loadMarketplace(targetAgentId);
|
||||
params.onProgress?.({
|
||||
percent: 100,
|
||||
message: "Task-manager installed and enabled.",
|
||||
});
|
||||
const agentName =
|
||||
agents.find((agent) => agent.agentId === targetAgentId)?.name ?? "the main agent";
|
||||
setMessage({
|
||||
kind: "success",
|
||||
text: `Installed and enabled ${packagedSkill.name.trim()} for ${agentName}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
const nextMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to install and enable the skill.";
|
||||
setError(nextMessage);
|
||||
setMessage({
|
||||
kind: "error",
|
||||
text: nextMessage,
|
||||
});
|
||||
if (!isGatewayDisconnectLikeError(err)) {
|
||||
console.error(nextMessage);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(nextMessage);
|
||||
} finally {
|
||||
onSkillActivityEnd?.(targetAgentId);
|
||||
setBusySkillKey((current) =>
|
||||
current === packagedSkill.skillKey ? null : current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
agents,
|
||||
client,
|
||||
loadMarketplace,
|
||||
onSkillActivityEnd,
|
||||
onSkillActivityStart,
|
||||
selectedAgentId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSetSkillGlobalEnabled = useCallback(
|
||||
@@ -338,6 +464,7 @@ export const useOfficeSkillsMarketplace = ({
|
||||
handleSetSkillEnabled,
|
||||
handleInstallSkill,
|
||||
handleInstallPackagedSkill,
|
||||
handleInstallPackagedSkillAndEnable,
|
||||
handleSetSkillGlobalEnabled,
|
||||
handleRemoveSkill,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
|
||||
@@ -57,22 +57,26 @@ const findAgentForRunEvent = (
|
||||
export const useRunLog = ({
|
||||
client,
|
||||
status,
|
||||
enabled = true,
|
||||
agents,
|
||||
maxRecords = MAX_RUN_RECORDS,
|
||||
}: {
|
||||
client: GatewayClient;
|
||||
status: GatewayStatus;
|
||||
enabled?: boolean;
|
||||
agents: AgentState[];
|
||||
maxRecords?: number;
|
||||
}) => {
|
||||
const [records, setRecords] = useState<RunRecord[]>([]);
|
||||
const agentsRef = useRef(agents);
|
||||
const visibleRecords = useMemo(() => (enabled ? records : []), [enabled, records]);
|
||||
|
||||
useEffect(() => {
|
||||
agentsRef.current = agents;
|
||||
}, [agents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (status !== "connected") return;
|
||||
return client.onEvent((event) => {
|
||||
if (event.event !== "agent") return;
|
||||
@@ -126,7 +130,7 @@ export const useRunLog = ({
|
||||
return [fallbackRecord, ...current].slice(0, Math.max(1, maxRecords));
|
||||
});
|
||||
});
|
||||
}, [client, maxRecords, status]);
|
||||
}, [client, enabled, maxRecords, status]);
|
||||
|
||||
return records;
|
||||
return visibleRecords;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps, useCallback, useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { TaskBoardView } from "@/features/office/tasks/TaskBoardView";
|
||||
import type { TaskBoardCard, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
|
||||
export function KanbanImmersiveScreen({
|
||||
agents,
|
||||
cardsByStatus,
|
||||
selectedCard,
|
||||
activeRuns,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
cronError,
|
||||
taskCaptureDebug,
|
||||
onCreateCard,
|
||||
onMoveCard,
|
||||
onSelectCard,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onRefreshCronJobs,
|
||||
onClose,
|
||||
}: {
|
||||
agents: AgentState[];
|
||||
cardsByStatus: Record<TaskBoardStatus, TaskBoardCard[]>;
|
||||
selectedCard: TaskBoardCard | null;
|
||||
activeRuns: Array<{ runId: string; agentId: string; label: string }>;
|
||||
cronJobs: CronJobSummary[];
|
||||
cronLoading: boolean;
|
||||
cronError: string | null;
|
||||
taskCaptureDebug?: ComponentProps<typeof TaskBoardView>["taskCaptureDebug"];
|
||||
onCreateCard: () => void;
|
||||
onMoveCard: (cardId: string, status: TaskBoardStatus) => void;
|
||||
onSelectCard: (cardId: string | null) => void;
|
||||
onUpdateCard: (cardId: string, patch: Partial<TaskBoardCard>) => void;
|
||||
onDeleteCard: (cardId: string) => void;
|
||||
onRefreshCronJobs: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
dialog.focus();
|
||||
|
||||
const trapFocus = (event: FocusEvent) => {
|
||||
if (!dialog.contains(event.target as Node)) {
|
||||
event.stopPropagation();
|
||||
dialog.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("focusin", trapFocus);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", trapFocus);
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Kanban Board"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close Kanban Board"
|
||||
className="absolute -right-5 -top-5 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-amber-400/20 bg-[#0e0b07]/90 text-amber-200/70 backdrop-blur-sm transition-colors hover:border-amber-400/40 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className="flex h-[min(75vh,800px)] w-[min(80vw,1280px)] flex-col overflow-hidden rounded-2xl border border-amber-500/20 bg-[#0e0b07]/85 shadow-2xl outline-none backdrop-blur-md"
|
||||
>
|
||||
<div className="min-h-0 flex-1">
|
||||
<TaskBoardView
|
||||
title="Kanban Board"
|
||||
subtitle="Headquarters task routing, scheduling, and review."
|
||||
agents={agents}
|
||||
cardsByStatus={cardsByStatus}
|
||||
selectedCard={selectedCard}
|
||||
activeRuns={activeRuns}
|
||||
cronJobs={cronJobs}
|
||||
cronLoading={cronLoading}
|
||||
cronError={cronError}
|
||||
taskCaptureDebug={taskCaptureDebug}
|
||||
onCreateCard={onCreateCard}
|
||||
onMoveCard={onMoveCard}
|
||||
onSelectCard={onSelectCard}
|
||||
onUpdateCard={onUpdateCard}
|
||||
onDeleteCard={onDeleteCard}
|
||||
onRefreshCronJobs={onRefreshCronJobs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,16 +12,17 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { MessageSquare, ChevronDown, Mic } from "lucide-react";
|
||||
import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D";
|
||||
import type { OfficeAgent } from "@/features/retro-office/core/types";
|
||||
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||
import { GatewayConnectScreen } from "@/features/agents/components/GatewayConnectScreen";
|
||||
import { useAgentStore, type AgentState } from "@/features/agents/state/store";
|
||||
import {
|
||||
GatewayClient,
|
||||
buildAgentMainSessionKey,
|
||||
useGatewayConnection,
|
||||
type EventFrame,
|
||||
isSameSessionKey,
|
||||
parseAgentIdFromSessionKey,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import { useRuntimeConnection } from "@/lib/runtime/useRuntimeConnection";
|
||||
import {
|
||||
createStudioSettingsCoordinator,
|
||||
type StudioSettingsLoadOptions,
|
||||
@@ -137,8 +138,10 @@ import type {
|
||||
import { AnalyticsPanel } from "@/features/office/components/panels/AnalyticsPanel";
|
||||
import { HistoryPanel } from "@/features/office/components/panels/HistoryPanel";
|
||||
import { InboxPanel } from "@/features/office/components/panels/InboxPanel";
|
||||
import { KanbanDisabledPanel } from "@/features/office/components/panels/KanbanDisabledPanel";
|
||||
import { PlaybooksPanel } from "@/features/office/components/panels/PlaybooksPanel";
|
||||
import { SkillsMarketplaceModal } from "@/features/office/components/panels/SkillsMarketplaceModal";
|
||||
import { TaskBoardPanel } from "@/features/office/components/panels/TaskBoardPanel";
|
||||
import { JukeboxPanel } from "@/features/spotify-jukebox/components/JukeboxPanel";
|
||||
import { JukeboxDisabledPanel } from "@/features/spotify-jukebox/components/JukeboxDisabledPanel";
|
||||
import { executeBrowserJukeboxCommand } from "@/features/spotify-jukebox/agentBridge";
|
||||
@@ -152,6 +155,7 @@ import { useRemoteOfficeLayout } from "@/features/office/hooks/useRemoteOfficeLa
|
||||
import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSkillsMarketplace";
|
||||
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
||||
import { useTaskBoardController } from "@/features/office/tasks/useTaskBoardController";
|
||||
import {
|
||||
OnboardingWizard,
|
||||
useOnboardingState,
|
||||
@@ -211,6 +215,8 @@ const MAIN_AGENT_ID = "main";
|
||||
const MAX_OPENCLAW_LOG_ENTRIES = 200;
|
||||
const MAX_OPENCLAW_AGENT_OUTPUT_LINES = 12;
|
||||
const OFFICE_DANCE_MS = 60_000;
|
||||
const GATEWAY_LOADING_OVERLAY_DELAY_MS = 1_200;
|
||||
const GATEWAY_CONNECT_OVERLAY_DELAY_MS = 1_500;
|
||||
|
||||
const getLatestUserRequestForAgent = (
|
||||
agent: AgentState,
|
||||
@@ -507,6 +513,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
|
||||
return {
|
||||
id: agent.agentId,
|
||||
name: agent.name || "Unknown",
|
||||
subtitle: agent.role ?? null,
|
||||
status: "error",
|
||||
color: stringToColor(agent.agentId),
|
||||
item: getDeterministicItem(agent.agentId),
|
||||
@@ -517,6 +524,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
|
||||
return {
|
||||
id: agent.agentId,
|
||||
name: agent.name || "Unknown",
|
||||
subtitle: agent.role ?? null,
|
||||
status: isWorking ? "working" : "idle",
|
||||
color: stringToColor(agent.agentId),
|
||||
item: getDeterministicItem(agent.agentId),
|
||||
@@ -832,11 +840,14 @@ export function OfficeScreen({
|
||||
);
|
||||
const {
|
||||
client,
|
||||
provider,
|
||||
status,
|
||||
connectPromptReady,
|
||||
shouldPromptForConnect,
|
||||
gatewayUrl,
|
||||
token,
|
||||
selectedAdapterType,
|
||||
activeAdapterType,
|
||||
localGatewayDefaults,
|
||||
error: gatewayError,
|
||||
connect,
|
||||
@@ -844,12 +855,23 @@ export function OfficeScreen({
|
||||
useLocalGatewayDefaults,
|
||||
setGatewayUrl,
|
||||
setToken,
|
||||
setSelectedAdapterType,
|
||||
supportsCapability,
|
||||
} =
|
||||
useGatewayConnection(settingsCoordinator);
|
||||
useRuntimeConnection(settingsCoordinator);
|
||||
const runtimeSupportsSkills = supportsCapability("skills");
|
||||
const runtimeSupportsApprovals = supportsCapability("approvals");
|
||||
const runtimeSupportsCron = supportsCapability("cron");
|
||||
const runtimeSupportsModels = supportsCapability("models");
|
||||
const runtimeSupportsRunLifecycle = supportsCapability("runtime-agent-events");
|
||||
const { state, dispatch, hydrateAgents, setError, setLoading } =
|
||||
useAgentStore();
|
||||
const [agentsLoaded, setAgentsLoaded] = useState(false);
|
||||
const [didAttemptGatewayConnect, setDidAttemptGatewayConnect] = useState(false);
|
||||
const [showDelayedGatewayLoadingOverlay, setShowDelayedGatewayLoadingOverlay] =
|
||||
useState(false);
|
||||
const [showDelayedGatewayConnectOverlay, setShowDelayedGatewayConnectOverlay] =
|
||||
useState(false);
|
||||
const [clockTick, setClockTick] = useState(0);
|
||||
const [debugRows, setDebugRows] = useState<OfficeDebugRow[]>([]);
|
||||
const [feedEvents, setFeedEvents] = useState<OfficeFeedEvent[]>([]);
|
||||
@@ -880,6 +902,8 @@ export function OfficeScreen({
|
||||
const [openClawConsoleCopyStatus, setOpenClawConsoleCopyStatus] = useState<
|
||||
"idle" | "copied" | "error"
|
||||
>("idle");
|
||||
const taskBoardEventHandlerRef = useRef<(event: EventFrame) => void>(() => {});
|
||||
const taskBoardRefreshRef = useRef<() => Promise<void>>(async () => {});
|
||||
const [officeTriggerState, setOfficeTriggerState] = useState(() =>
|
||||
createOfficeAnimationTriggerState(),
|
||||
);
|
||||
@@ -963,6 +987,18 @@ export function OfficeScreen({
|
||||
const [gatewayModels, setGatewayModels] = useState<GatewayModelChoice[]>([]);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [marketplaceOpen, setMarketplaceOpen] = useState(false);
|
||||
const [kanbanInstallPromptOpen, setKanbanInstallPromptOpen] = useState(false);
|
||||
const [kanbanInstallProgress, setKanbanInstallProgress] = useState<{
|
||||
active: boolean;
|
||||
percent: number;
|
||||
message: string;
|
||||
error: string | null;
|
||||
}>({
|
||||
active: false,
|
||||
percent: 0,
|
||||
message: "",
|
||||
error: null,
|
||||
});
|
||||
const [danceUntilByAgentId, setDanceUntilByAgentId] = useState<Record<string, number>>({});
|
||||
const initJukeboxStore = useJukeboxStore((state) => state.init);
|
||||
const jukeboxToken = useJukeboxStore((state) => state.token);
|
||||
@@ -1112,14 +1148,33 @@ export function OfficeScreen({
|
||||
},
|
||||
[dispatch, gatewayUrl, settingsCoordinator],
|
||||
);
|
||||
const focusLocalAgent = useCallback(
|
||||
(agentId: string, options?: { openChat?: boolean }) => {
|
||||
setSelectedChatAgentId(agentId);
|
||||
if (options?.openChat !== false) {
|
||||
setChatOpen(true);
|
||||
}
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const focusChatTarget = useCallback(
|
||||
(agentId: string) => {
|
||||
setSelectedChatAgentId(agentId);
|
||||
setChatOpen(true);
|
||||
if (!isRemoteOfficeAgentId(agentId)) {
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const openAgentEditor = useCallback(
|
||||
(agentId: string, initialSection: AgentEditorSection = "avatar") => {
|
||||
setAgentEditorAgentId(agentId);
|
||||
setAgentEditorInitialSection(initialSection);
|
||||
setSelectedChatAgentId(agentId);
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
focusLocalAgent(agentId, { openChat: false });
|
||||
},
|
||||
[dispatch],
|
||||
[focusLocalAgent],
|
||||
);
|
||||
|
||||
const handleDeskAssignmentChange = useCallback(
|
||||
@@ -1336,7 +1391,7 @@ export function OfficeScreen({
|
||||
? { force: true }
|
||||
: { maxAgeMs: options?.settingsMaxAgeMs ?? 60_000 };
|
||||
const commands = await runStudioBootstrapLoadOperation({
|
||||
client,
|
||||
client: provider,
|
||||
gatewayUrl,
|
||||
cachedConfigSnapshot: gatewayConfigSnapshot.current,
|
||||
loadStudioSettings: () => loadStudioSettings(settingsLoadOptions),
|
||||
@@ -1372,7 +1427,7 @@ export function OfficeScreen({
|
||||
}
|
||||
try {
|
||||
const inference = await inferRunningFromAgentSessions({
|
||||
client,
|
||||
client: provider,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
if (connectionEpochAtStart !== connectionEpochRef.current) {
|
||||
@@ -1553,7 +1608,7 @@ export function OfficeScreen({
|
||||
const runCompanyBuilderAiTask = useCallback(
|
||||
async (prompt: string, statusText: string) => {
|
||||
if (status !== "connected") {
|
||||
throw new Error("Connect to OpenClaw before using the company builder.");
|
||||
throw new Error("Connect to a runtime before using the company builder.");
|
||||
}
|
||||
const livePlannerAgent = resolveCompanyPlanningAgent({
|
||||
agents: stateRef.current.agents,
|
||||
@@ -1581,7 +1636,7 @@ export function OfficeScreen({
|
||||
try {
|
||||
const improvedBrief = await runCompanyBuilderAiTask(
|
||||
buildImproveCompanyBriefPrompt(brief),
|
||||
"Improving your company brief with OpenClaw.",
|
||||
"Improving your company brief with the connected runtime.",
|
||||
);
|
||||
setCompanyBuilderInput((current) => ({
|
||||
...current,
|
||||
@@ -1608,7 +1663,7 @@ export function OfficeScreen({
|
||||
try {
|
||||
const response = await runCompanyBuilderAiTask(
|
||||
buildGenerateCompanyPlanPrompt(brief),
|
||||
"Generating your AI company structure with OpenClaw.",
|
||||
"Generating your AI company structure with the connected runtime.",
|
||||
);
|
||||
const parsedPlan = parseCompanyPlanFromAssistantText(response);
|
||||
const nextInput: CompanyBuilderInput = {
|
||||
@@ -1651,7 +1706,7 @@ export function OfficeScreen({
|
||||
const handleCreateCompanyFromPlan = useCallback(
|
||||
async (params: { input: CompanyBuilderInput; plan: CompanyBuilderPlan }) => {
|
||||
if (status !== "connected") {
|
||||
const message = "Connect to OpenClaw before creating the company.";
|
||||
const message = "Connect to a runtime before creating the company.";
|
||||
setCompanyBuilderError(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
@@ -1755,8 +1810,7 @@ export function OfficeScreen({
|
||||
persistSnapshot: persistCompanyBuilderSnapshot,
|
||||
setOfficeTitle,
|
||||
selectAgent: (agentId) => {
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
setSelectedChatAgentId(agentId);
|
||||
focusLocalAgent(agentId);
|
||||
},
|
||||
setStatusLine: setCompanyBuilderStatusLine,
|
||||
});
|
||||
@@ -1870,11 +1924,7 @@ export function OfficeScreen({
|
||||
);
|
||||
}
|
||||
}
|
||||
dispatch({
|
||||
type: "selectAgent",
|
||||
agentId: completion.agentId,
|
||||
});
|
||||
setSelectedChatAgentId(completion.agentId);
|
||||
focusLocalAgent(completion.agentId);
|
||||
setCreateAgentBlock(null);
|
||||
setCreateAgentModalError(null);
|
||||
},
|
||||
@@ -1894,6 +1944,7 @@ export function OfficeScreen({
|
||||
createAgentBusy,
|
||||
dispatch,
|
||||
enqueueConfigMutation,
|
||||
focusLocalAgent,
|
||||
hasDeleteMutationBlock,
|
||||
loadAgents,
|
||||
setError,
|
||||
@@ -2088,7 +2139,7 @@ export function OfficeScreen({
|
||||
const requestedSessionKey = params.sessionKey?.trim() ?? "";
|
||||
if (requestedSessionKey) {
|
||||
try {
|
||||
const history = await client.call<{
|
||||
const history = await provider.call<{
|
||||
messages?: Record<string, unknown>[];
|
||||
}>("chat.history", {
|
||||
sessionKey: requestedSessionKey,
|
||||
@@ -2100,7 +2151,7 @@ export function OfficeScreen({
|
||||
const derived = buildHistoryLines(messages);
|
||||
let lastUser = derived.lastUser?.trim() ?? "";
|
||||
if (!lastUser) {
|
||||
const previewResult = await client.call<SummaryPreviewSnapshot>(
|
||||
const previewResult = await provider.call<SummaryPreviewSnapshot>(
|
||||
"sessions.preview",
|
||||
{
|
||||
keys: [requestedSessionKey],
|
||||
@@ -2196,7 +2247,7 @@ export function OfficeScreen({
|
||||
return;
|
||||
}
|
||||
const commands = await runHistorySyncOperation({
|
||||
client,
|
||||
client: provider,
|
||||
agentId: params.agentId,
|
||||
getAgent: (agentId) =>
|
||||
stateRef.current.agents.find((entry) => entry.agentId === agentId) ??
|
||||
@@ -2222,7 +2273,7 @@ export function OfficeScreen({
|
||||
});
|
||||
}
|
||||
},
|
||||
[client, debugEnabled, dispatch, status],
|
||||
[debugEnabled, dispatch, provider, status],
|
||||
);
|
||||
|
||||
const refreshRecentTransportSessionHistory = useCallback(
|
||||
@@ -2307,7 +2358,6 @@ export function OfficeScreen({
|
||||
useEffect(() => {
|
||||
if (status === "disconnected") {
|
||||
connectionEpochRef.current += 1;
|
||||
setAgentsLoaded(false);
|
||||
setCreateAgentWizardOpen(false);
|
||||
setCreateAgentBusy(false);
|
||||
setCreateAgentModalError(null);
|
||||
@@ -2316,7 +2366,11 @@ export function OfficeScreen({
|
||||
loadAgentsInFlightRef.current = null;
|
||||
gatewayConfigSnapshot.current = null;
|
||||
lastLoadAgentsStartedAtRef.current = 0;
|
||||
hydrateAgents([]);
|
||||
setLoading(false);
|
||||
if (stateRef.current.agents.length === 0) {
|
||||
setAgentsLoaded(false);
|
||||
hydrateAgents([]);
|
||||
}
|
||||
setFeedEvents([]);
|
||||
setDebugRows([]);
|
||||
setRunCountByAgentId({});
|
||||
@@ -2324,7 +2378,7 @@ export function OfficeScreen({
|
||||
prevAssistantPreviewRef.current = {};
|
||||
lastGatewayActivityAtRef.current = 0;
|
||||
}
|
||||
}, [hydrateAgents, status]);
|
||||
}, [hydrateAgents, setLoading, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentsLoaded) return;
|
||||
@@ -2468,6 +2522,7 @@ export function OfficeScreen({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
taskBoardEventHandlerRef.current(event);
|
||||
runtimeHandler.handleEvent(event);
|
||||
});
|
||||
const unsubscribeGap = client.onGap(() => {
|
||||
@@ -2476,6 +2531,7 @@ export function OfficeScreen({
|
||||
settingsMaxAgeMs: 30_000,
|
||||
silent: true,
|
||||
});
|
||||
void taskBoardRefreshRef.current();
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -2583,10 +2639,11 @@ export function OfficeScreen({
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "connected") return;
|
||||
if (!runtimeSupportsModels) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await client.call<{ models: GatewayModelChoice[] }>(
|
||||
const result = await provider.call<{ models: GatewayModelChoice[] }>(
|
||||
"models.list",
|
||||
{},
|
||||
);
|
||||
@@ -2605,7 +2662,7 @@ export function OfficeScreen({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [status, client]);
|
||||
}, [status, provider, runtimeSupportsModels]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatOpen && !selectedChatAgentId && state.agents.length > 0) {
|
||||
@@ -2619,7 +2676,7 @@ export function OfficeScreen({
|
||||
);
|
||||
|
||||
const chatController = useChatInteractionController({
|
||||
client,
|
||||
client: provider,
|
||||
status,
|
||||
agents: state.agents,
|
||||
dispatch: (action) => dispatch(action as never),
|
||||
@@ -2657,7 +2714,12 @@ export function OfficeScreen({
|
||||
setAgentEditorAgentId(null);
|
||||
}, [agentEditorAgentId, state.agents]);
|
||||
|
||||
const runLog = useRunLog({ client, status, agents: state.agents });
|
||||
const runLog = useRunLog({
|
||||
client,
|
||||
status,
|
||||
enabled: runtimeSupportsRunLifecycle,
|
||||
agents: state.agents,
|
||||
});
|
||||
const standupAgentSnapshots = useMemo<StandupAgentSnapshot[]>(
|
||||
() =>
|
||||
state.agents.map((agent) => ({
|
||||
@@ -2672,6 +2734,22 @@ export function OfficeScreen({
|
||||
gatewayUrl,
|
||||
agents: standupAgentSnapshots,
|
||||
});
|
||||
const taskBoard = useTaskBoardController({
|
||||
gatewayUrl,
|
||||
settingsCoordinator,
|
||||
client,
|
||||
status,
|
||||
cronEnabled: runtimeSupportsCron,
|
||||
agents: state.agents,
|
||||
runLog,
|
||||
standup: standupController,
|
||||
});
|
||||
const ingestTaskBoardEvent = taskBoard.ingestGatewayEvent;
|
||||
taskBoardEventHandlerRef.current = ingestTaskBoardEvent;
|
||||
taskBoardRefreshRef.current = async () => {
|
||||
await taskBoard.refreshSharedTasks();
|
||||
await taskBoard.refreshRemoteTasks();
|
||||
};
|
||||
const handleMarketplaceGymStart = useCallback((agentId: string) => {
|
||||
setMarketplaceGymHoldByAgentId((previous) => ({
|
||||
...previous,
|
||||
@@ -2689,6 +2767,7 @@ export function OfficeScreen({
|
||||
const marketplace = useOfficeSkillsMarketplace({
|
||||
client,
|
||||
status,
|
||||
enabled: runtimeSupportsSkills,
|
||||
agents: state.agents,
|
||||
preferredAgentId: selectedLocalChatAgentId,
|
||||
onSkillActivityStart: handleMarketplaceGymStart,
|
||||
@@ -2697,6 +2776,7 @@ export function OfficeScreen({
|
||||
const skillTriggers = useOfficeSkillTriggers({
|
||||
client,
|
||||
status,
|
||||
enabled: runtimeSupportsSkills,
|
||||
agents: state.agents,
|
||||
});
|
||||
const animationNowMs = Date.now();
|
||||
@@ -2829,9 +2909,8 @@ export function OfficeScreen({
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGithubReviewAgentId) return;
|
||||
setSelectedChatAgentId(activeGithubReviewAgentId);
|
||||
dispatch({ type: "selectAgent", agentId: activeGithubReviewAgentId });
|
||||
}, [activeGithubReviewAgentId, dispatch]);
|
||||
focusLocalAgent(activeGithubReviewAgentId);
|
||||
}, [activeGithubReviewAgentId, focusLocalAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
setQaTestingAgentId(activeQaTestingAgentId);
|
||||
@@ -2839,9 +2918,8 @@ export function OfficeScreen({
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeQaTestingAgentId) return;
|
||||
setSelectedChatAgentId(activeQaTestingAgentId);
|
||||
dispatch({ type: "selectAgent", agentId: activeQaTestingAgentId });
|
||||
}, [activeQaTestingAgentId, dispatch]);
|
||||
focusLocalAgent(activeQaTestingAgentId);
|
||||
}, [activeQaTestingAgentId, focusLocalAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeKeys = new Set(
|
||||
@@ -2895,9 +2973,7 @@ export function OfficeScreen({
|
||||
promptedPhoneCallKeysRef.current.delete(request.key);
|
||||
return;
|
||||
}
|
||||
setSelectedChatAgentId(agentId);
|
||||
setChatOpen(true);
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
focusLocalAgent(agentId);
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
@@ -2955,7 +3031,7 @@ export function OfficeScreen({
|
||||
prepareScenarioForAgent(agentId, request);
|
||||
}
|
||||
}
|
||||
}, [dispatch, phoneCallByAgentId, state.agents]);
|
||||
}, [dispatch, focusLocalAgent, phoneCallByAgentId, state.agents]);
|
||||
|
||||
const activePhoneBoothAgentId = useMemo(
|
||||
() =>
|
||||
@@ -2977,10 +3053,9 @@ export function OfficeScreen({
|
||||
({ agentId, requestKey }: PhoneCallSpeakPayload) => {
|
||||
if (spokenPhoneCallKeysRef.current.has(requestKey)) return;
|
||||
spokenPhoneCallKeysRef.current.add(requestKey);
|
||||
setSelectedChatAgentId(agentId);
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
focusLocalAgent(agentId);
|
||||
},
|
||||
[dispatch],
|
||||
[focusLocalAgent],
|
||||
);
|
||||
|
||||
const handlePhoneCallComplete = useCallback(
|
||||
@@ -3058,9 +3133,7 @@ export function OfficeScreen({
|
||||
promptedTextMessageKeysRef.current.delete(request.key);
|
||||
return;
|
||||
}
|
||||
setSelectedChatAgentId(agentId);
|
||||
setChatOpen(true);
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
focusLocalAgent(agentId);
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
@@ -3118,7 +3191,7 @@ export function OfficeScreen({
|
||||
prepareScenarioForAgent(agentId, request);
|
||||
}
|
||||
}
|
||||
}, [dispatch, state.agents, textMessageByAgentId]);
|
||||
}, [dispatch, focusLocalAgent, state.agents, textMessageByAgentId]);
|
||||
|
||||
const activeSmsBoothAgentId = useMemo(
|
||||
() =>
|
||||
@@ -3185,13 +3258,9 @@ export function OfficeScreen({
|
||||
|
||||
const handleOpenAgentChat = useCallback(
|
||||
(agentId: string) => {
|
||||
setSelectedChatAgentId(agentId);
|
||||
setChatOpen(true);
|
||||
if (!isRemoteOfficeAgentId(agentId)) {
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
}
|
||||
focusChatTarget(agentId);
|
||||
},
|
||||
[dispatch],
|
||||
[focusChatTarget],
|
||||
);
|
||||
const updateRemoteChatSession = useCallback(
|
||||
(
|
||||
@@ -3718,6 +3787,15 @@ export function OfficeScreen({
|
||||
state.agents,
|
||||
workingUntilByAgentId,
|
||||
]);
|
||||
const streamingTextByAgentId = useMemo(() => {
|
||||
const map: Record<string, string | null> = {};
|
||||
for (const agent of state.agents) {
|
||||
if (agent.streamText?.trim()) {
|
||||
map[agent.agentId] = agent.streamText.trim();
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [state.agents]);
|
||||
const openClawLiveStateText = useMemo(() => {
|
||||
const lines = ["== LIVE OPENCLAW STATE =="];
|
||||
if (state.agents.length === 0) {
|
||||
@@ -3945,6 +4023,19 @@ export function OfficeScreen({
|
||||
}) ?? null,
|
||||
[marketplace.skillsReport],
|
||||
);
|
||||
const taskManagerSkill = useMemo<SkillStatusEntry | null>(
|
||||
() =>
|
||||
marketplace.skillsReport?.skills.find((skill) => {
|
||||
const normalizedKey = skill.skillKey.trim().toLowerCase();
|
||||
const normalizedName = skill.name.trim().toLowerCase();
|
||||
return normalizedKey === "task-manager" || normalizedName === "task-manager";
|
||||
}) ?? null,
|
||||
[marketplace.skillsReport],
|
||||
);
|
||||
const taskManagerReady = useMemo(
|
||||
() => (taskManagerSkill ? deriveSkillReadinessState(taskManagerSkill) === "ready" : false),
|
||||
[taskManagerSkill],
|
||||
);
|
||||
const soundclawReady = useMemo(
|
||||
() => (soundclawSkill ? deriveSkillReadinessState(soundclawSkill) === "ready" : false),
|
||||
[soundclawSkill]
|
||||
@@ -4040,43 +4131,52 @@ export function OfficeScreen({
|
||||
// No longer force-close the jukebox panel when skill is disabled;
|
||||
// the panel handles the disabled state itself.
|
||||
|
||||
if (
|
||||
useEffect(() => {
|
||||
if (
|
||||
status === "connecting" &&
|
||||
!agentsLoaded &&
|
||||
gatewayUrl.trim().length > 0 &&
|
||||
!shouldPromptForConnect
|
||||
) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowDelayedGatewayLoadingOverlay(true);
|
||||
}, GATEWAY_LOADING_OVERLAY_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
setShowDelayedGatewayLoadingOverlay(false);
|
||||
}, [agentsLoaded, gatewayUrl, shouldPromptForConnect, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
status === "disconnected" &&
|
||||
!agentsLoaded &&
|
||||
didAttemptGatewayConnect &&
|
||||
!shouldPromptForConnect
|
||||
) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowDelayedGatewayConnectOverlay(true);
|
||||
}, GATEWAY_CONNECT_OVERLAY_DELAY_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
setShowDelayedGatewayConnectOverlay(false);
|
||||
}, [agentsLoaded, didAttemptGatewayConnect, shouldPromptForConnect, status]);
|
||||
|
||||
const showGatewayLoadingOverlay =
|
||||
!agentsLoaded &&
|
||||
(!connectPromptReady ||
|
||||
(gatewayUrl.trim().length > 0 &&
|
||||
!shouldPromptForConnect &&
|
||||
(!didAttemptGatewayConnect || status === "connecting")))
|
||||
) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black font-mono text-[#4FC3F7]">
|
||||
CONNECTING TO GATEWAY...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
((!didAttemptGatewayConnect && showDelayedGatewayLoadingOverlay) ||
|
||||
(status === "connecting" && showDelayedGatewayLoadingOverlay))));
|
||||
const showGatewayConnectOverlay =
|
||||
connectPromptReady &&
|
||||
status === "disconnected" &&
|
||||
!agentsLoaded &&
|
||||
(shouldPromptForConnect || didAttemptGatewayConnect)
|
||||
) {
|
||||
return (
|
||||
<main className="min-h-screen bg-black px-4 py-10">
|
||||
<GatewayConnectScreen
|
||||
gatewayUrl={gatewayUrl}
|
||||
token={token}
|
||||
localGatewayDefaults={localGatewayDefaults}
|
||||
status={status}
|
||||
error={gatewayError}
|
||||
showApprovalHint={didAttemptGatewayConnect}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onUseLocalDefaults={useLocalGatewayDefaults}
|
||||
onConnect={() => void connect()}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
(shouldPromptForConnect || showDelayedGatewayConnectOverlay);
|
||||
|
||||
const runningCount = state.agents.filter(
|
||||
(agent) =>
|
||||
@@ -4098,7 +4198,44 @@ export function OfficeScreen({
|
||||
"Connected to the gateway, but no agents were loaded into the office.";
|
||||
|
||||
return (
|
||||
<main className="h-full w-full overflow-hidden bg-black">
|
||||
<main className="relative h-full w-full overflow-hidden bg-black">
|
||||
{showGatewayLoadingOverlay ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-[#120a05]/76"
|
||||
aria-label="Connecting to runtime"
|
||||
role="status"
|
||||
>
|
||||
<div className="rounded-xl border border-amber-700/45 bg-[#1a1008] px-8 py-6 shadow-2xl">
|
||||
<RunningAvatarLoader
|
||||
size={28}
|
||||
trackWidth={76}
|
||||
label="Connecting to your runtime..."
|
||||
labelClassName="text-amber-100/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showGatewayConnectOverlay ? (
|
||||
<div className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-[#120a05]/76 px-4 py-10">
|
||||
<div className="w-full max-w-[860px] rounded-2xl border border-amber-900/55 bg-[#120a05]/98 p-3 shadow-2xl">
|
||||
<GatewayConnectScreen
|
||||
gatewayUrl={gatewayUrl}
|
||||
token={token}
|
||||
selectedAdapterType={selectedAdapterType}
|
||||
activeAdapterType={activeAdapterType}
|
||||
localGatewayDefaults={localGatewayDefaults}
|
||||
status={status}
|
||||
error={gatewayError}
|
||||
showApprovalHint={didAttemptGatewayConnect}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onTokenChange={setToken}
|
||||
onAdapterTypeChange={setSelectedAdapterType}
|
||||
onUseLocalDefaults={useLocalGatewayDefaults}
|
||||
onConnect={() => void connect()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
|
||||
<RetroOffice3D
|
||||
agents={allVisibleAgents}
|
||||
@@ -4114,6 +4251,7 @@ export function OfficeScreen({
|
||||
monitorAgentId={monitorAgentId}
|
||||
monitorByAgentId={monitorByAgentId}
|
||||
githubSkill={githubSkill}
|
||||
taskManagerEnabled={taskManagerReady}
|
||||
soundclawEnabled={soundclawReady}
|
||||
officeTitle={officeTitle}
|
||||
officeTitleLoaded={officeTitleLoaded}
|
||||
@@ -4154,12 +4292,21 @@ export function OfficeScreen({
|
||||
gatewayUrl,
|
||||
settingsCoordinator,
|
||||
}}
|
||||
gatewayUrl={gatewayUrl}
|
||||
gatewayToken={token}
|
||||
selectedAdapterType={selectedAdapterType}
|
||||
activeAdapterType={activeAdapterType}
|
||||
onGatewayDisconnect={disconnect}
|
||||
onGatewayConnect={() => void connect()}
|
||||
onGatewayUrlChange={setGatewayUrl}
|
||||
onGatewayTokenChange={setToken}
|
||||
onGatewayAdapterTypeChange={setSelectedAdapterType}
|
||||
onOpenOnboarding={handleOpenOnboarding}
|
||||
feedEvents={feedEvents}
|
||||
gatewayStatus={status}
|
||||
runCountByAgentId={runCountByAgentId}
|
||||
lastSeenByAgentId={lastSeenByAgentId}
|
||||
streamingTextByAgentId={streamingTextByAgentId}
|
||||
standupMeeting={standupController.meeting}
|
||||
standupAutoOpenBoard={standupController.openBoardByDefault}
|
||||
onStandupArrivalsChange={(arrivedAgentIds) => {
|
||||
@@ -4176,8 +4323,7 @@ export function OfficeScreen({
|
||||
onMonitorSelect={(agentId) => {
|
||||
setMonitorAgentId(agentId);
|
||||
if (agentId && !isRemoteOfficeAgentId(agentId)) {
|
||||
setSelectedChatAgentId(agentId);
|
||||
dispatch({ type: "selectAgent", agentId });
|
||||
focusLocalAgent(agentId, { openChat: false });
|
||||
}
|
||||
}}
|
||||
onAgentChatSelect={(agentId) => {
|
||||
@@ -4207,6 +4353,33 @@ export function OfficeScreen({
|
||||
onJukeboxInteract={() => {
|
||||
setJukeboxOpen(true);
|
||||
}}
|
||||
onKanbanInteract={() => {
|
||||
setKanbanInstallPromptOpen(true);
|
||||
}}
|
||||
taskBoardAgents={state.agents}
|
||||
taskBoardCardsByStatus={taskBoard.cardsByStatus}
|
||||
taskBoardSelectedCard={taskBoard.selectedCard}
|
||||
taskBoardActiveRuns={taskBoard.activeRuns}
|
||||
taskBoardCronJobs={taskBoard.cronJobs}
|
||||
taskBoardCronLoading={taskBoard.cronLoading}
|
||||
taskBoardCronError={
|
||||
taskBoard.sharedTasksError ?? taskBoard.gatewayTasksError ?? taskBoard.cronError
|
||||
}
|
||||
taskBoardCaptureDebug={showOpenClawConsole ? taskBoard.taskCaptureDebug : undefined}
|
||||
onTaskBoardCreateCard={() => {
|
||||
taskBoard.createManualCard();
|
||||
}}
|
||||
onTaskBoardMoveCard={taskBoard.moveCard}
|
||||
onTaskBoardSelectCard={(cardId) => {
|
||||
taskBoard.selectCard(cardId);
|
||||
}}
|
||||
onTaskBoardUpdateCard={taskBoard.updateCard}
|
||||
onTaskBoardDeleteCard={taskBoard.removeCard}
|
||||
onTaskBoardRefreshCronJobs={() => {
|
||||
void taskBoard.refreshSharedTasks();
|
||||
void taskBoard.refreshRemoteTasks();
|
||||
void taskBoard.refreshCronJobs();
|
||||
}}
|
||||
/>
|
||||
{jukeboxOpen ? (
|
||||
soundclawReady ? (
|
||||
@@ -4225,6 +4398,75 @@ export function OfficeScreen({
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{kanbanInstallPromptOpen ? (
|
||||
<KanbanDisabledPanel
|
||||
onClose={() => {
|
||||
if (kanbanInstallProgress.active) {
|
||||
return;
|
||||
}
|
||||
setKanbanInstallPromptOpen(false);
|
||||
setKanbanInstallProgress({
|
||||
active: false,
|
||||
percent: 0,
|
||||
message: "",
|
||||
error: null,
|
||||
});
|
||||
}}
|
||||
onInstall={() => {
|
||||
const targetAgentId =
|
||||
(selectedChatAgentId ?? state.selectedAgentId ?? state.agents[0]?.agentId ?? "")
|
||||
.trim() || null;
|
||||
setKanbanInstallProgress({
|
||||
active: true,
|
||||
percent: 8,
|
||||
message: "Starting task-manager installation.",
|
||||
error: null,
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
await marketplace.handleInstallPackagedSkillAndEnable({
|
||||
skillKey: "task-manager",
|
||||
agentId: targetAgentId,
|
||||
onProgress: ({ percent, message }) => {
|
||||
setKanbanInstallProgress({
|
||||
active: true,
|
||||
percent,
|
||||
message,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
});
|
||||
setKanbanInstallProgress({
|
||||
active: true,
|
||||
percent: 100,
|
||||
message: "Refreshing task-manager state in Claw3D.",
|
||||
error: null,
|
||||
});
|
||||
setKanbanInstallPromptOpen(false);
|
||||
setKanbanInstallProgress({
|
||||
active: false,
|
||||
percent: 0,
|
||||
message: "",
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
setKanbanInstallProgress((current) => ({
|
||||
...current,
|
||||
active: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to install task-manager.",
|
||||
}));
|
||||
}
|
||||
})();
|
||||
}}
|
||||
installing={kanbanInstallProgress.active}
|
||||
progressPercent={kanbanInstallProgress.percent}
|
||||
progressMessage={kanbanInstallProgress.message}
|
||||
errorMessage={kanbanInstallProgress.error}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{showEmptyFleetBanner ? (
|
||||
@@ -4311,10 +4553,38 @@ export function OfficeScreen({
|
||||
}}
|
||||
/>
|
||||
}
|
||||
kanbanPanel={
|
||||
<TaskBoardPanel
|
||||
agents={state.agents}
|
||||
cardsByStatus={taskBoard.cardsByStatus}
|
||||
selectedCard={taskBoard.selectedCard}
|
||||
activeRuns={taskBoard.activeRuns}
|
||||
cronJobs={taskBoard.cronJobs}
|
||||
cronLoading={taskBoard.cronLoading}
|
||||
cronError={
|
||||
taskBoard.sharedTasksError ?? taskBoard.gatewayTasksError ?? taskBoard.cronError
|
||||
}
|
||||
taskCaptureDebug={showOpenClawConsole ? taskBoard.taskCaptureDebug : undefined}
|
||||
onCreateCard={() => {
|
||||
taskBoard.createManualCard();
|
||||
setActiveSidebarTab("kanban");
|
||||
}}
|
||||
onMoveCard={taskBoard.moveCard}
|
||||
onSelectCard={taskBoard.selectCard}
|
||||
onUpdateCard={taskBoard.updateCard}
|
||||
onDeleteCard={taskBoard.removeCard}
|
||||
onRefreshCronJobs={() => {
|
||||
void taskBoard.refreshSharedTasks();
|
||||
void taskBoard.refreshRemoteTasks();
|
||||
void taskBoard.refreshCronJobs();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
playbooksPanel={
|
||||
<PlaybooksPanel
|
||||
client={client}
|
||||
status={status}
|
||||
cronEnabled={runtimeSupportsCron}
|
||||
agents={state.agents}
|
||||
standup={standupController}
|
||||
/>
|
||||
@@ -4323,6 +4593,7 @@ export function OfficeScreen({
|
||||
<AnalyticsPanel
|
||||
client={client}
|
||||
status={status}
|
||||
approvalsEnabled={runtimeSupportsApprovals}
|
||||
agents={state.agents}
|
||||
runLog={runLog}
|
||||
gatewayUrl={gatewayUrl}
|
||||
@@ -4661,9 +4932,9 @@ export function OfficeScreen({
|
||||
chatController.stopBusyAgentId === focusedChatAgent.agentId
|
||||
}
|
||||
onLoadMoreHistory={() => {}}
|
||||
onOpenSettings={() => {
|
||||
router.push("/office");
|
||||
}}
|
||||
onOpenSettings={() =>
|
||||
openAgentEditor(focusedChatAgent.agentId, "IDENTITY.md")
|
||||
}
|
||||
onNewSession={() =>
|
||||
chatController.handleNewSession(focusedChatAgent.agentId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"use client";
|
||||
|
||||
import type { DragEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { CronJobSummary } from "@/lib/cron/types";
|
||||
import type { TaskBoardCard, TaskBoardStatus } from "@/features/office/tasks/types";
|
||||
|
||||
const STATUS_LABELS: Record<TaskBoardStatus, string> = {
|
||||
todo: "Todo",
|
||||
in_progress: "In Progress",
|
||||
blocked: "Blocked",
|
||||
review: "Review",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
const STATUS_ORDER: TaskBoardStatus[] = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
];
|
||||
|
||||
const formatRelativeTime = (value: string | null) => {
|
||||
if (!value) return "No activity";
|
||||
const at = Date.parse(value);
|
||||
if (!Number.isFinite(at)) return "No activity";
|
||||
const delta = Math.max(0, Date.now() - at);
|
||||
if (delta < 60_000) return "Just now";
|
||||
if (delta < 3_600_000) return `${Math.max(1, Math.floor(delta / 60_000))}m ago`;
|
||||
if (delta < 86_400_000) return `${Math.max(1, Math.floor(delta / 3_600_000))}h ago`;
|
||||
return `${Math.max(1, Math.floor(delta / 86_400_000))}d ago`;
|
||||
};
|
||||
|
||||
const stopAndGetCardId = (event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return event.dataTransfer.getData("text/task-card-id").trim();
|
||||
};
|
||||
|
||||
export function TaskBoardView({
|
||||
title,
|
||||
subtitle,
|
||||
agents,
|
||||
cardsByStatus,
|
||||
selectedCard,
|
||||
activeRuns,
|
||||
cronJobs,
|
||||
cronLoading,
|
||||
cronError,
|
||||
taskCaptureDebug,
|
||||
onCreateCard,
|
||||
onMoveCard,
|
||||
onSelectCard,
|
||||
onUpdateCard,
|
||||
onDeleteCard,
|
||||
onRefreshCronJobs,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
agents: AgentState[];
|
||||
cardsByStatus: Record<TaskBoardStatus, TaskBoardCard[]>;
|
||||
selectedCard: TaskBoardCard | null;
|
||||
activeRuns: Array<{ runId: string; agentId: string; label: string }>;
|
||||
cronJobs: CronJobSummary[];
|
||||
cronLoading: boolean;
|
||||
cronError: string | null;
|
||||
taskCaptureDebug?: {
|
||||
lastStatus: "idle" | "detected" | "persisted" | "failed" | "unsupported";
|
||||
lastUpdatedAt: string | null;
|
||||
lastTitle: string | null;
|
||||
lastTaskId: string | null;
|
||||
lastSessionKey: string | null;
|
||||
lastMessage: string | null;
|
||||
detectedCount: number;
|
||||
visibleCardCount: number;
|
||||
totalCardCount: number;
|
||||
sharedTasksSupported: boolean;
|
||||
sharedTasksLoading: boolean;
|
||||
sharedTasksError: string | null;
|
||||
};
|
||||
onCreateCard: () => void;
|
||||
onMoveCard: (cardId: string, status: TaskBoardStatus) => void;
|
||||
onSelectCard: (cardId: string | null) => void;
|
||||
onUpdateCard: (cardId: string, patch: Partial<TaskBoardCard>) => void;
|
||||
onDeleteCard: (cardId: string) => void;
|
||||
onRefreshCronJobs: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-transparent text-white">
|
||||
<div className="border-b border-cyan-500/10 bg-[#070b11]/22 px-4 py-3 backdrop-blur-[1px]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.22em] text-cyan-200/80">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-white/40">{subtitle}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshCronJobs}
|
||||
className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white/70 transition-colors hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{cronLoading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : "Refresh"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateCard}
|
||||
className="inline-flex items-center gap-1 rounded border border-cyan-500/25 bg-cyan-500/10 px-2.5 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-100 transition-colors hover:border-cyan-400/50 hover:text-white"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{cronError ? (
|
||||
<div className="mt-2 rounded border border-rose-500/30 bg-rose-500/10 px-3 py-2 font-mono text-[11px] text-rose-100">
|
||||
{cronError}
|
||||
</div>
|
||||
) : null}
|
||||
{taskCaptureDebug ? (
|
||||
<details className="mt-2 rounded border border-amber-400/20 bg-amber-400/5 px-3 py-2 font-mono text-[11px] text-amber-50">
|
||||
<summary className="cursor-pointer list-none select-none">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] uppercase tracking-[0.14em] text-amber-100/75">
|
||||
<span>Capture debug</span>
|
||||
<span>Status: {taskCaptureDebug.lastStatus}</span>
|
||||
<span>Visible cards: {taskCaptureDebug.visibleCardCount}</span>
|
||||
<span>Tracked cards: {taskCaptureDebug.totalCardCount}</span>
|
||||
<span>Detected: {taskCaptureDebug.detectedCount}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="mt-2 grid gap-1 text-white/80">
|
||||
<div>
|
||||
Last request: {taskCaptureDebug.lastTitle ?? "None yet."}
|
||||
</div>
|
||||
<div>
|
||||
Last task id: {taskCaptureDebug.lastTaskId ?? "-"}
|
||||
</div>
|
||||
<div>
|
||||
Session/thread: {taskCaptureDebug.lastSessionKey ?? "-"}
|
||||
</div>
|
||||
<div>
|
||||
Last update: {formatRelativeTime(taskCaptureDebug.lastUpdatedAt)}
|
||||
</div>
|
||||
<div>
|
||||
Shared store:{" "}
|
||||
{taskCaptureDebug.sharedTasksSupported
|
||||
? taskCaptureDebug.sharedTasksLoading
|
||||
? "Syncing."
|
||||
: "Available."
|
||||
: "Unavailable."}
|
||||
</div>
|
||||
<div>
|
||||
Note: {taskCaptureDebug.lastMessage ?? "Waiting for inbound request detection."}
|
||||
</div>
|
||||
{taskCaptureDebug.sharedTasksError ? (
|
||||
<div className="text-rose-200">
|
||||
Store error: {taskCaptureDebug.sharedTasksError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`grid min-h-0 flex-1 overflow-hidden ${selectedCard ? "grid-cols-[minmax(0,1fr)_300px]" : "grid-cols-1"}`}>
|
||||
<div className="min-h-0 overflow-auto px-4 py-4">
|
||||
<div className="grid min-w-[700px] grid-cols-5 gap-3">
|
||||
{STATUS_ORDER.map((status) => {
|
||||
const cards = cardsByStatus[status];
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const cardId = stopAndGetCardId(event);
|
||||
if (!cardId) return;
|
||||
onMoveCard(cardId, status);
|
||||
}}
|
||||
className="flex min-h-[420px] flex-col rounded-xl border border-white/10 bg-black/14 backdrop-blur-[1px]"
|
||||
>
|
||||
<div className="border-b border-white/8 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/50">
|
||||
{STATUS_LABELS[status]}
|
||||
</div>
|
||||
<div className="rounded bg-white/8 px-1.5 py-0.5 font-mono text-[10px] text-white/60">
|
||||
{cards.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{cards.length === 0 ? (
|
||||
<div className="rounded border border-dashed border-white/10 px-3 py-4 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-white/25">
|
||||
Drop a card here.
|
||||
</div>
|
||||
) : (
|
||||
cards.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
type="button"
|
||||
draggable
|
||||
aria-label={`${card.title} — ${STATUS_LABELS[card.status]}. Arrow keys to move between columns.`}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData("text/task-card-id", card.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onClick={() => onSelectCard(selectedCard?.id === card.id ? null : card.id)}
|
||||
onKeyDown={(event: ReactKeyboardEvent) => {
|
||||
const currentIdx = STATUS_ORDER.indexOf(card.status);
|
||||
if (event.key === "ArrowRight" && currentIdx < STATUS_ORDER.length - 1) {
|
||||
event.preventDefault();
|
||||
onMoveCard(card.id, STATUS_ORDER[currentIdx + 1]!);
|
||||
} else if (event.key === "ArrowLeft" && currentIdx > 0) {
|
||||
event.preventDefault();
|
||||
onMoveCard(card.id, STATUS_ORDER[currentIdx - 1]!);
|
||||
}
|
||||
}}
|
||||
className={`flex w-full flex-col rounded-lg border px-3 py-3 text-left transition-colors ${
|
||||
selectedCard?.id === card.id
|
||||
? "border-cyan-400/35 bg-cyan-500/[0.10]"
|
||||
: "border-white/8 bg-black/12 hover:border-cyan-400/20 hover:bg-cyan-500/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="line-clamp-2 text-sm font-medium text-white/90">
|
||||
{card.title}
|
||||
</div>
|
||||
<span className="rounded border border-white/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.14em] text-white/50">
|
||||
{card.source.replaceAll("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
{card.description ? (
|
||||
<div className="mt-2 line-clamp-3 text-[12px] leading-5 text-white/55">
|
||||
{card.description}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-[0.12em] text-white/38">
|
||||
<span>{card.assignedAgentId ?? "Unassigned"}</span>
|
||||
{card.runId ? <span>Run linked.</span> : null}
|
||||
{card.playbookJobId ? <span>Playbook linked.</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[10px] text-white/32">
|
||||
{formatRelativeTime(card.lastActivityAt ?? card.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCard ? (
|
||||
<aside className="flex min-h-0 flex-col border-l border-white/8 bg-black/25">
|
||||
<div className="flex items-center justify-between border-b border-white/8 px-4 py-3">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/45">
|
||||
Task Details
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectCard(null)}
|
||||
className="font-mono text-[10px] uppercase tracking-[0.14em] text-white/40 hover:text-white/70"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Title
|
||||
</span>
|
||||
<input
|
||||
value={selectedCard.title}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, { title: event.target.value })
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Description
|
||||
</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={selectedCard.description}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, { description: event.target.value })
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Status
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.status}
|
||||
onChange={(event) =>
|
||||
onMoveCard(selectedCard.id, event.target.value as TaskBoardStatus)
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
{STATUS_ORDER.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{STATUS_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Assigned agent
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.assignedAgentId ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
assignedAgentId: event.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{agents.map((agent) => (
|
||||
<option key={agent.agentId} value={agent.agentId}>
|
||||
{agent.name || agent.agentId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Linked active run
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.runId ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, { runId: event.target.value || null })
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">No linked run</option>
|
||||
{activeRuns.map((run) => (
|
||||
<option key={run.runId} value={run.runId}>
|
||||
{run.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Linked playbook
|
||||
</span>
|
||||
<select
|
||||
value={selectedCard.playbookJobId ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
playbookJobId: event.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">No linked playbook</option>
|
||||
{cronJobs.map((job) => (
|
||||
<option key={job.id} value={job.id}>
|
||||
{job.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Channel
|
||||
</span>
|
||||
<input
|
||||
value={selectedCard.channel ?? ""}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
channel: event.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-white/35">
|
||||
Notes
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={selectedCard.notes.join("\n")}
|
||||
onChange={(event) =>
|
||||
onUpdateCard(selectedCard.id, {
|
||||
notes: event.target.value
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
className="rounded border border-white/10 bg-black/40 px-3 py-2 text-sm text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2 rounded border border-white/8 bg-white/[0.03] px-3 py-3 font-mono text-[10px] uppercase tracking-[0.14em] text-white/38">
|
||||
<div>Source: {selectedCard.source.replaceAll("_", " ")}.</div>
|
||||
<div>Created: {new Date(selectedCard.createdAt).toLocaleString()}.</div>
|
||||
<div>Updated: {new Date(selectedCard.updatedAt).toLocaleString()}.</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteCard(selectedCard.id)}
|
||||
className="inline-flex items-center gap-2 rounded border border-rose-500/25 bg-rose-500/10 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.16em] text-rose-100 transition-colors hover:border-rose-400/50 hover:text-white"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete Task
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
defaultTaskBoardPreference,
|
||||
type TaskBoardCard,
|
||||
type TaskBoardPreference,
|
||||
type TaskBoardStatus,
|
||||
} from "@/features/office/tasks/types";
|
||||
|
||||
type TaskBoardAction =
|
||||
| { type: "hydrate"; preference: TaskBoardPreference }
|
||||
| { type: "upsert"; card: TaskBoardCard }
|
||||
| { type: "upsertMany"; cards: TaskBoardCard[] }
|
||||
| { type: "update"; cardId: string; patch: Partial<TaskBoardCard> }
|
||||
| { type: "move"; cardId: string; status: TaskBoardStatus }
|
||||
| { type: "remove"; cardId: string }
|
||||
| { type: "select"; cardId: string | null };
|
||||
|
||||
const compareCards = (left: TaskBoardCard, right: TaskBoardCard) => {
|
||||
const leftArchived = left.isArchived ? 1 : 0;
|
||||
const rightArchived = right.isArchived ? 1 : 0;
|
||||
if (leftArchived !== rightArchived) return leftArchived - rightArchived;
|
||||
const leftAt = Date.parse(left.updatedAt) || 0;
|
||||
const rightAt = Date.parse(right.updatedAt) || 0;
|
||||
if (leftAt !== rightAt) return rightAt - leftAt;
|
||||
return left.title.localeCompare(right.title);
|
||||
};
|
||||
|
||||
export const sortTaskBoardCards = (cards: TaskBoardCard[]): TaskBoardCard[] =>
|
||||
[...cards].sort(compareCards);
|
||||
|
||||
export const upsertTaskBoardCard = (
|
||||
cards: TaskBoardCard[],
|
||||
nextCard: TaskBoardCard,
|
||||
): TaskBoardCard[] => {
|
||||
const cardId = nextCard.id.trim();
|
||||
if (!cardId) return cards;
|
||||
const existingIndex = cards.findIndex((card) => card.id === cardId);
|
||||
if (existingIndex < 0) return sortTaskBoardCards([...cards, nextCard]);
|
||||
const next = [...cards];
|
||||
next[existingIndex] = nextCard;
|
||||
return sortTaskBoardCards(next);
|
||||
};
|
||||
|
||||
export const taskBoardReducer = (
|
||||
state: TaskBoardPreference = defaultTaskBoardPreference(),
|
||||
action: TaskBoardAction,
|
||||
): TaskBoardPreference => {
|
||||
switch (action.type) {
|
||||
case "hydrate":
|
||||
return {
|
||||
cards: sortTaskBoardCards(action.preference.cards),
|
||||
selectedCardId: action.preference.selectedCardId,
|
||||
};
|
||||
case "upsert":
|
||||
return {
|
||||
...state,
|
||||
cards: upsertTaskBoardCard(state.cards, action.card),
|
||||
};
|
||||
case "upsertMany": {
|
||||
let cards = state.cards;
|
||||
for (const card of action.cards) {
|
||||
cards = upsertTaskBoardCard(cards, card);
|
||||
}
|
||||
return { ...state, cards };
|
||||
}
|
||||
case "update": {
|
||||
const existing = state.cards.find((card) => card.id === action.cardId);
|
||||
if (!existing) return state;
|
||||
return {
|
||||
...state,
|
||||
cards: upsertTaskBoardCard(state.cards, {
|
||||
...existing,
|
||||
...action.patch,
|
||||
updatedAt: action.patch.updatedAt ?? new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "move": {
|
||||
const existing = state.cards.find((card) => card.id === action.cardId);
|
||||
if (!existing) return state;
|
||||
return {
|
||||
...state,
|
||||
cards: upsertTaskBoardCard(state.cards, {
|
||||
...existing,
|
||||
status: action.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "remove": {
|
||||
const cards = state.cards.filter((card) => card.id !== action.cardId);
|
||||
return {
|
||||
cards,
|
||||
selectedCardId:
|
||||
state.selectedCardId === action.cardId ? cards[0]?.id ?? null : state.selectedCardId,
|
||||
};
|
||||
}
|
||||
case "select":
|
||||
return {
|
||||
...state,
|
||||
selectedCardId: action.cardId,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { EventFrame } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
export const TASK_BOARD_STATUSES = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export type TaskBoardStatus = (typeof TASK_BOARD_STATUSES)[number];
|
||||
|
||||
export const TASK_BOARD_SOURCES = [
|
||||
"openclaw_event",
|
||||
"claw3d_manual",
|
||||
"playbook",
|
||||
"fallback_inferred",
|
||||
] as const;
|
||||
|
||||
export type TaskBoardSource = (typeof TASK_BOARD_SOURCES)[number];
|
||||
|
||||
export type TaskBoardCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskBoardStatus;
|
||||
source: TaskBoardSource;
|
||||
sourceEventId: string | null;
|
||||
assignedAgentId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
playbookJobId: string | null;
|
||||
runId: string | null;
|
||||
channel: string | null;
|
||||
externalThreadId: string | null;
|
||||
lastActivityAt: string | null;
|
||||
notes: string[];
|
||||
isArchived: boolean;
|
||||
isInferred: boolean;
|
||||
};
|
||||
|
||||
export type TaskBoardPreference = {
|
||||
cards: TaskBoardCard[];
|
||||
selectedCardId: string | null;
|
||||
};
|
||||
|
||||
export type TaskBoardPreferencePatch = {
|
||||
cards?: TaskBoardCard[];
|
||||
selectedCardId?: string | null;
|
||||
};
|
||||
|
||||
export type TaskBoardExplicitEventKind =
|
||||
| "task_created"
|
||||
| "task_updated"
|
||||
| "task_status_changed"
|
||||
| "task_assigned"
|
||||
| "task_linked_to_run"
|
||||
| "task_deleted"
|
||||
| "task_archived"
|
||||
| "playbook_triggered";
|
||||
|
||||
export type TaskBoardExplicitEvent = {
|
||||
kind: TaskBoardExplicitEventKind;
|
||||
frame: EventFrame;
|
||||
taskId: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
status?: TaskBoardStatus | null;
|
||||
assignedAgentId?: string | null;
|
||||
playbookJobId?: string | null;
|
||||
runId?: string | null;
|
||||
channel?: string | null;
|
||||
externalThreadId?: string | null;
|
||||
occurredAt: string;
|
||||
sourceEventId: string;
|
||||
archived?: boolean;
|
||||
};
|
||||
|
||||
export const defaultTaskBoardPreference = (): TaskBoardPreference => ({
|
||||
cards: [],
|
||||
selectedCardId: null,
|
||||
});
|
||||
|
||||
export const isTaskBoardStatus = (value: unknown): value is TaskBoardStatus =>
|
||||
typeof value === "string" &&
|
||||
(TASK_BOARD_STATUSES as readonly string[]).includes(value);
|
||||
|
||||
export const isTaskBoardSource = (value: unknown): value is TaskBoardSource =>
|
||||
typeof value === "string" &&
|
||||
(TASK_BOARD_SOURCES as readonly string[]).includes(value);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ export const CompanyStep = ({
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Improve the brief",
|
||||
description: "Use your connected OpenClaw runtime to sharpen the company prompt.",
|
||||
description: "Use your connected runtime to sharpen the company prompt.",
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
@@ -45,7 +45,7 @@ export const CompanyStep = ({
|
||||
{
|
||||
icon: Wand2,
|
||||
title: "Create everything",
|
||||
description: "Write agent files and create the team directly in OpenClaw.",
|
||||
description: "Write agent files and create the team directly in the connected runtime.",
|
||||
},
|
||||
].map(({ icon: Icon, title, description }) => (
|
||||
<div
|
||||
@@ -73,7 +73,7 @@ export const CompanyStep = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-100/80">
|
||||
Connect to OpenClaw and keep at least one planning agent available to generate the
|
||||
Connect to a runtime and keep at least one planning agent available to generate the
|
||||
company with AI.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const CompleteStep = ({
|
||||
</p>
|
||||
<p className="max-w-sm text-sm text-white/60">
|
||||
{companyCreated
|
||||
? `${companyName?.trim() || "Your company"} is ready. Your new team has been created in OpenClaw and placed into the office.`
|
||||
? `${companyName?.trim() || "Your company"} is ready. Your new team has been created in the connected runtime and placed into the office.`
|
||||
: "Your gateway is connected and your agents are ready. Step inside and explore the 3D workspace where your AI team operates."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -32,11 +32,11 @@ export const WelcomeStep = () => (
|
||||
<p className="text-sm leading-relaxed text-white/80">
|
||||
Claw3D turns your AI automation into a{" "}
|
||||
<span className="font-medium text-white">visual workplace</span> — an
|
||||
office where your OpenClaw agents collaborate, code, test, and execute
|
||||
office where your AI agents collaborate, code, test, and execute
|
||||
tasks in a shared 3D environment.
|
||||
</p>
|
||||
<p className="text-sm text-white/60">
|
||||
This wizard will help you connect to your OpenClaw gateway and get
|
||||
This wizard will help you connect to your runtime gateway and get
|
||||
started in about two minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
id: "connect",
|
||||
title: "Connect Your Gateway",
|
||||
description: "Link to your OpenClaw instance",
|
||||
description: "Link to your runtime instance",
|
||||
skippable: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Uses localStorage so the wizard only shows once per browser.
|
||||
* The key is scoped to the Claw3D app to avoid collisions.
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "claw3d:onboarding:completed";
|
||||
|
||||
@@ -40,7 +40,11 @@ export type OnboardingStateReturn = {
|
||||
};
|
||||
|
||||
export const useOnboardingState = (): OnboardingStateReturn => {
|
||||
const [completed, setCompleted] = useState(readCompleted);
|
||||
const [completed, setCompleted] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCompleted(readCompleted());
|
||||
}, []);
|
||||
|
||||
const completeOnboarding = useCallback(() => {
|
||||
setCompleted(true);
|
||||
@@ -53,7 +57,7 @@ export const useOnboardingState = (): OnboardingStateReturn => {
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showOnboarding: !completed,
|
||||
showOnboarding: completed === false,
|
||||
completeOnboarding,
|
||||
resetOnboarding,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,13 @@ const DEFAULT_JUKEBOX: FurnitureSeed = {
|
||||
facing: 90,
|
||||
};
|
||||
|
||||
const DEFAULT_KANBAN_BOARD: FurnitureSeed = {
|
||||
type: "kanban_board",
|
||||
x: 460,
|
||||
y: -60,
|
||||
facing: 180,
|
||||
};
|
||||
|
||||
const PREVIOUS_SERVER_ROOM_ITEMS_BOTTOM_RIGHT: FurnitureSeed[] = [
|
||||
{ type: "wall", x: 820, y: 540, w: 280, h: WALL_THICKNESS },
|
||||
{ type: "wall", x: 820, y: 540, w: WALL_THICKNESS, h: 70 },
|
||||
@@ -419,9 +426,8 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
|
||||
{ type: "chair", x: 120, y: 480, facing: 180 },
|
||||
{ type: "chair", x: 50, y: 150, facing: 105 },
|
||||
{ type: "chair", x: 60, y: 80, facing: 60 },
|
||||
{ type: "executive_desk", x: 420, y: 60, w: 130, h: 65 },
|
||||
{ type: "chair", x: 540, y: 60, facing: 0 },
|
||||
{ type: "bookshelf", x: 500, y: 30, w: 80, h: 120 },
|
||||
{ type: "chair", x: 550, y: 50, facing: 0 },
|
||||
{ type: "bookshelf", x: 600, y: 30, w: 80, h: 120 },
|
||||
{ type: "couch", x: 270, y: 90, w: 40, h: 80, vertical: true, facing: 180 },
|
||||
{ type: "fridge", x: 1050, y: 20, w: 40, h: 80 },
|
||||
{ type: "stove", x: 920, y: 20 },
|
||||
@@ -433,7 +439,11 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
|
||||
{ type: "coffee_machine", x: 880, y: 30, elevation: 0.56 },
|
||||
{ type: "wall_cabinet", x: 960, y: 10, w: 80, h: 20, elevation: 0.9 },
|
||||
{ type: "wall_cabinet", x: 880, y: 10, w: 80, h: 20, elevation: 0.9 },
|
||||
...DEFAULT_DINING_ITEMS,
|
||||
{ type: "round_table", x: 890, y: 100, r: 50 },
|
||||
{ type: "chair", x: 930, y: 100, facing: 0 },
|
||||
{ type: "chair", x: 930, y: 180, facing: 180 },
|
||||
{ type: "chair", x: 880, y: 130, facing: 90 },
|
||||
{ type: "chair", x: 970, y: 130, facing: 270 },
|
||||
{ type: "vending", x: 790, y: 10 },
|
||||
{ type: "trash", x: 210, y: 20 },
|
||||
{ type: "desk_cubicle", x: 100, y: 300, id: "desk_0" },
|
||||
@@ -486,11 +496,12 @@ const DEFAULT_FURNITURE: FurnitureSeed[] = [
|
||||
{ type: "couch", x: 1000, y: 380, w: 100, h: 40, facing: 90 },
|
||||
{ type: "couch", x: 390, y: 630, w: 100, h: 40 },
|
||||
{ type: "table_rect", x: 980, y: 380, w: 60, h: 30, facing: 270 },
|
||||
DEFAULT_PINGPONG_TABLE,
|
||||
{ type: "pingpong", x: 950, y: 600, w: 100, h: 60 },
|
||||
{ type: "beanbag", x: 1000, y: 330, color: "#e65100", facing: 90 },
|
||||
{ type: "beanbag", x: 1000, y: 410, color: "#1565c0", facing: 90 },
|
||||
DEFAULT_ATM_MACHINE,
|
||||
DEFAULT_PHONE_BOOTH,
|
||||
DEFAULT_KANBAN_BOARD,
|
||||
{ type: "whiteboard", x: 40, y: 200, w: 10, h: 60 },
|
||||
{ type: "clock", x: 550, y: 5 },
|
||||
{ type: "lamp", x: 430, y: 100 },
|
||||
@@ -595,6 +606,11 @@ export const ensureOfficeJukebox = (items: FurnitureItem[]): FurnitureItem[] =>
|
||||
return [...items, { ...DEFAULT_JUKEBOX, _uid: nextUid() }];
|
||||
};
|
||||
|
||||
export const ensureOfficeKanbanBoard = (items: FurnitureItem[]): FurnitureItem[] => {
|
||||
if (items.some((item) => item.type === "kanban_board")) return items;
|
||||
return [...items, { ...DEFAULT_KANBAN_BOARD, _uid: nextUid() }];
|
||||
};
|
||||
|
||||
export const ensureOfficePhoneBooth = (
|
||||
items: FurnitureItem[],
|
||||
): FurnitureItem[] => {
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ITEM_FOOTPRINT: Record<string, [number, number]> = {
|
||||
server_rack: [45, 90],
|
||||
server_terminal: [42, 34],
|
||||
qa_terminal: [54, 38],
|
||||
kanban_board: [130, 65],
|
||||
device_rack: [70, 36],
|
||||
test_bench: [90, 42],
|
||||
treadmill: [70, 35],
|
||||
@@ -149,6 +150,7 @@ export const ITEM_METADATA: Record<string, { blocksNavigation: boolean; navPaddi
|
||||
phone_booth: { blocksNavigation: true },
|
||||
// ── QA lab ────────────────────────────────────────────────────────────────
|
||||
qa_terminal: { blocksNavigation: true },
|
||||
kanban_board: { blocksNavigation: true },
|
||||
device_rack: { blocksNavigation: true },
|
||||
test_bench: { blocksNavigation: true },
|
||||
// ── gym ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OfficeInteractionTargetId } from "@/lib/office/places";
|
||||
export type OfficeAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
status: "working" | "idle" | "error";
|
||||
color: string;
|
||||
item: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ const formatAgentNameplateText = (value: string): string => {
|
||||
export const AgentModel = memo(function AgentModel({
|
||||
agentId,
|
||||
name,
|
||||
subtitle,
|
||||
status,
|
||||
color,
|
||||
appearance,
|
||||
@@ -640,6 +641,7 @@ export const AgentModel = memo(function AgentModel({
|
||||
: "transparent";
|
||||
const speechBubbleBorderInset = activeSpeechBubble ? 0.03 : 0;
|
||||
const nameplateText = name ? formatAgentNameplateText(name) : "";
|
||||
const subtitleText = typeof subtitle === "string" ? subtitle.trim() : "";
|
||||
const nameplateFontSize =
|
||||
nameplateText.length > 9 ? 0.118 : nameplateText.length > 7 ? 0.13 : 0.144;
|
||||
|
||||
@@ -1070,19 +1072,19 @@ export const AgentModel = memo(function AgentModel({
|
||||
{!activeSpeechBubble && nameplateText ? (
|
||||
<Billboard position={[0, 1.05, 0]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[0.82, 0.24]} />
|
||||
<planeGeometry args={[0.82, subtitleText ? 0.34 : 0.24]} />
|
||||
<meshBasicMaterial color="#080c14" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
<mesh position={[-0.392, 0, 0]}>
|
||||
<planeGeometry args={[0.028, 0.24]} />
|
||||
<planeGeometry args={[0.028, subtitleText ? 0.34 : 0.24]} />
|
||||
<meshBasicMaterial color={color} />
|
||||
</mesh>
|
||||
<mesh position={[0.355, 0, 0]}>
|
||||
<mesh position={[0.355, subtitleText ? 0.05 : 0, 0]}>
|
||||
<circleGeometry args={[0.052, 14]} />
|
||||
<meshBasicMaterial ref={statusDotMatRef} color="#ef4444" />
|
||||
</mesh>
|
||||
<Text
|
||||
position={[-0.02, 0, 0.001]}
|
||||
position={[-0.02, subtitleText ? 0.05 : 0, 0.001]}
|
||||
fontSize={nameplateFontSize}
|
||||
color="#e8dfc0"
|
||||
anchorX="center"
|
||||
@@ -1092,6 +1094,19 @@ export const AgentModel = memo(function AgentModel({
|
||||
>
|
||||
{nameplateText}
|
||||
</Text>
|
||||
{subtitleText ? (
|
||||
<Text
|
||||
position={[-0.02, -0.085, 0.001]}
|
||||
fontSize={0.082}
|
||||
color="#8ab4ff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
maxWidth={0.68}
|
||||
font={undefined}
|
||||
>
|
||||
{subtitleText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Billboard>
|
||||
) : null}
|
||||
<group ref={awayBubbleRef} visible={false}>
|
||||
|
||||
@@ -31,6 +31,7 @@ export const FURNITURE_GLB: Record<string, string> = {
|
||||
fridge: "/office-assets/models/furniture/kitchenFridgeSmall.glb",
|
||||
water_cooler: "/office-assets/models/furniture/plantSmall1.glb",
|
||||
whiteboard: "/office-assets/models/furniture/bookcaseClosed.glb",
|
||||
kanban_board: "/office-assets/models/furniture/deskCorner.glb",
|
||||
cabinet: "/office-assets/models/furniture/kitchenCabinet.glb",
|
||||
computer: "/office-assets/models/furniture/computerScreen.glb",
|
||||
lamp: "/office-assets/models/furniture/lampRoundFloor.glb",
|
||||
@@ -53,6 +54,7 @@ export const FURNITURE_SCALE: Record<string, [number, number, number]> = {
|
||||
fridge: [1, 1.4, 1],
|
||||
water_cooler: [1, 2, 1],
|
||||
whiteboard: [0.6, 1.4, 0.3],
|
||||
kanban_board: [1.8, 1.8, 1.8],
|
||||
cabinet: [2.6, 1.2, 1],
|
||||
computer: [1.1, 1.1, 1.1],
|
||||
lamp: [1.2, 1.2, 1.2],
|
||||
@@ -63,6 +65,9 @@ export const FURNITURE_Y_OFFSET: Record<string, number> = {
|
||||
computer: 0.61,
|
||||
};
|
||||
|
||||
/** Global offset for all kanban desk clutter (papers, monitor, mug, etc.). */
|
||||
export const KANBAN_CLUTTER_OFFSET = { x: -1, y: 1, z: -2 };
|
||||
|
||||
export const FURNITURE_TINT: Record<string, string | null> = {
|
||||
desk_cubicle: "#8b5e32",
|
||||
executive_desk: "#6b3c1a",
|
||||
@@ -79,6 +84,7 @@ export const FURNITURE_TINT: Record<string, string | null> = {
|
||||
fridge: "#505a60",
|
||||
water_cooler: "#3a5070",
|
||||
whiteboard: "#f4f2ee",
|
||||
kanban_board: "#8b5e32",
|
||||
cabinet: "#3c4248",
|
||||
plant: null,
|
||||
lamp: "#c8a060",
|
||||
@@ -144,7 +150,9 @@ const resolveFurnitureTemplate = (params: {
|
||||
};
|
||||
return nextMaterial;
|
||||
});
|
||||
mesh.material = Array.isArray(mesh.material) ? templateMats : templateMats[0];
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? templateMats
|
||||
: templateMats[0];
|
||||
});
|
||||
|
||||
furnitureTemplateCache.set(cacheKey, template);
|
||||
@@ -163,8 +171,16 @@ const buildFurnitureItemMatrix = (item: FurnitureItem, itemType: string) => {
|
||||
const containerMatrix = new THREE.Matrix4().makeTranslation(wx, yOffset, wz);
|
||||
const pivotMatrix = new THREE.Matrix4().makeTranslation(pivotX, 0, pivotZ);
|
||||
const rotationMatrix = new THREE.Matrix4().makeRotationY(rotY);
|
||||
const unpivotMatrix = new THREE.Matrix4().makeTranslation(-pivotX, 0, -pivotZ);
|
||||
const scaleMatrix = new THREE.Matrix4().makeScale(scale[0], scale[1], scale[2]);
|
||||
const unpivotMatrix = new THREE.Matrix4().makeTranslation(
|
||||
-pivotX,
|
||||
0,
|
||||
-pivotZ,
|
||||
);
|
||||
const scaleMatrix = new THREE.Matrix4().makeScale(
|
||||
scale[0],
|
||||
scale[1],
|
||||
scale[2],
|
||||
);
|
||||
|
||||
return containerMatrix
|
||||
.multiply(pivotMatrix)
|
||||
@@ -274,6 +290,7 @@ export function FurnitureModel({
|
||||
isSelected,
|
||||
isHovered,
|
||||
editMode,
|
||||
kanbanTaskCount = 0,
|
||||
onPointerDown,
|
||||
onPointerOver,
|
||||
onPointerOut,
|
||||
@@ -300,18 +317,159 @@ export function FurnitureModel({
|
||||
const { width, height } = getItemBaseSize(item);
|
||||
const pivotX = width * SCALE * 0.5;
|
||||
const pivotZ = height * SCALE * 0.5;
|
||||
const kanbanDeskLoadout = useMemo(() => {
|
||||
const visibleTaskCount = Math.max(0, Math.min(kanbanTaskCount, 12));
|
||||
if (visibleTaskCount === 0) {
|
||||
return {
|
||||
papers: [] as Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
h: number;
|
||||
r: number;
|
||||
color: string;
|
||||
}>,
|
||||
folders: [] as Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
h: number;
|
||||
d: number;
|
||||
color: string;
|
||||
r: number;
|
||||
}>,
|
||||
stickyNotes: [] as Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
color: string;
|
||||
r: number;
|
||||
}>,
|
||||
binders: [] as Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
h: number;
|
||||
d: number;
|
||||
color: string;
|
||||
r: number;
|
||||
}>,
|
||||
};
|
||||
}
|
||||
|
||||
const cx = KANBAN_CLUTTER_OFFSET.x;
|
||||
const cy = KANBAN_CLUTTER_OFFSET.y;
|
||||
const cz = KANBAN_CLUTTER_OFFSET.z;
|
||||
|
||||
const papers = Array.from(
|
||||
{ length: Math.min(visibleTaskCount + 2, 14) },
|
||||
(_, index) => {
|
||||
const row = index % 4;
|
||||
const stack = Math.floor(index / 4);
|
||||
return {
|
||||
x: cx + -0.22 + row * 0.16 + (stack % 2) * 0.03,
|
||||
z: cz + 0.06 - stack * 0.12 + (row % 2) * 0.02,
|
||||
y: cy + stack * 0.007 + index * 0.0015,
|
||||
w: 0.17 + (index % 3) * 0.02,
|
||||
h: 0.12 + ((index + 1) % 2) * 0.02,
|
||||
r: -0.2 + row * 0.08 + stack * 0.03,
|
||||
color: ["#fff7df", "#f6edd2", "#efe4c7", "#fffaf0"][index % 4]!,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const folders = [
|
||||
{
|
||||
x: cx + 0.28,
|
||||
y: cy + 0.013,
|
||||
z: cz + 0.0,
|
||||
w: 0.24,
|
||||
h: 0.17,
|
||||
d: 0.035,
|
||||
color: "#d6a447",
|
||||
r: 0.16,
|
||||
},
|
||||
...(visibleTaskCount >= 5
|
||||
? [
|
||||
{
|
||||
x: cx + 0.06,
|
||||
y: cy + 0.018,
|
||||
z: cz + 0.14,
|
||||
w: 0.22,
|
||||
h: 0.16,
|
||||
d: 0.04,
|
||||
color: "#9d5f3f",
|
||||
r: -0.08,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const stickyNotes = Array.from(
|
||||
{ length: Math.min(2 + Math.floor(visibleTaskCount / 3), 5) },
|
||||
(_, index) => ({
|
||||
x: cx + -0.1 + index * 0.08,
|
||||
y: cy + 0.012 + index * 0.002,
|
||||
z: cz + -0.14 - (index % 2) * 0.04,
|
||||
color: ["#f7db5e", "#ffb35c", "#97d7f6", "#c0e56e", "#ff8fa3"][
|
||||
index % 5
|
||||
]!,
|
||||
r: -0.15 + index * 0.08,
|
||||
}),
|
||||
);
|
||||
|
||||
const binders =
|
||||
visibleTaskCount >= 7
|
||||
? [
|
||||
{
|
||||
x: cx + -0.24,
|
||||
y: cy + 0.04,
|
||||
z: cz + -0.06,
|
||||
w: 0.12,
|
||||
h: 0.12,
|
||||
d: 0.18,
|
||||
color: "#5d7bb0",
|
||||
r: -0.08,
|
||||
},
|
||||
{
|
||||
x: cx + -0.14,
|
||||
y: cy + 0.047,
|
||||
z: cz + -0.1,
|
||||
w: 0.12,
|
||||
h: 0.13,
|
||||
d: 0.19,
|
||||
color: "#6f8b3d",
|
||||
r: 0.03,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
papers,
|
||||
folders,
|
||||
stickyNotes,
|
||||
binders,
|
||||
};
|
||||
}, [kanbanTaskCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const highlightActive = isSelected || (isHovered && editMode);
|
||||
cloned.traverse((child) => {
|
||||
if (!(child as THREE.Mesh).isMesh) return;
|
||||
const mesh = child as THREE.Mesh;
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
|
||||
const mats = Array.isArray(mesh.material)
|
||||
? mesh.material
|
||||
: [mesh.material];
|
||||
const nextMats = mats.map((material) => {
|
||||
if (!(material instanceof THREE.MeshStandardMaterial)) {
|
||||
return material;
|
||||
}
|
||||
const hasOwnMaterial = Boolean(material.userData?.furnitureInstanceMaterial);
|
||||
const hasOwnMaterial = Boolean(
|
||||
material.userData?.furnitureInstanceMaterial,
|
||||
);
|
||||
let nextMaterial = material;
|
||||
if (highlightActive && !hasOwnMaterial) {
|
||||
nextMaterial = material.clone();
|
||||
@@ -363,6 +521,196 @@ export function FurnitureModel({
|
||||
<group position={[-pivotX, 0, -pivotZ]} scale={scale}>
|
||||
<primitive object={cloned} />
|
||||
</group>
|
||||
{itemType === "kanban_board" ? (
|
||||
<>
|
||||
{kanbanTaskCount > 0 ? (
|
||||
<>
|
||||
{/* Monitor. */}
|
||||
<mesh
|
||||
position={[
|
||||
KANBAN_CLUTTER_OFFSET.x + 0.02,
|
||||
KANBAN_CLUTTER_OFFSET.y + 0.1,
|
||||
KANBAN_CLUTTER_OFFSET.z + -0.16,
|
||||
]}
|
||||
rotation={[0, -0.28, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[0.22, 0.16, 0.03]} />
|
||||
<meshStandardMaterial
|
||||
color="#30374a"
|
||||
roughness={0.48}
|
||||
metalness={0.18}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Keyboard. */}
|
||||
<mesh
|
||||
position={[
|
||||
KANBAN_CLUTTER_OFFSET.x + 0.02,
|
||||
KANBAN_CLUTTER_OFFSET.y + 0.01,
|
||||
KANBAN_CLUTTER_OFFSET.z + -0.03,
|
||||
]}
|
||||
rotation={[-Math.PI / 2, -0.1, 0]}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[0.22, 0.018, 0.09]} />
|
||||
<meshStandardMaterial
|
||||
color="#d8dce4"
|
||||
roughness={0.82}
|
||||
metalness={0.08}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Mug. */}
|
||||
<mesh
|
||||
position={[
|
||||
KANBAN_CLUTTER_OFFSET.x + 0.24,
|
||||
KANBAN_CLUTTER_OFFSET.y + 0.03,
|
||||
KANBAN_CLUTTER_OFFSET.z + -0.17,
|
||||
]}
|
||||
rotation={[-Math.PI / 2, 0.14, 0]}
|
||||
castShadow
|
||||
>
|
||||
<cylinderGeometry args={[0.04, 0.04, 0.09, 18]} />
|
||||
<meshStandardMaterial
|
||||
color="#2d4f73"
|
||||
roughness={0.68}
|
||||
metalness={0.12}
|
||||
/>
|
||||
</mesh>
|
||||
{/* Book stack. */}
|
||||
<mesh
|
||||
position={[
|
||||
KANBAN_CLUTTER_OFFSET.x + 0.34,
|
||||
KANBAN_CLUTTER_OFFSET.y + 0.04,
|
||||
KANBAN_CLUTTER_OFFSET.z + -0.06,
|
||||
]}
|
||||
rotation={[0, 0.2, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[0.17, 0.05, 0.24]} />
|
||||
<meshStandardMaterial
|
||||
color="#bcc5d0"
|
||||
roughness={0.78}
|
||||
metalness={0.12}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh
|
||||
position={[
|
||||
KANBAN_CLUTTER_OFFSET.x + 0.34,
|
||||
KANBAN_CLUTTER_OFFSET.y + 0.07,
|
||||
KANBAN_CLUTTER_OFFSET.z + -0.06,
|
||||
]}
|
||||
rotation={[0, 0.2, 0]}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[0.17, 0.012, 0.24]} />
|
||||
<meshStandardMaterial
|
||||
color="#eef2f4"
|
||||
roughness={0.92}
|
||||
metalness={0.03}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh
|
||||
position={[
|
||||
KANBAN_CLUTTER_OFFSET.x + 0.34,
|
||||
KANBAN_CLUTTER_OFFSET.y + 0.095,
|
||||
KANBAN_CLUTTER_OFFSET.z + -0.06,
|
||||
]}
|
||||
rotation={[0, 0.2, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[0.17, 0.05, 0.24]} />
|
||||
<meshStandardMaterial
|
||||
color="#cbd3db"
|
||||
roughness={0.8}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh
|
||||
position={[
|
||||
KANBAN_CLUTTER_OFFSET.x + 0.34,
|
||||
KANBAN_CLUTTER_OFFSET.y + 0.125,
|
||||
KANBAN_CLUTTER_OFFSET.z + -0.06,
|
||||
]}
|
||||
rotation={[0, 0.2, 0]}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[0.17, 0.012, 0.24]} />
|
||||
<meshStandardMaterial
|
||||
color="#fffdf7"
|
||||
roughness={0.94}
|
||||
metalness={0.02}
|
||||
/>
|
||||
</mesh>
|
||||
</>
|
||||
) : null}
|
||||
{kanbanDeskLoadout.papers.map((paper, index) => (
|
||||
<mesh
|
||||
key={`kanban-paper-${index}`}
|
||||
position={[paper.x, paper.y, paper.z]}
|
||||
rotation={[-Math.PI / 2, paper.r, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[paper.w, 0.018, paper.h]} />
|
||||
<meshStandardMaterial
|
||||
color={paper.color}
|
||||
roughness={0.94}
|
||||
metalness={0.02}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
{kanbanDeskLoadout.folders.map((folder, index) => (
|
||||
<mesh
|
||||
key={`kanban-folder-${index}`}
|
||||
position={[folder.x, folder.y, folder.z]}
|
||||
rotation={[-Math.PI / 2, folder.r, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[folder.w, folder.d, folder.h]} />
|
||||
<meshStandardMaterial
|
||||
color={folder.color}
|
||||
roughness={0.84}
|
||||
metalness={0.06}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
{kanbanDeskLoadout.stickyNotes.map((note, index) => (
|
||||
<mesh
|
||||
key={`kanban-sticky-${index}`}
|
||||
position={[note.x, note.y, note.z]}
|
||||
rotation={[-Math.PI / 2, note.r, 0]}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[0.075, 0.014, 0.075]} />
|
||||
<meshStandardMaterial
|
||||
color={note.color}
|
||||
roughness={0.95}
|
||||
metalness={0.01}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
{kanbanDeskLoadout.binders.map((binder, index) => (
|
||||
<mesh
|
||||
key={`kanban-binder-${index}`}
|
||||
position={[binder.x, binder.y, binder.z]}
|
||||
rotation={[0, binder.r, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
>
|
||||
<boxGeometry args={[binder.w, binder.h, binder.d]} />
|
||||
<meshStandardMaterial
|
||||
color={binder.color}
|
||||
roughness={0.74}
|
||||
metalness={0.08}
|
||||
/>
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
@@ -402,4 +750,6 @@ export function PlacementGhost({
|
||||
);
|
||||
}
|
||||
|
||||
[...new Set(Object.values(FURNITURE_GLB))].forEach((path) => useGLTF.preload(path));
|
||||
[...new Set(Object.values(FURNITURE_GLB))].forEach((path) =>
|
||||
useGLTF.preload(path),
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export type InteractiveFurnitureModelProps = {
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
editMode: boolean;
|
||||
kanbanTaskCount?: number;
|
||||
doorOpen?: boolean;
|
||||
onPointerDown: (uid: string) => void;
|
||||
onPointerOver: (uid: string) => void;
|
||||
@@ -29,6 +30,7 @@ export type InteractiveFurnitureModelProps = {
|
||||
export type AgentModelProps = {
|
||||
agentId: string;
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
status: OfficeAgent["status"];
|
||||
color: string;
|
||||
appearance?: AgentAvatarProfile | null;
|
||||
|
||||
@@ -245,22 +245,24 @@ export function DeskNameplates({
|
||||
const [wx, , wz] = toWorld(desk.x, desk.y);
|
||||
|
||||
return (
|
||||
<Billboard key={`nameplate-${index}`} position={[wx, 0.55, wz]}>
|
||||
<Billboard key={`nameplate-${index}`} position={[wx, 1.02, wz]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<planeGeometry args={[1.1, 0.18]} />
|
||||
<meshBasicMaterial color="#0a0804" transparent opacity={0.75} />
|
||||
<planeGeometry args={[0.74, 0.16]} />
|
||||
<meshBasicMaterial color="#050403" transparent opacity={0.86} />
|
||||
</mesh>
|
||||
<mesh position={[-0.52, 0, 0]}>
|
||||
<planeGeometry args={[0.04, 0.18]} />
|
||||
<mesh position={[-0.35, 0, 0]}>
|
||||
<planeGeometry args={[0.035, 0.16]} />
|
||||
<meshBasicMaterial color={agent.color} />
|
||||
</mesh>
|
||||
<Text
|
||||
position={[0.02, 0, 0.001]}
|
||||
fontSize={0.09}
|
||||
color="#c8a860"
|
||||
position={[0.01, 0.002, 0.001]}
|
||||
fontSize={0.11}
|
||||
color="#fff6d8"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
maxWidth={1.0}
|
||||
maxWidth={0.64}
|
||||
outlineWidth={0.007}
|
||||
outlineColor="#16110a"
|
||||
font={undefined}
|
||||
overflowWrap="break-word"
|
||||
whiteSpace="nowrap"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { registerOTel } from "@vercel/otel";
|
||||
// Telemetry intentionally disabled in this fork.
|
||||
// Keep Next's instrumentation hook present, but do not register
|
||||
// any external telemetry providers.
|
||||
|
||||
export const register = () => {
|
||||
registerOTel({ serviceName: "claw3d" });
|
||||
};
|
||||
export const register = () => {};
|
||||
|
||||
@@ -72,5 +72,5 @@ export const AGENT_FILE_PLACEHOLDERS: Record<AgentFileName, string> = {
|
||||
|
||||
export const createAgentFilesState = () =>
|
||||
Object.fromEntries(
|
||||
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false }])
|
||||
) as Record<AgentFileName, { content: string; exists: boolean }>;
|
||||
AGENT_FILE_NAMES.map((name) => [name, { content: "", exists: false, path: null, workspace: null }])
|
||||
) as Record<AgentFileName, { content: string; exists: boolean; path: string | null; workspace: string | null }>;
|
||||
|
||||
@@ -28,7 +28,10 @@ export type PersonalityBuilderDraft = {
|
||||
memory: string;
|
||||
};
|
||||
|
||||
type AgentFilesInput = Record<AgentFileName, { content: string; exists: boolean }>;
|
||||
type AgentFilesInput = Record<
|
||||
AgentFileName,
|
||||
{ content: string; exists: boolean; path: string | null; workspace: string | null }
|
||||
>;
|
||||
|
||||
export const createEmptyPersonalityDraft = (): PersonalityBuilderDraft => ({
|
||||
identity: {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { AgentAvatarProfile } from "./profile";
|
||||
|
||||
const AVATAR_BG = "#070b16";
|
||||
const EYE_COLOR = "#111827";
|
||||
const HEADSET_BAND = "#94a3b8";
|
||||
const HEADSET_PAD = "#475569";
|
||||
const MOUTH_COLOR = "#9c4a4a";
|
||||
|
||||
const escapeXml = (value: string) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
const normalizeHex = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
|
||||
const [, r, g, b] = trimmed;
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const blendHex = (source: string, target: string, weight: number): string => {
|
||||
const sourceHex = normalizeHex(source);
|
||||
const targetHex = normalizeHex(target);
|
||||
if (!sourceHex || !targetHex) return source;
|
||||
const ratio = Math.max(0, Math.min(1, weight));
|
||||
const sourceChannels = [
|
||||
Number.parseInt(sourceHex.slice(1, 3), 16),
|
||||
Number.parseInt(sourceHex.slice(3, 5), 16),
|
||||
Number.parseInt(sourceHex.slice(5, 7), 16),
|
||||
];
|
||||
const targetChannels = [
|
||||
Number.parseInt(targetHex.slice(1, 3), 16),
|
||||
Number.parseInt(targetHex.slice(3, 5), 16),
|
||||
Number.parseInt(targetHex.slice(5, 7), 16),
|
||||
];
|
||||
const mixed = sourceChannels.map((channel, index) =>
|
||||
Math.round(channel * (1 - ratio) + targetChannels[index] * ratio)
|
||||
);
|
||||
return `#${mixed.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
||||
};
|
||||
|
||||
const buildHairSvg = (profile: AgentAvatarProfile, hairColor: string) => {
|
||||
if (profile.accessories.hatStyle !== "none") return "";
|
||||
switch (profile.hair.style) {
|
||||
case "short":
|
||||
return `<rect x="22" y="19" width="36" height="12" rx="4" fill="${hairColor}"/>`;
|
||||
case "parted":
|
||||
return [
|
||||
`<rect x="22" y="19" width="36" height="11" rx="4" fill="${hairColor}"/>`,
|
||||
`<path d="M25 25 L46 18 L47 29 L25 30 Z" fill="${blendHex(hairColor, "#ffffff", 0.08)}"/>`,
|
||||
].join("");
|
||||
case "spiky":
|
||||
return [
|
||||
`<rect x="23" y="21" width="34" height="9" rx="3" fill="${hairColor}"/>`,
|
||||
`<path d="M25 22 L30 14 L34 22 Z" fill="${hairColor}"/>`,
|
||||
`<path d="M38 21 L43 11 L47 21 Z" fill="${hairColor}"/>`,
|
||||
`<path d="M49 22 L54 14 L57 22 Z" fill="${hairColor}"/>`,
|
||||
].join("");
|
||||
case "bun":
|
||||
return [
|
||||
`<rect x="22" y="20" width="36" height="10" rx="4" fill="${hairColor}"/>`,
|
||||
`<circle cx="40" cy="15" r="6" fill="${hairColor}"/>`,
|
||||
].join("");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const buildHatSvg = (profile: AgentAvatarProfile, accessoryColor: string) => {
|
||||
switch (profile.accessories.hatStyle) {
|
||||
case "cap":
|
||||
return [
|
||||
`<rect x="21" y="17" width="38" height="10" rx="4" fill="${accessoryColor}"/>`,
|
||||
`<rect x="29" y="25" width="22" height="5" rx="2.5" fill="${blendHex(accessoryColor, "#000000", 0.08)}"/>`,
|
||||
].join("");
|
||||
case "beanie":
|
||||
return `<path d="M22 27 C22 16, 58 16, 58 27 L58 31 L22 31 Z" fill="${accessoryColor}"/>`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const buildHeadsetSvg = (enabled: boolean) => {
|
||||
if (!enabled) return "";
|
||||
return [
|
||||
`<path d="M24 33 C24 21, 56 21, 56 33" fill="none" stroke="${HEADSET_BAND}" stroke-width="3" stroke-linecap="round"/>`,
|
||||
`<rect x="20" y="33" width="6" height="14" rx="3" fill="${HEADSET_PAD}"/>`,
|
||||
`<rect x="54" y="33" width="6" height="14" rx="3" fill="${HEADSET_PAD}"/>`,
|
||||
].join("");
|
||||
};
|
||||
|
||||
const buildGlassesSvg = (enabled: boolean) => {
|
||||
if (!enabled) return "";
|
||||
return [
|
||||
`<rect x="26" y="34" width="12" height="10" rx="2" fill="none" stroke="${EYE_COLOR}" stroke-width="2"/>`,
|
||||
`<rect x="42" y="34" width="12" height="10" rx="2" fill="none" stroke="${EYE_COLOR}" stroke-width="2"/>`,
|
||||
`<rect x="38" y="38" width="4" height="2" rx="1" fill="${EYE_COLOR}"/>`,
|
||||
].join("");
|
||||
};
|
||||
|
||||
export const buildAgentAvatarPortraitSvg = (profile: AgentAvatarProfile): string => {
|
||||
const skinTone = profile.body.skinTone;
|
||||
const hairColor = profile.hair.color;
|
||||
const topColor = profile.clothing.topColor;
|
||||
const accessoryColor = blendHex(topColor, "#ffffff", 0.08);
|
||||
const shirtShadow = blendHex(topColor, "#000000", 0.18);
|
||||
const faceShadow = blendHex(skinTone, "#000000", 0.12);
|
||||
const faceHighlight = blendHex(skinTone, "#ffffff", 0.16);
|
||||
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" role="img" aria-label="${escapeXml(profile.seed)} avatar portrait">`,
|
||||
`<rect width="80" height="80" rx="18" fill="${AVATAR_BG}"/>`,
|
||||
`<circle cx="60" cy="18" r="14" fill="${topColor}" opacity="0.16"/>`,
|
||||
`<circle cx="18" cy="66" r="16" fill="${faceHighlight}" opacity="0.1"/>`,
|
||||
`<ellipse cx="40" cy="72" rx="18" ry="5" fill="#000000" opacity="0.22"/>`,
|
||||
`<rect x="20" y="55" width="40" height="17" rx="8" fill="${topColor}"/>`,
|
||||
`<rect x="24" y="55" width="32" height="5" rx="2.5" fill="${shirtShadow}" opacity="0.22"/>`,
|
||||
`<rect x="36" y="48" width="8" height="9" rx="2" fill="${faceShadow}"/>`,
|
||||
`<rect x="24" y="21" width="32" height="29" rx="6" fill="${skinTone}"/>`,
|
||||
`<rect x="27" y="24" width="26" height="8" rx="3" fill="${faceHighlight}" opacity="0.26"/>`,
|
||||
buildHairSvg(profile, hairColor),
|
||||
buildHatSvg(profile, accessoryColor),
|
||||
buildHeadsetSvg(profile.accessories.headset),
|
||||
`<rect x="29" y="35" width="7" height="7" rx="1.5" fill="${EYE_COLOR}"/>`,
|
||||
`<rect x="44" y="35" width="7" height="7" rx="1.5" fill="${EYE_COLOR}"/>`,
|
||||
buildGlassesSvg(profile.accessories.glasses),
|
||||
`<rect x="34" y="45" width="12" height="3" rx="1.5" fill="${MOUTH_COLOR}"/>`,
|
||||
`</svg>`,
|
||||
].join("");
|
||||
};
|
||||
|
||||
export const buildAgentAvatarPortraitDataUrl = (profile: AgentAvatarProfile): string =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(buildAgentAvatarPortraitSvg(profile))}`;
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type GatewayHelloOk,
|
||||
} from "./openclaw/GatewayBrowserClient";
|
||||
import type {
|
||||
StudioGatewayProfilePublic,
|
||||
StudioGatewayAdapterType,
|
||||
StudioGatewaySettings,
|
||||
StudioSettings,
|
||||
StudioSettingsPatch,
|
||||
@@ -18,8 +20,21 @@ import type {
|
||||
} from "@/lib/studio/coordinator";
|
||||
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
|
||||
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
|
||||
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
|
||||
const gatewayDebugEnabled = process.env.NODE_ENV !== "production";
|
||||
|
||||
const gatewayDebugLog = (message: string, details?: Record<string, unknown>) => {
|
||||
if (!gatewayDebugEnabled) return;
|
||||
if (details) {
|
||||
console.info("[gateway-client]", message, details);
|
||||
return;
|
||||
}
|
||||
console.info("[gateway-client]", message);
|
||||
};
|
||||
import { probeCustomRuntime } from "@/lib/runtime/custom/http";
|
||||
|
||||
export type ReqFrame = {
|
||||
type: "req";
|
||||
id: string;
|
||||
@@ -82,7 +97,7 @@ export const isSameSessionKey = (a: string, b: string) => {
|
||||
};
|
||||
|
||||
const CONNECT_FAILED_CLOSE_CODE = 4008;
|
||||
const GATEWAY_CONNECT_TIMEOUT_MS = 8_000;
|
||||
const GATEWAY_CONNECT_TIMEOUT_MS = 13_000;
|
||||
|
||||
const parseConnectFailedCloseReason = (
|
||||
reason: string
|
||||
@@ -100,10 +115,81 @@ const parseConnectFailedCloseReason = (
|
||||
|
||||
const DEFAULT_UPSTREAM_GATEWAY_URL =
|
||||
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
|
||||
const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
|
||||
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
|
||||
const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200;
|
||||
const OPENCLAW_CONTROL_UI_CLIENT_ID = "openclaw-control-ui";
|
||||
const OPENCLAW_WEBCHAT_UI_CLIENT_ID = "webchat-ui";
|
||||
|
||||
const isAutoManagedAdapter = (adapterType: StudioGatewayAdapterType) =>
|
||||
adapterType !== "custom";
|
||||
|
||||
export const resolveGatewayClientName = (
|
||||
adapterType: StudioGatewayAdapterType,
|
||||
gatewayUrl: string
|
||||
) => {
|
||||
if (adapterType !== "openclaw") {
|
||||
return OPENCLAW_CONTROL_UI_CLIENT_ID;
|
||||
}
|
||||
return isLocalGatewayUrl(gatewayUrl)
|
||||
? OPENCLAW_CONTROL_UI_CLIENT_ID
|
||||
: OPENCLAW_WEBCHAT_UI_CLIENT_ID;
|
||||
};
|
||||
|
||||
export const resolveInitialGatewayAutoConnectDelayMs = (
|
||||
adapterType: StudioGatewayAdapterType
|
||||
): number => {
|
||||
switch (adapterType) {
|
||||
case "hermes":
|
||||
case "demo":
|
||||
return INITIAL_AUTO_CONNECT_DELAY_MS;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveInitialGatewayConnectAttemptCount = (
|
||||
adapterType: StudioGatewayAdapterType,
|
||||
hasConnectedOnce: boolean
|
||||
): number => {
|
||||
switch (adapterType) {
|
||||
case "hermes":
|
||||
case "demo":
|
||||
return 2;
|
||||
default:
|
||||
if (hasConnectedOnce) return 1;
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveDefaultGatewayProfile = (
|
||||
adapterType: StudioGatewayAdapterType,
|
||||
localDefaults: StudioGatewaySettings | null
|
||||
): { url: string; token: string } => {
|
||||
switch (adapterType) {
|
||||
case "custom":
|
||||
return { url: DEFAULT_CUSTOM_RUNTIME_URL, token: "" };
|
||||
case "demo":
|
||||
case "hermes":
|
||||
return { url: "ws://localhost:18789", token: "" };
|
||||
case "openclaw":
|
||||
default:
|
||||
return {
|
||||
url: localDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL,
|
||||
token: localDefaults?.token ?? "",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const raw = value as { url?: unknown; token?: unknown; tokenConfigured?: unknown };
|
||||
const raw = value as {
|
||||
url?: unknown;
|
||||
token?: unknown;
|
||||
tokenConfigured?: unknown;
|
||||
adapterType?: unknown;
|
||||
profiles?: unknown;
|
||||
};
|
||||
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
||||
if (!url) return null;
|
||||
// Accept both full settings ({ url, token }) and the sanitized public
|
||||
@@ -111,7 +197,40 @@ const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings |
|
||||
// tokenConfigured is present the actual token isn't available on the
|
||||
// client — leave it empty so the connection dialog can prompt if needed.
|
||||
const token = typeof raw.token === "string" ? raw.token.trim() : "";
|
||||
return { url, token };
|
||||
const adapterType =
|
||||
raw.adapterType === "demo" ||
|
||||
raw.adapterType === "hermes" ||
|
||||
raw.adapterType === "openclaw" ||
|
||||
raw.adapterType === "custom"
|
||||
? raw.adapterType
|
||||
: "openclaw";
|
||||
const profiles = normalizeGatewayProfilesPublic(raw.profiles);
|
||||
return { url, token, adapterType, ...(profiles ? { profiles } : {}) };
|
||||
};
|
||||
|
||||
const normalizeGatewayProfilePublic = (
|
||||
value: unknown
|
||||
): { url: string; token: string } | null => {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const raw = value as { url?: unknown };
|
||||
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
||||
if (!url) return null;
|
||||
return { url, token: "" };
|
||||
};
|
||||
|
||||
const normalizeGatewayProfilesPublic = (
|
||||
value: unknown
|
||||
): Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>> | undefined => {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const raw = value as Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePublic>>;
|
||||
const profiles: Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>> = {};
|
||||
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
|
||||
const profile = normalizeGatewayProfilePublic(raw[adapterType]);
|
||||
if (profile) {
|
||||
profiles[adapterType] = profile;
|
||||
}
|
||||
}
|
||||
return Object.keys(profiles).length > 0 ? profiles : undefined;
|
||||
};
|
||||
|
||||
type StatusHandler = (status: GatewayStatus) => void;
|
||||
@@ -146,6 +265,7 @@ export class GatewayClient {
|
||||
private rejectConnect: ((error: Error) => void) | null = null;
|
||||
private manualDisconnect = false;
|
||||
private lastHello: GatewayHelloOk | null = null;
|
||||
private _lastDisconnectCode: number | null = null;
|
||||
|
||||
onStatus(handler: StatusHandler) {
|
||||
this.statusHandlers.add(handler);
|
||||
@@ -204,6 +324,7 @@ export class GatewayClient {
|
||||
},
|
||||
onClose: ({ code, reason }) => {
|
||||
if (this.client !== nextClient) return;
|
||||
this._lastDisconnectCode = code;
|
||||
const connectFailed =
|
||||
code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null;
|
||||
const err = connectFailed
|
||||
@@ -295,6 +416,10 @@ export class GatewayClient {
|
||||
return this.lastHello;
|
||||
}
|
||||
|
||||
get lastDisconnectCode() {
|
||||
return this._lastDisconnectCode;
|
||||
}
|
||||
|
||||
private updateStatus(status: GatewayStatus) {
|
||||
this.status = status;
|
||||
this.statusHandlers.forEach((handler) => handler(status));
|
||||
@@ -419,14 +544,51 @@ export const syncGatewaySessionSettings = async ({
|
||||
const doctorFixHint =
|
||||
"Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout).";
|
||||
|
||||
const protocolMismatchHint =
|
||||
"This gateway looks too old for Claw3D's protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway` for a no-framework office demo.";
|
||||
|
||||
const tailscaleGatewayHint =
|
||||
"If this is a remote OpenClaw/Tailscale gateway, confirm the Studio host can reach the `wss://...` address and approve the first device pairing on the gateway host with `openclaw devices approve --latest`.";
|
||||
|
||||
const pairingRequiredHint =
|
||||
"This gateway is asking for first-time device approval. Run `openclaw devices approve --latest` on the gateway host, then restart Claw3D and reconnect from this browser.";
|
||||
|
||||
const requiresDeviceIdentityHint =
|
||||
"This gateway rejected the client as a control UI without device identity. For remote OpenClaw/Tailscale connections, update to the latest Claw3D build and approve the device pairing on the gateway host.";
|
||||
|
||||
const isGatewayProtocolMismatchError = (error: GatewayResponseError) => {
|
||||
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
|
||||
const message = error.message.trim();
|
||||
if (!message) return false;
|
||||
return /minProtocol|maxProtocol/i.test(message);
|
||||
};
|
||||
|
||||
const formatGatewayError = (error: unknown) => {
|
||||
if (error instanceof GatewayResponseError) {
|
||||
if (isGatewayProtocolMismatchError(error)) {
|
||||
return `Gateway error (${error.code}): ${error.message}. ${protocolMismatchHint}`;
|
||||
}
|
||||
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
|
||||
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
|
||||
}
|
||||
if (error.code === "studio.upstream_timeout") {
|
||||
return `Gateway error (${error.code}): ${error.message} ${tailscaleGatewayHint}`;
|
||||
}
|
||||
if (error.code === "studio.upstream_rejected") {
|
||||
const lower = error.message.toLowerCase();
|
||||
if (lower.includes("pairing required")) {
|
||||
return `Gateway error (${error.code}): ${error.message}. ${pairingRequiredHint}`;
|
||||
}
|
||||
if (lower.includes("device identity")) {
|
||||
return `Gateway error (${error.code}): ${error.message}. ${requiresDeviceIdentityHint}`;
|
||||
}
|
||||
}
|
||||
return `Gateway error (${error.code}): ${error.message}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
if (/timed out connecting to the gateway/i.test(error.message)) {
|
||||
return `${error.message} If you are testing locally, an older OpenClaw build may be speaking an incompatible protocol. Try upgrading OpenClaw, using the Hermes adapter, or running \`npm run demo-gateway\`.`;
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return "Unknown gateway error.";
|
||||
@@ -437,6 +599,9 @@ export type GatewayConnectionState = {
|
||||
status: GatewayStatus;
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
selectedAdapterType: StudioGatewayAdapterType;
|
||||
detectedAdapterType: StudioGatewayAdapterType | null;
|
||||
activeAdapterType: StudioGatewayAdapterType;
|
||||
localGatewayDefaults: StudioGatewaySettings | null;
|
||||
error: string | null;
|
||||
connectPromptReady: boolean;
|
||||
@@ -446,6 +611,7 @@ export type GatewayConnectionState = {
|
||||
useLocalGatewayDefaults: () => void;
|
||||
setGatewayUrl: (value: string) => void;
|
||||
setToken: (value: string) => void;
|
||||
setSelectedAdapterType: (value: StudioGatewayAdapterType) => void;
|
||||
clearError: () => void;
|
||||
};
|
||||
|
||||
@@ -484,6 +650,8 @@ const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
|
||||
"studio.gateway_url_invalid",
|
||||
"studio.settings_load_failed",
|
||||
"studio.upstream_error",
|
||||
"studio.upstream_timeout",
|
||||
"studio.upstream_rejected",
|
||||
]);
|
||||
|
||||
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
|
||||
@@ -492,6 +660,10 @@ const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
|
||||
return NON_RETRYABLE_CONNECT_ERROR_CODES.has(normalized);
|
||||
};
|
||||
|
||||
/** WebSocket close code 1008 = policy violation (rate limit). */
|
||||
const WS_CLOSE_POLICY_VIOLATION = 1008;
|
||||
const RATE_LIMIT_RETRY_DELAY_MS = 15_000;
|
||||
|
||||
export const resolveGatewayAutoRetryDelayMs = (params: {
|
||||
status: GatewayStatus;
|
||||
didAutoConnect: boolean;
|
||||
@@ -500,6 +672,7 @@ export const resolveGatewayAutoRetryDelayMs = (params: {
|
||||
gatewayUrl: string;
|
||||
errorMessage: string | null;
|
||||
connectErrorCode: string | null;
|
||||
lastDisconnectCode: number | null;
|
||||
attempt: number;
|
||||
}): number | null => {
|
||||
if (params.status !== "disconnected") return null;
|
||||
@@ -511,8 +684,13 @@ export const resolveGatewayAutoRetryDelayMs = (params: {
|
||||
if (isNonRetryableConnectErrorCode(params.connectErrorCode)) return null;
|
||||
if (params.connectErrorCode === null && isAuthError(params.errorMessage)) return null;
|
||||
|
||||
const baseDelay =
|
||||
params.lastDisconnectCode === WS_CLOSE_POLICY_VIOLATION
|
||||
? Math.max(INITIAL_RETRY_DELAY_MS, RATE_LIMIT_RETRY_DELAY_MS)
|
||||
: INITIAL_RETRY_DELAY_MS;
|
||||
|
||||
return Math.min(
|
||||
INITIAL_RETRY_DELAY_MS * Math.pow(1.5, params.attempt),
|
||||
baseDelay * Math.pow(1.5, params.attempt),
|
||||
MAX_RETRY_DELAY_MS
|
||||
);
|
||||
};
|
||||
@@ -523,13 +701,27 @@ export const useGatewayConnection = (
|
||||
const [client] = useState(() => new GatewayClient());
|
||||
const didAutoConnect = useRef(false);
|
||||
const hasConnectedOnceRef = useRef(false);
|
||||
const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null);
|
||||
const loadedGatewaySettings = useRef<{
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
adapterType: StudioGatewayAdapterType;
|
||||
profiles?: Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>>;
|
||||
hasLastKnownGood: boolean;
|
||||
} | null>(null);
|
||||
const retryAttemptRef = useRef(0);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const autoConnectTimerRef = useRef<number | null>(null);
|
||||
const wasManualDisconnectRef = useRef(false);
|
||||
|
||||
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
|
||||
const [token, setToken] = useState("");
|
||||
const [selectedAdapterType, setSelectedAdapterTypeState] =
|
||||
useState<StudioGatewayAdapterType>("openclaw");
|
||||
const [adapterProfiles, setAdapterProfiles] = useState<
|
||||
Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>>
|
||||
>({});
|
||||
const [detectedAdapterType, setDetectedAdapterType] =
|
||||
useState<StudioGatewayAdapterType | null>(null);
|
||||
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
|
||||
null
|
||||
);
|
||||
@@ -537,6 +729,19 @@ export const useGatewayConnection = (
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false);
|
||||
const setSelectedAdapterType = useCallback(
|
||||
(value: StudioGatewayAdapterType) => {
|
||||
setSelectedAdapterTypeState(value);
|
||||
const profile =
|
||||
adapterProfiles[value] ?? resolveDefaultGatewayProfile(value, localGatewayDefaults);
|
||||
setGatewayUrl(profile.url);
|
||||
setToken(profile.token);
|
||||
setError(null);
|
||||
setConnectErrorCode(null);
|
||||
},
|
||||
[adapterProfiles, localGatewayDefaults]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -554,26 +759,93 @@ export const useGatewayConnection = (
|
||||
if (cancelled) return;
|
||||
const normalizedDefaults = normalizeLocalGatewayDefaults(envelope.localGatewayDefaults);
|
||||
setLocalGatewayDefaults(normalizedDefaults);
|
||||
const lastKnownGood =
|
||||
gateway && "lastKnownGood" in gateway && gateway.lastKnownGood
|
||||
? {
|
||||
url:
|
||||
typeof gateway.lastKnownGood.url === "string"
|
||||
? gateway.lastKnownGood.url
|
||||
: "",
|
||||
token:
|
||||
"token" in gateway.lastKnownGood &&
|
||||
typeof gateway.lastKnownGood.token === "string"
|
||||
? gateway.lastKnownGood.token
|
||||
: "",
|
||||
adapterType:
|
||||
gateway.lastKnownGood.adapterType === "demo" ||
|
||||
gateway.lastKnownGood.adapterType === "hermes" ||
|
||||
gateway.lastKnownGood.adapterType === "openclaw" ||
|
||||
gateway.lastKnownGood.adapterType === "custom"
|
||||
? gateway.lastKnownGood.adapterType
|
||||
: "openclaw",
|
||||
}
|
||||
: null;
|
||||
// When the user has no saved gateway URL, prefer the runtime
|
||||
// localGatewayDefaults (from openclaw.json / CLAW3D_GATEWAY_URL)
|
||||
// localGatewayDefaults (from openclaw.json, CLAW3D_GATEWAY_URL,
|
||||
// or detected local Hermes/demo adapter ports)
|
||||
// over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale
|
||||
// or empty if the operator forgot to rebuild after .env changes.
|
||||
const hasSavedUrl = Boolean(gateway?.url?.trim());
|
||||
const savedAdapterType =
|
||||
hasSavedUrl && gateway && "adapterType" in gateway && typeof gateway.adapterType === "string"
|
||||
? ((gateway.adapterType === "demo" ||
|
||||
gateway.adapterType === "hermes" ||
|
||||
gateway.adapterType === "openclaw" ||
|
||||
gateway.adapterType === "custom"
|
||||
? gateway.adapterType
|
||||
: "openclaw") as StudioGatewayAdapterType)
|
||||
: null;
|
||||
const nextAdapterType =
|
||||
savedAdapterType ??
|
||||
lastKnownGood?.adapterType ??
|
||||
normalizedDefaults?.adapterType ??
|
||||
"openclaw";
|
||||
const lastKnownGoodForSelectedAdapter =
|
||||
lastKnownGood?.adapterType === nextAdapterType ? lastKnownGood : null;
|
||||
const resolvedUrl = hasSavedUrl
|
||||
? gateway!.url
|
||||
: normalizedDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL;
|
||||
const nextGatewayUrl = resolvedUrl;
|
||||
const nextToken = hasSavedUrl
|
||||
? (gateway && "token" in gateway && typeof gateway.token === "string"
|
||||
? gateway.token
|
||||
: "")
|
||||
: normalizedDefaults?.token ?? "";
|
||||
: lastKnownGoodForSelectedAdapter?.url ||
|
||||
normalizedDefaults?.url ||
|
||||
DEFAULT_UPSTREAM_GATEWAY_URL;
|
||||
const baseProfiles = {
|
||||
...(gateway?.profiles
|
||||
? normalizeGatewayProfilesPublic(gateway.profiles)
|
||||
: undefined),
|
||||
...(normalizedDefaults?.profiles ?? {}),
|
||||
};
|
||||
const mergedProfiles = {
|
||||
...baseProfiles,
|
||||
...(hasSavedUrl
|
||||
? {
|
||||
[nextAdapterType]: {
|
||||
url: resolvedUrl,
|
||||
token:
|
||||
gateway && "token" in gateway && typeof gateway.token === "string"
|
||||
? gateway.token
|
||||
: "",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const selectedProfile = (
|
||||
mergedProfiles[nextAdapterType] ??
|
||||
lastKnownGoodForSelectedAdapter ??
|
||||
resolveDefaultGatewayProfile(nextAdapterType, normalizedDefaults)
|
||||
);
|
||||
const nextGatewayUrl = selectedProfile.url;
|
||||
const nextToken = selectedProfile.token;
|
||||
loadedGatewaySettings.current = {
|
||||
gatewayUrl: nextGatewayUrl.trim(),
|
||||
token: nextToken,
|
||||
adapterType: nextAdapterType,
|
||||
profiles: mergedProfiles,
|
||||
hasLastKnownGood: Boolean(lastKnownGoodForSelectedAdapter?.url),
|
||||
};
|
||||
setGatewayUrl(nextGatewayUrl);
|
||||
setToken(nextToken);
|
||||
setSelectedAdapterTypeState(nextAdapterType);
|
||||
setAdapterProfiles(mergedProfiles);
|
||||
setHasLastKnownGoodState(Boolean(lastKnownGoodForSelectedAdapter?.url));
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
|
||||
@@ -585,6 +857,9 @@ export const useGatewayConnection = (
|
||||
loadedGatewaySettings.current = {
|
||||
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
|
||||
token: "",
|
||||
adapterType: "openclaw",
|
||||
profiles: undefined,
|
||||
hasLastKnownGood: false,
|
||||
};
|
||||
}
|
||||
setSettingsLoaded(true);
|
||||
@@ -599,11 +874,14 @@ export const useGatewayConnection = (
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((nextStatus) => {
|
||||
gatewayDebugLog("status", { nextStatus });
|
||||
setStatus(nextStatus);
|
||||
if (nextStatus !== "connecting") {
|
||||
setError(null);
|
||||
if (nextStatus === "connected") {
|
||||
setConnectErrorCode(null);
|
||||
} else {
|
||||
setDetectedAdapterType(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -611,6 +889,10 @@ export const useGatewayConnection = (
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoConnectTimerRef.current) {
|
||||
clearTimeout(autoConnectTimerRef.current);
|
||||
autoConnectTimerRef.current = null;
|
||||
}
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
@@ -620,35 +902,143 @@ export const useGatewayConnection = (
|
||||
}, [client]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (autoConnectTimerRef.current) {
|
||||
clearTimeout(autoConnectTimerRef.current);
|
||||
autoConnectTimerRef.current = null;
|
||||
}
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
gatewayDebugLog("connect:start", {
|
||||
selectedAdapterType,
|
||||
gatewayUrl,
|
||||
hasToken: Boolean(token),
|
||||
});
|
||||
setError(null);
|
||||
setConnectErrorCode(null);
|
||||
retryAttemptRef.current = 0;
|
||||
wasManualDisconnectRef.current = false;
|
||||
if (selectedAdapterType === "custom") {
|
||||
setStatus("connecting");
|
||||
try {
|
||||
await settingsCoordinator.flushPending();
|
||||
await probeCustomRuntime(gatewayUrl);
|
||||
setDetectedAdapterType("custom");
|
||||
setStatus("connected");
|
||||
setConnectErrorCode(null);
|
||||
gatewayDebugLog("connect:custom-success", { gatewayUrl });
|
||||
} catch (err) {
|
||||
setStatus("disconnected");
|
||||
setDetectedAdapterType(null);
|
||||
setConnectErrorCode("studio.custom_runtime_probe_failed");
|
||||
setError(formatGatewayError(err));
|
||||
gatewayDebugLog("connect:custom-failed", {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await settingsCoordinator.flushPending();
|
||||
await client.connect({
|
||||
gatewayUrl: resolveStudioProxyGatewayUrl(),
|
||||
token,
|
||||
authScopeKey: gatewayUrl,
|
||||
clientName: "openclaw-control-ui",
|
||||
});
|
||||
const maxAttempts = resolveInitialGatewayConnectAttemptCount(
|
||||
selectedAdapterType,
|
||||
hasConnectedOnceRef.current
|
||||
);
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
await client.connect({
|
||||
gatewayUrl: resolveStudioProxyGatewayUrl(),
|
||||
token,
|
||||
authScopeKey: gatewayUrl,
|
||||
clientName: resolveGatewayClientName(selectedAdapterType, gatewayUrl),
|
||||
disableDeviceAuth: selectedAdapterType !== "openclaw",
|
||||
});
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
gatewayDebugLog("connect:attempt-failed", {
|
||||
selectedAdapterType,
|
||||
attempt: attempt + 1,
|
||||
maxAttempts,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (attempt + 1 >= maxAttempts) {
|
||||
throw err;
|
||||
}
|
||||
client.disconnect();
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, INITIAL_CONNECT_RETRY_DELAY_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
await ensureGatewayReloadModeHotForLocalStudio({
|
||||
client,
|
||||
upstreamGatewayUrl: gatewayUrl,
|
||||
});
|
||||
retryAttemptRef.current = 0;
|
||||
const hello = client.getLastHello();
|
||||
const nextDetectedAdapterType =
|
||||
hello?.adapterType === "demo" ||
|
||||
hello?.adapterType === "hermes" ||
|
||||
hello?.adapterType === "openclaw" ||
|
||||
hello?.adapterType === "custom"
|
||||
? hello.adapterType
|
||||
: "openclaw";
|
||||
setDetectedAdapterType(nextDetectedAdapterType);
|
||||
setHasLastKnownGoodState(nextDetectedAdapterType === selectedAdapterType);
|
||||
settingsCoordinator.schedulePatch({
|
||||
gateway: {
|
||||
lastKnownGood: {
|
||||
url: gatewayUrl.trim(),
|
||||
token,
|
||||
adapterType: nextDetectedAdapterType,
|
||||
},
|
||||
},
|
||||
});
|
||||
gatewayDebugLog("connect:success", {
|
||||
selectedAdapterType,
|
||||
detectedAdapterType: nextDetectedAdapterType,
|
||||
});
|
||||
} catch (err) {
|
||||
setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null);
|
||||
setError(formatGatewayError(err));
|
||||
gatewayDebugLog("connect:failed", {
|
||||
selectedAdapterType,
|
||||
code: err instanceof GatewayResponseError ? err.code : null,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, [client, gatewayUrl, settingsCoordinator, token]);
|
||||
}, [client, gatewayUrl, selectedAdapterType, settingsCoordinator, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (didAutoConnect.current) return;
|
||||
if (!settingsLoaded) return;
|
||||
if (!hasLastKnownGoodState) return;
|
||||
if (!gatewayUrl.trim()) return;
|
||||
if (!isAutoManagedAdapter(selectedAdapterType)) return;
|
||||
didAutoConnect.current = true;
|
||||
void connect();
|
||||
}, [connect, gatewayUrl, settingsLoaded]);
|
||||
const delayMs = resolveInitialGatewayAutoConnectDelayMs(selectedAdapterType);
|
||||
gatewayDebugLog("auto-connect", {
|
||||
selectedAdapterType,
|
||||
gatewayUrl,
|
||||
delayMs,
|
||||
});
|
||||
autoConnectTimerRef.current = window.setTimeout(() => {
|
||||
autoConnectTimerRef.current = null;
|
||||
void connect();
|
||||
}, delayMs);
|
||||
return () => {
|
||||
if (autoConnectTimerRef.current) {
|
||||
window.clearTimeout(autoConnectTimerRef.current);
|
||||
autoConnectTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect, gatewayUrl, hasLastKnownGoodState, selectedAdapterType, settingsLoaded]);
|
||||
|
||||
// Auto-retry on disconnect (gateway busy, network blip, etc.)
|
||||
useEffect(() => {
|
||||
@@ -661,12 +1051,28 @@ export const useGatewayConnection = (
|
||||
gatewayUrl,
|
||||
errorMessage: error,
|
||||
connectErrorCode,
|
||||
lastDisconnectCode: client.lastDisconnectCode,
|
||||
attempt,
|
||||
});
|
||||
if (!isAutoManagedAdapter(selectedAdapterType)) return;
|
||||
if (delay === null) return;
|
||||
gatewayDebugLog("auto-retry-scheduled", {
|
||||
selectedAdapterType,
|
||||
attempt: attempt + 1,
|
||||
delay,
|
||||
gatewayUrl,
|
||||
status,
|
||||
});
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryAttemptRef.current = attempt + 1;
|
||||
// Call connect first (it synchronously resets retryAttemptRef to 0),
|
||||
// then override with the correct attempt count so the next auto-retry
|
||||
// uses proper exponential backoff.
|
||||
void connect();
|
||||
retryAttemptRef.current = attempt + 1;
|
||||
gatewayDebugLog("auto-retry-fire", {
|
||||
selectedAdapterType,
|
||||
attempt: retryAttemptRef.current,
|
||||
});
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
@@ -675,22 +1081,62 @@ export const useGatewayConnection = (
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect, connectErrorCode, error, gatewayUrl, status]);
|
||||
}, [connect, connectErrorCode, error, gatewayUrl, selectedAdapterType, status]);
|
||||
|
||||
// Reset retry count on successful connection
|
||||
// Reset retry count after the connection has been stable for a minimum
|
||||
// duration. If the upstream drops the connection quickly (e.g. within a
|
||||
// few seconds), keeping the current attempt count lets exponential backoff
|
||||
// work properly instead of hammering the gateway every 2 seconds.
|
||||
useEffect(() => {
|
||||
if (status === "connected") {
|
||||
hasConnectedOnceRef.current = true;
|
||||
retryAttemptRef.current = 0;
|
||||
const stableTimer = setTimeout(() => {
|
||||
retryAttemptRef.current = 0;
|
||||
}, 10_000);
|
||||
return () => clearTimeout(stableTimer);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
setAdapterProfiles((current) => {
|
||||
const nextProfile = {
|
||||
url: gatewayUrl.trim(),
|
||||
token,
|
||||
};
|
||||
const existing = current[selectedAdapterType];
|
||||
if (
|
||||
existing &&
|
||||
existing.url === nextProfile.url &&
|
||||
existing.token === nextProfile.token
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[selectedAdapterType]: nextProfile,
|
||||
};
|
||||
});
|
||||
}, [gatewayUrl, selectedAdapterType, settingsLoaded, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return;
|
||||
const baseline = loadedGatewaySettings.current;
|
||||
if (!baseline) return;
|
||||
const nextGatewayUrl = gatewayUrl.trim();
|
||||
if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) {
|
||||
const nextProfiles = {
|
||||
...adapterProfiles,
|
||||
[selectedAdapterType]: {
|
||||
url: nextGatewayUrl,
|
||||
token,
|
||||
},
|
||||
};
|
||||
if (
|
||||
nextGatewayUrl === baseline.gatewayUrl &&
|
||||
token === baseline.token &&
|
||||
selectedAdapterType === baseline.adapterType &&
|
||||
JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
settingsCoordinator.schedulePatch(
|
||||
@@ -698,11 +1144,13 @@ export const useGatewayConnection = (
|
||||
gateway: {
|
||||
url: nextGatewayUrl,
|
||||
token,
|
||||
adapterType: selectedAdapterType,
|
||||
profiles: nextProfiles,
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
|
||||
}, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]);
|
||||
|
||||
const useLocalGatewayDefaults = useCallback(() => {
|
||||
if (!localGatewayDefaults) {
|
||||
@@ -710,17 +1158,31 @@ export const useGatewayConnection = (
|
||||
}
|
||||
setGatewayUrl(localGatewayDefaults.url);
|
||||
setToken(localGatewayDefaults.token);
|
||||
setAdapterProfiles((current) => ({
|
||||
...current,
|
||||
[localGatewayDefaults.adapterType]: {
|
||||
url: localGatewayDefaults.url,
|
||||
token: localGatewayDefaults.token,
|
||||
},
|
||||
}));
|
||||
setSelectedAdapterTypeState(localGatewayDefaults.adapterType);
|
||||
setError(null);
|
||||
setConnectErrorCode(null);
|
||||
}, [localGatewayDefaults]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
gatewayDebugLog("disconnect", { selectedAdapterType });
|
||||
setError(null);
|
||||
setConnectErrorCode(null);
|
||||
wasManualDisconnectRef.current = true;
|
||||
setDetectedAdapterType(null);
|
||||
if (selectedAdapterType === "custom") {
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
client.disconnect();
|
||||
clearGatewayBrowserSessionStorage();
|
||||
}, [client]);
|
||||
}, [client, selectedAdapterType]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -728,16 +1190,26 @@ export const useGatewayConnection = (
|
||||
}, []);
|
||||
|
||||
const connectPromptReady = settingsLoaded;
|
||||
const activeAdapterType =
|
||||
status === "connected" ? detectedAdapterType ?? selectedAdapterType : selectedAdapterType;
|
||||
const shouldPromptForConnect =
|
||||
settingsLoaded &&
|
||||
status !== "connected" &&
|
||||
(!gatewayUrl.trim() || !token.trim() || wasManualDisconnectRef.current || Boolean(error));
|
||||
(selectedAdapterType === "custom" ||
|
||||
!hasLastKnownGoodState ||
|
||||
!gatewayUrl.trim() ||
|
||||
(selectedAdapterType === "openclaw" && !token.trim()) ||
|
||||
wasManualDisconnectRef.current ||
|
||||
Boolean(error));
|
||||
|
||||
return {
|
||||
client,
|
||||
status,
|
||||
gatewayUrl,
|
||||
token,
|
||||
selectedAdapterType,
|
||||
detectedAdapterType,
|
||||
activeAdapterType,
|
||||
localGatewayDefaults,
|
||||
error,
|
||||
connectPromptReady,
|
||||
@@ -747,6 +1219,7 @@ export const useGatewayConnection = (
|
||||
useLocalGatewayDefaults,
|
||||
setGatewayUrl,
|
||||
setToken,
|
||||
setSelectedAdapterType,
|
||||
clearError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { AgentFileName } from "@/lib/agents/agentFiles";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
|
||||
type AgentsFilesGetResponse = {
|
||||
file?: { missing?: unknown; content?: unknown };
|
||||
workspace?: unknown;
|
||||
file?: { missing?: unknown; content?: unknown; path?: unknown };
|
||||
};
|
||||
|
||||
const resolveAgentId = (value: string) => {
|
||||
@@ -17,7 +18,7 @@ export const readGatewayAgentFile = async (params: {
|
||||
client: GatewayClient;
|
||||
agentId: string;
|
||||
name: AgentFileName;
|
||||
}): Promise<{ exists: boolean; content: string }> => {
|
||||
}): Promise<{ exists: boolean; content: string; path: string | null; workspace: string | null }> => {
|
||||
const agentId = resolveAgentId(params.agentId);
|
||||
const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", {
|
||||
agentId,
|
||||
@@ -28,7 +29,11 @@ export const readGatewayAgentFile = async (params: {
|
||||
const missing = fileRecord?.missing === true;
|
||||
const content =
|
||||
fileRecord && typeof fileRecord.content === "string" ? fileRecord.content : "";
|
||||
return { exists: !missing, content };
|
||||
const path =
|
||||
fileRecord && typeof fileRecord.path === "string" ? fileRecord.path : null;
|
||||
const workspace =
|
||||
typeof response?.workspace === "string" ? response.workspace : null;
|
||||
return { exists: !missing, content, path, workspace };
|
||||
};
|
||||
|
||||
export const writeGatewayAgentFile = async (params: {
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
|
||||
const gatewayBrowserDebugEnabled =
|
||||
process.env.NODE_ENV !== "production";
|
||||
|
||||
const gatewayBrowserDebugLog = (
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
) => {
|
||||
if (!gatewayBrowserDebugEnabled) return;
|
||||
if (details) {
|
||||
console.info("[gateway-browser]", message, details);
|
||||
return;
|
||||
}
|
||||
console.info("[gateway-browser]", message);
|
||||
};
|
||||
|
||||
const GATEWAY_CLIENT_NAMES = {
|
||||
CONTROL_UI: "openclaw-control-ui",
|
||||
} as const;
|
||||
@@ -350,6 +365,7 @@ export type GatewayResponseFrame = {
|
||||
export type GatewayHelloOk = {
|
||||
type: "hello-ok";
|
||||
protocol: number;
|
||||
adapterType?: "openclaw" | "hermes" | "demo" | "custom";
|
||||
features?: { methods?: string[]; events?: string[] };
|
||||
snapshot?: unknown;
|
||||
auth?: {
|
||||
@@ -415,11 +431,19 @@ export class GatewayBrowserClient {
|
||||
|
||||
start() {
|
||||
this.closed = false;
|
||||
gatewayBrowserDebugLog("start", {
|
||||
url: this.opts.url,
|
||||
authScopeKey: this.opts.authScopeKey ?? null,
|
||||
disableDeviceAuth: Boolean(this.opts.disableDeviceAuth),
|
||||
clientName: this.opts.clientName ?? null,
|
||||
mode: this.opts.mode ?? null,
|
||||
});
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.closed = true;
|
||||
gatewayBrowserDebugLog("stop");
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.flushPending(new Error("gateway client stopped"));
|
||||
@@ -431,18 +455,23 @@ export class GatewayBrowserClient {
|
||||
|
||||
private connect() {
|
||||
if (this.closed) return;
|
||||
gatewayBrowserDebugLog("connect:open-socket", { url: this.opts.url });
|
||||
this.ws = new WebSocket(this.opts.url);
|
||||
this.ws.onopen = () => this.queueConnect();
|
||||
this.ws.onopen = () => {
|
||||
gatewayBrowserDebugLog("socket:open");
|
||||
this.queueConnect();
|
||||
};
|
||||
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
|
||||
this.ws.onclose = (ev) => {
|
||||
const reason = String(ev.reason ?? "");
|
||||
gatewayBrowserDebugLog("socket:close", { code: ev.code, reason });
|
||||
this.ws = null;
|
||||
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
|
||||
this.opts.onClose?.({ code: ev.code, reason });
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
this.ws.onerror = () => {
|
||||
// ignored; close handler will fire
|
||||
gatewayBrowserDebugLog("socket:error");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -450,6 +479,7 @@ export class GatewayBrowserClient {
|
||||
if (this.closed) return;
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
|
||||
gatewayBrowserDebugLog("schedule-reconnect", { delay });
|
||||
window.setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
@@ -468,6 +498,12 @@ export class GatewayBrowserClient {
|
||||
|
||||
const isSecureContext =
|
||||
!this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle;
|
||||
gatewayBrowserDebugLog("send-connect", {
|
||||
url: this.opts.url,
|
||||
disableDeviceAuth: Boolean(this.opts.disableDeviceAuth),
|
||||
hasNonce: Boolean(this.connectNonce),
|
||||
isSecureContext,
|
||||
});
|
||||
|
||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||
const role = "operator";
|
||||
@@ -545,8 +581,21 @@ export class GatewayBrowserClient {
|
||||
locale: navigator.language,
|
||||
};
|
||||
|
||||
gatewayBrowserDebugLog("connect-params", {
|
||||
clientId: params.client.id,
|
||||
clientMode: params.client.mode,
|
||||
disableDeviceAuth: this.opts.disableDeviceAuth,
|
||||
isSecureContext,
|
||||
hasToken: Boolean(authToken),
|
||||
hasDeviceIdentity: Boolean(deviceIdentity),
|
||||
});
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
gatewayBrowserDebugLog("hello-ok", {
|
||||
protocol: hello?.protocol ?? null,
|
||||
hasAuthToken: Boolean(hello?.auth?.deviceToken),
|
||||
});
|
||||
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
@@ -560,6 +609,9 @@ export class GatewayBrowserClient {
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch((err) => {
|
||||
gatewayBrowserDebugLog("connect-failed", {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (canFallbackToShared && deviceIdentity) {
|
||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey });
|
||||
}
|
||||
@@ -587,6 +639,7 @@ export class GatewayBrowserClient {
|
||||
if (frame.type === "event") {
|
||||
const evt = parsed as GatewayEventFrame;
|
||||
if (evt.event === "connect.challenge") {
|
||||
gatewayBrowserDebugLog("connect-challenge");
|
||||
const payload = evt.payload as { nonce?: unknown } | undefined;
|
||||
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
|
||||
if (nonce) {
|
||||
@@ -650,6 +703,7 @@ export class GatewayBrowserClient {
|
||||
this.connectNonce = null;
|
||||
this.connectSent = false;
|
||||
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
|
||||
gatewayBrowserDebugLog("queue-connect", { delayMs: 750 });
|
||||
this.connectTimer = window.setTimeout(() => {
|
||||
void this.sendConnect();
|
||||
}, 750);
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
resolveOfficeGithubDirective,
|
||||
resolveOfficeGymDirective,
|
||||
resolveOfficeQaDirective,
|
||||
resolveOfficeStandupDirective,
|
||||
resolveOfficeTextDirective,
|
||||
} from "@/lib/office/deskDirectives";
|
||||
import { extractText, extractThinking } from "@/lib/text/message-extract";
|
||||
@@ -51,6 +50,7 @@ const WORKING_LATCH_MS = 5_000;
|
||||
const GYM_WORKOUT_LATCH_MS = 60_000;
|
||||
const STREAM_ACTIVITY_LATCH_MS = 6_000;
|
||||
const THINKING_ACTIVITY_LATCH_MS = 6_000;
|
||||
const STANDUP_TRIGGER_MAX_AGE_MS = 30_000;
|
||||
const CLEANING_CUE_LIMIT = 24;
|
||||
const TRANSIENT_BOOTH_RESTORE_MAX_AGE_MS = 2 * 60_000;
|
||||
|
||||
@@ -807,17 +807,15 @@ const applyUserMessageTriggers = (params: {
|
||||
};
|
||||
}
|
||||
if (params.agentId === "main" && intentSnapshot.standup === "standup") {
|
||||
const requestKey = normalizeCommandText(params.message);
|
||||
if (next.pendingStandupRequest?.key !== requestKey) {
|
||||
next = {
|
||||
...next,
|
||||
pendingStandupRequest: {
|
||||
key: requestKey,
|
||||
message: params.message.trim(),
|
||||
requestedAt: params.nowMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
const requestKey = `${normalizeCommandText(params.message)}:${params.nowMs}`;
|
||||
next = {
|
||||
...next,
|
||||
pendingStandupRequest: {
|
||||
key: requestKey,
|
||||
message: params.message.trim(),
|
||||
requestedAt: params.nowMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (intentSnapshot.call) {
|
||||
const request = createPhoneCallRequest({
|
||||
@@ -1108,6 +1106,12 @@ export const reconcileOfficeAnimationTriggerState = (params: {
|
||||
let workingUntilByAgentId = next.workingUntilByAgentId;
|
||||
const manualGymUntilByAgentId = next.manualGymUntilByAgentId;
|
||||
let pendingStandupRequest = next.pendingStandupRequest;
|
||||
if (
|
||||
pendingStandupRequest &&
|
||||
nowMs - pendingStandupRequest.requestedAt > STANDUP_TRIGGER_MAX_AGE_MS
|
||||
) {
|
||||
pendingStandupRequest = null;
|
||||
}
|
||||
|
||||
for (const agent of params.agents) {
|
||||
const agentId = agent.agentId;
|
||||
@@ -1192,23 +1196,6 @@ export const reconcileOfficeAnimationTriggerState = (params: {
|
||||
skillGymHoldByAgentId[agentId] = true;
|
||||
}
|
||||
|
||||
const standupDirective = resolveLatestDirective({
|
||||
lastUserMessage: agent.lastUserMessage,
|
||||
transcriptEntries: agent.transcriptEntries,
|
||||
resolver: resolveOfficeStandupDirective,
|
||||
});
|
||||
if (
|
||||
agentId === "main" &&
|
||||
standupDirective &&
|
||||
pendingStandupRequest?.key !== standupDirective.key
|
||||
) {
|
||||
pendingStandupRequest = {
|
||||
key: standupDirective.key,
|
||||
message: standupDirective.text,
|
||||
requestedAt: nowMs,
|
||||
};
|
||||
}
|
||||
|
||||
const phoneCallRequest = resolveLatestPhoneCallRequest({
|
||||
lastUserMessage: agent.lastUserMessage,
|
||||
transcriptEntries: agent.transcriptEntries,
|
||||
|
||||
@@ -94,6 +94,19 @@ export const DEFAULT_SKILL_TRIGGER_FALLBACKS_BY_SKILL_KEY: Record<
|
||||
movementTarget: "desk",
|
||||
skipIfAlreadyThere: true,
|
||||
},
|
||||
"task-manager": {
|
||||
anyPhrases: [
|
||||
"add a task",
|
||||
"create a task",
|
||||
"track this task",
|
||||
"task status",
|
||||
"mark this done",
|
||||
"block this task",
|
||||
"what tasks do we have",
|
||||
],
|
||||
movementTarget: "desk",
|
||||
skipIfAlreadyThere: true,
|
||||
},
|
||||
soundclaw: {
|
||||
anyPhrases: [
|
||||
"spotify",
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { StandupMeeting, StandupMeetingStore } from "@/lib/office/standup/t
|
||||
|
||||
const STORE_DIR = "claw3d";
|
||||
const STORE_FILE = "standup-store.json";
|
||||
const GATHERING_MEETING_MAX_AGE_MS = 5 * 60 * 1000;
|
||||
const ACTIVE_MEETING_MAX_AGE_MS = 20 * 60 * 1000;
|
||||
|
||||
const ensureDirectory = (dirPath: string) => {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
@@ -36,6 +38,24 @@ const normalizeMeeting = (value: unknown): StandupMeeting | null => {
|
||||
return value as StandupMeeting;
|
||||
};
|
||||
|
||||
const isActiveMeetingStale = (
|
||||
meeting: StandupMeeting | null,
|
||||
nowMs: number = Date.now()
|
||||
): boolean => {
|
||||
if (!meeting) return false;
|
||||
if (meeting.phase === "gathering") {
|
||||
const startedAtMs = Date.parse(meeting.startedAt);
|
||||
if (!Number.isFinite(startedAtMs)) return false;
|
||||
return nowMs - startedAtMs > GATHERING_MEETING_MAX_AGE_MS;
|
||||
}
|
||||
if (meeting.phase !== "in_progress") {
|
||||
return false;
|
||||
}
|
||||
const updatedAtMs = Date.parse(meeting.updatedAt);
|
||||
if (!Number.isFinite(updatedAtMs)) return false;
|
||||
return nowMs - updatedAtMs > ACTIVE_MEETING_MAX_AGE_MS;
|
||||
};
|
||||
|
||||
const readStore = (): StandupMeetingStore => {
|
||||
const storePath = resolveStorePath();
|
||||
if (!fs.existsSync(storePath)) {
|
||||
@@ -44,9 +64,11 @@ const readStore = (): StandupMeetingStore => {
|
||||
const raw = fs.readFileSync(storePath, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!isRecord(parsed)) return defaultStore();
|
||||
const activeMeeting = normalizeMeeting(parsed.activeMeeting);
|
||||
const lastMeeting = normalizeMeeting(parsed.lastMeeting);
|
||||
return {
|
||||
activeMeeting: normalizeMeeting(parsed.activeMeeting),
|
||||
lastMeeting: normalizeMeeting(parsed.lastMeeting),
|
||||
activeMeeting: isActiveMeetingStale(activeMeeting) ? null : activeMeeting,
|
||||
lastMeeting,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CustomRuntimeProvider } from "@/lib/runtime/custom/provider";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { DemoRuntimeProvider } from "@/lib/runtime/demo/provider";
|
||||
import { HermesRuntimeProvider } from "@/lib/runtime/hermes/provider";
|
||||
import { OpenClawRuntimeProvider } from "@/lib/runtime/openclaw/provider";
|
||||
import type { RuntimeProvider } from "@/lib/runtime/types";
|
||||
|
||||
export const createRuntimeProvider = (
|
||||
providerId: RuntimeProvider["id"],
|
||||
client: GatewayClient,
|
||||
runtimeUrl: string
|
||||
): RuntimeProvider => {
|
||||
switch (providerId) {
|
||||
case "custom":
|
||||
return new CustomRuntimeProvider(client, runtimeUrl);
|
||||
case "demo":
|
||||
return new DemoRuntimeProvider(client);
|
||||
case "hermes":
|
||||
return new HermesRuntimeProvider(client);
|
||||
case "openclaw":
|
||||
default:
|
||||
return new OpenClawRuntimeProvider(client);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
export const normalizeCustomBaseUrl = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol === "ws:") {
|
||||
parsed.protocol = "http:";
|
||||
} else if (parsed.protocol === "wss:") {
|
||||
parsed.protocol = "https:";
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return trimmed.replace(/\/$/, "");
|
||||
}
|
||||
};
|
||||
|
||||
type CustomRuntimeProxyInput = {
|
||||
runtimeUrl: string;
|
||||
pathname: string;
|
||||
method?: "GET" | "POST";
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export async function requestCustomRuntime<T = unknown>({
|
||||
runtimeUrl,
|
||||
pathname,
|
||||
method = "GET",
|
||||
body,
|
||||
}: CustomRuntimeProxyInput): Promise<T> {
|
||||
const normalizedRuntimeUrl = normalizeCustomBaseUrl(runtimeUrl);
|
||||
if (!normalizedRuntimeUrl) {
|
||||
throw new Error("Custom runtime URL is not configured.");
|
||||
}
|
||||
const response = await fetch("/api/runtime/custom", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
runtimeUrl: normalizedRuntimeUrl,
|
||||
pathname,
|
||||
method,
|
||||
body,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(
|
||||
text.trim() || `Custom runtime request failed (${response.status}) for ${pathname}.`
|
||||
);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchCustomRuntimeJson<T = unknown>(
|
||||
runtimeUrl: string,
|
||||
pathname: string
|
||||
): Promise<T> {
|
||||
return requestCustomRuntime<T>({ runtimeUrl, pathname, method: "GET" });
|
||||
}
|
||||
|
||||
export async function probeCustomRuntime(runtimeUrl: string): Promise<void> {
|
||||
await fetchCustomRuntimeJson(runtimeUrl, "/health");
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import type {
|
||||
EventFrame,
|
||||
GatewayConnectOptions,
|
||||
GatewayGapInfo,
|
||||
GatewayStatus,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
parseAgentIdFromSessionKey,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
fetchCustomRuntimeJson,
|
||||
normalizeCustomBaseUrl,
|
||||
requestCustomRuntime,
|
||||
} from "@/lib/runtime/custom/http";
|
||||
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
|
||||
|
||||
const CUSTOM_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
|
||||
"agents",
|
||||
"sessions",
|
||||
"chat",
|
||||
"models",
|
||||
"agent-roles",
|
||||
]);
|
||||
|
||||
type CustomRuntimeStateResponse = {
|
||||
profileName?: string | null;
|
||||
registry_profile?: string | null;
|
||||
active?: Record<string, unknown> | null;
|
||||
profile?: string | null;
|
||||
identity?: {
|
||||
name?: string | null;
|
||||
role?: string | null;
|
||||
lane?: string | null;
|
||||
model_id?: string | null;
|
||||
} | null;
|
||||
runtime?: {
|
||||
name?: string | null;
|
||||
version?: string | null;
|
||||
vendor?: string | null;
|
||||
status?: string | null;
|
||||
active_model?: string | null;
|
||||
governance?: string | null;
|
||||
} | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CustomRuntimeRegistryResponse = {
|
||||
models?: Record<string, unknown> | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CustomRuntimeHealthResponse = {
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type SyntheticAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
};
|
||||
|
||||
type SessionMessage = {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type SessionRecord = {
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
role: string | null;
|
||||
model: string | null;
|
||||
updatedAt: number | null;
|
||||
messages: SessionMessage[];
|
||||
};
|
||||
|
||||
type ActiveRunRecord = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
controller: AbortController;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const titleCase = (value: string): string =>
|
||||
value
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
|
||||
const resolveRouteProfile = (state: CustomRuntimeStateResponse | null): string | null => {
|
||||
if (!state) return null;
|
||||
if (typeof state.profileName === "string" && state.profileName.trim()) return state.profileName.trim();
|
||||
if (typeof state.registry_profile === "string" && state.registry_profile.trim()) {
|
||||
return state.registry_profile.trim();
|
||||
}
|
||||
if (typeof state.profile === "string" && state.profile.trim()) return state.profile.trim();
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractContentText = (content: unknown): string => {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (isRecord(item) && typeof item.text === "string") return item.text;
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const resolveAssistantTextFromResponse = (payload: unknown): string | null => {
|
||||
if (!isRecord(payload)) return null;
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const first = choices[0];
|
||||
if (!isRecord(first)) return null;
|
||||
const message = isRecord(first.message) ? first.message : null;
|
||||
const direct = extractContentText(message?.content);
|
||||
if (direct) return direct;
|
||||
const text = extractContentText(first.text);
|
||||
return text || null;
|
||||
};
|
||||
|
||||
const normalizeModelChoices = (registry: CustomRuntimeRegistryResponse | null): string[] => {
|
||||
if (!registry || !isRecord(registry.models)) return [];
|
||||
return Object.keys(registry.models).map((value) => value.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
const resolveOptionalString = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
|
||||
const resolveDefaultModelId = (
|
||||
state: CustomRuntimeStateResponse | null,
|
||||
modelChoices: string[]
|
||||
): string | null => {
|
||||
return (
|
||||
resolveOptionalString(state?.identity?.model_id) ??
|
||||
resolveOptionalString(state?.runtime?.active_model) ??
|
||||
modelChoices[0] ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const buildIdentityAgent = (
|
||||
state: CustomRuntimeStateResponse | null,
|
||||
runtimeName: string
|
||||
): SyntheticAgent | null => {
|
||||
const name = resolveOptionalString(state?.identity?.name);
|
||||
const role = resolveOptionalString(state?.identity?.role) ?? "assistant";
|
||||
const lane = resolveOptionalString(state?.identity?.lane);
|
||||
if (!name && !lane && !role) return null;
|
||||
return {
|
||||
id: lane ?? role ?? "main",
|
||||
name: name ?? titleCase(lane ?? runtimeName),
|
||||
role,
|
||||
};
|
||||
};
|
||||
|
||||
const buildChatFailureMessage = (
|
||||
statusCode: number,
|
||||
responseText: string,
|
||||
health: CustomRuntimeHealthResponse | null
|
||||
): string => {
|
||||
const trimmed = responseText.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const healthStatus = resolveOptionalString(health?.status);
|
||||
if (healthStatus) {
|
||||
return `Custom runtime chat failed (${statusCode}). Runtime health is ${healthStatus}.`;
|
||||
}
|
||||
return `Custom runtime chat failed (${statusCode}).`;
|
||||
};
|
||||
|
||||
const buildSyntheticAgents = (
|
||||
state: CustomRuntimeStateResponse | null,
|
||||
runtimeName: string
|
||||
): SyntheticAgent[] => {
|
||||
const active = isRecord(state?.active) ? state.active : null;
|
||||
if (active) {
|
||||
const agents: SyntheticAgent[] = [];
|
||||
for (const [roleKey, value] of Object.entries(active)) {
|
||||
const role = roleKey.trim();
|
||||
if (!role) continue;
|
||||
const hasModels =
|
||||
(typeof value === "string" && value.trim()) ||
|
||||
(Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.trim()));
|
||||
if (!hasModels) continue;
|
||||
agents.push({
|
||||
id: role,
|
||||
name: titleCase(role),
|
||||
role,
|
||||
});
|
||||
}
|
||||
if (agents.length > 0) {
|
||||
return agents;
|
||||
}
|
||||
}
|
||||
const identityAgent = buildIdentityAgent(state, runtimeName);
|
||||
if (identityAgent) {
|
||||
return [identityAgent];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "main",
|
||||
name: runtimeName,
|
||||
role: "assistant",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export class CustomRuntimeProvider implements RuntimeProvider {
|
||||
readonly id = "custom" as const;
|
||||
readonly label = "Custom";
|
||||
readonly capabilities = CUSTOM_RUNTIME_CAPABILITIES;
|
||||
readonly metadata;
|
||||
private readonly baseUrl: string;
|
||||
private readonly sessions = new Map<string, SessionRecord>();
|
||||
private readonly activeRunsByRunId = new Map<string, ActiveRunRecord>();
|
||||
private readonly activeRunIdBySessionKey = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
readonly client: GatewayClient,
|
||||
runtimeUrl: string
|
||||
) {
|
||||
this.baseUrl = normalizeCustomBaseUrl(runtimeUrl);
|
||||
this.metadata = {
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
runtimeName: "Custom Runtime",
|
||||
routeProfile: null,
|
||||
};
|
||||
}
|
||||
|
||||
connect(options: GatewayConnectOptions): Promise<void> {
|
||||
return this.client.connect(options);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.client.disconnect();
|
||||
}
|
||||
|
||||
async call<T = unknown>(method: string, params: unknown): Promise<T> {
|
||||
switch (method) {
|
||||
case "agents.list":
|
||||
return (await this.callAgentsList()) as T;
|
||||
case "sessions.list":
|
||||
return (await this.callSessionsList(params)) as T;
|
||||
case "status":
|
||||
return (await this.callStatus()) as T;
|
||||
case "models.list":
|
||||
return (await this.callModelsList()) as T;
|
||||
case "sessions.preview":
|
||||
return (await this.callSessionsPreview(params)) as T;
|
||||
case "chat.history":
|
||||
return (await this.callChatHistory(params)) as T;
|
||||
case "chat.send":
|
||||
return (await this.callChatSend(params)) as T;
|
||||
case "chat.abort":
|
||||
return (await this.callChatAbort(params)) as T;
|
||||
case "sessions.reset":
|
||||
return (await this.callSessionsReset(params)) as T;
|
||||
case "agent.wait":
|
||||
return (await this.callAgentWait(params)) as T;
|
||||
case "exec.approvals.get":
|
||||
return ({ file: { agents: {} } } as T);
|
||||
case "config.get":
|
||||
case "config.patch":
|
||||
case "config.set":
|
||||
throw new Error(`Custom runtime does not support ${method}.`);
|
||||
default:
|
||||
throw new Error(`Custom runtime does not implement ${method}.`);
|
||||
}
|
||||
}
|
||||
|
||||
onStatus(handler: (status: GatewayStatus) => void): () => void {
|
||||
return this.client.onStatus(handler);
|
||||
}
|
||||
|
||||
onGap(handler: (info: GatewayGapInfo) => void): () => void {
|
||||
return this.client.onGap(handler);
|
||||
}
|
||||
|
||||
onEvent(handler: (event: EventFrame) => void): () => void {
|
||||
return this.client.onEvent(handler);
|
||||
}
|
||||
|
||||
onRuntimeEvent(_handler: (event: RuntimeEvent) => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async fetchHealth(): Promise<CustomRuntimeHealthResponse> {
|
||||
return this.fetchJson<CustomRuntimeHealthResponse>("/health");
|
||||
}
|
||||
|
||||
async fetchState(): Promise<CustomRuntimeStateResponse> {
|
||||
return this.fetchJson<CustomRuntimeStateResponse>("/state");
|
||||
}
|
||||
|
||||
async fetchRegistry(): Promise<CustomRuntimeRegistryResponse> {
|
||||
return this.fetchJson<CustomRuntimeRegistryResponse>("/registry");
|
||||
}
|
||||
|
||||
async describeRuntime() {
|
||||
const [health, state, registry] = await Promise.all([
|
||||
this.fetchHealth().catch(() => null),
|
||||
this.fetchState().catch(() => null),
|
||||
this.fetchRegistry().catch(() => null),
|
||||
]);
|
||||
|
||||
const routeProfile = resolveRouteProfile(state);
|
||||
const runtimeName =
|
||||
typeof state?.runtime?.name === "string" && state.runtime.name.trim()
|
||||
? state.runtime.name.trim()
|
||||
: this.metadata.runtimeName;
|
||||
const runtimeVersion =
|
||||
typeof state?.runtime?.version === "string" && state.runtime.version.trim()
|
||||
? state.runtime.version.trim()
|
||||
: null;
|
||||
const vendor =
|
||||
typeof state?.runtime?.vendor === "string" && state.runtime.vendor.trim()
|
||||
? state.runtime.vendor.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
...this.metadata,
|
||||
runtimeName,
|
||||
runtimeVersion,
|
||||
vendor,
|
||||
routeProfile,
|
||||
},
|
||||
health,
|
||||
state,
|
||||
registry,
|
||||
};
|
||||
}
|
||||
|
||||
private async callAgentsList() {
|
||||
const descriptor = await this.describeRuntime();
|
||||
const runtimeName = descriptor.metadata.runtimeName ?? this.metadata.runtimeName ?? "Custom Runtime";
|
||||
const agents = buildSyntheticAgents(descriptor.state, runtimeName);
|
||||
return {
|
||||
defaultId: agents[0]?.id ?? "main",
|
||||
mainKey: "main",
|
||||
scope: "custom",
|
||||
agents: agents.map((agent) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async callSessionsList(rawParams: unknown) {
|
||||
const params = isRecord(rawParams) ? rawParams : {};
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId.trim() : "";
|
||||
const descriptor = await this.describeRuntime();
|
||||
const modelChoices = normalizeModelChoices(descriptor.registry);
|
||||
const sessions = agentId
|
||||
? [this.ensureSession(agentId, agentId, resolveDefaultModelId(descriptor.state, modelChoices))]
|
||||
: [...this.sessions.values()];
|
||||
return {
|
||||
sessions: sessions.map((session) => ({
|
||||
key: session.sessionKey,
|
||||
updatedAt: session.updatedAt,
|
||||
displayName: session.agentId,
|
||||
origin: {
|
||||
label: descriptor.metadata.runtimeName ?? "Custom Runtime",
|
||||
provider: "custom",
|
||||
},
|
||||
modelProvider: "custom",
|
||||
model: session.model,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async callStatus() {
|
||||
return {
|
||||
sessions: {
|
||||
recent: [...this.sessions.values()].map((session) => ({
|
||||
key: session.sessionKey,
|
||||
updatedAt: session.updatedAt,
|
||||
})),
|
||||
byAgent: [...this.sessions.values()].map((session) => ({
|
||||
agentId: session.agentId,
|
||||
recent: [
|
||||
{
|
||||
key: session.sessionKey,
|
||||
updatedAt: session.updatedAt,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async callModelsList() {
|
||||
const descriptor = await this.describeRuntime();
|
||||
const modelIds = normalizeModelChoices(descriptor.registry);
|
||||
return {
|
||||
models: modelIds.map((id) => ({
|
||||
id,
|
||||
name: id,
|
||||
provider: "custom",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async callSessionsPreview(rawParams: unknown) {
|
||||
const params = isRecord(rawParams) ? rawParams : {};
|
||||
const keys = Array.isArray(params.keys)
|
||||
? params.keys.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
return {
|
||||
ts: Date.now(),
|
||||
previews: keys.map((key) => {
|
||||
const session = this.sessions.get(key) ?? null;
|
||||
const items = session
|
||||
? session.messages.slice(-8).map((message) => ({
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
timestamp: message.timestamp,
|
||||
}))
|
||||
: [];
|
||||
return {
|
||||
key,
|
||||
status: items.length > 0 ? "ok" : "empty",
|
||||
items,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async callChatHistory(rawParams: unknown) {
|
||||
const params = isRecord(rawParams) ? rawParams : {};
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||
if (!sessionKey) {
|
||||
throw new Error("Custom runtime requires sessionKey for chat.history.");
|
||||
}
|
||||
const session = this.sessions.get(sessionKey) ?? null;
|
||||
return {
|
||||
sessionKey,
|
||||
messages: (session?.messages ?? []).map((message) => ({
|
||||
role: message.role,
|
||||
content: message.text,
|
||||
timestamp: message.timestamp,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async callChatSend(rawParams: unknown) {
|
||||
const params = isRecord(rawParams) ? rawParams : {};
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||
const message = typeof params.message === "string" ? params.message.trim() : "";
|
||||
const runId = typeof params.idempotencyKey === "string" ? params.idempotencyKey.trim() : "";
|
||||
if (!sessionKey || !message) {
|
||||
throw new Error("Custom runtime requires sessionKey and message for chat.send.");
|
||||
}
|
||||
const agentId = parseAgentIdFromSessionKey(sessionKey) ?? "main";
|
||||
const descriptor = await this.describeRuntime();
|
||||
const modelChoices = normalizeModelChoices(descriptor.registry);
|
||||
const session = this.ensureSession(
|
||||
sessionKey,
|
||||
agentId,
|
||||
resolveDefaultModelId(descriptor.state, modelChoices)
|
||||
);
|
||||
const resolvedRole =
|
||||
session.role ??
|
||||
resolveOptionalString(descriptor.state?.identity?.role) ??
|
||||
undefined;
|
||||
const resolvedLane =
|
||||
resolveOptionalString(descriptor.state?.identity?.lane) ??
|
||||
session.role ??
|
||||
undefined;
|
||||
const controller = new AbortController();
|
||||
if (runId) {
|
||||
const activeRun: ActiveRunRecord = { runId, sessionKey, controller };
|
||||
this.activeRunsByRunId.set(runId, activeRun);
|
||||
this.activeRunIdBySessionKey.set(sessionKey, runId);
|
||||
}
|
||||
const userTimestamp = Date.now();
|
||||
session.messages.push({
|
||||
role: "user",
|
||||
text: message,
|
||||
timestamp: userTimestamp,
|
||||
});
|
||||
session.updatedAt = userTimestamp;
|
||||
|
||||
try {
|
||||
const payload = (await requestCustomRuntime({
|
||||
runtimeUrl: this.baseUrl,
|
||||
pathname: "/v1/chat/completions",
|
||||
method: "POST",
|
||||
body: {
|
||||
model: session.model ?? undefined,
|
||||
stream: false,
|
||||
role: resolvedRole,
|
||||
lane: resolvedLane,
|
||||
conversation_id: sessionKey,
|
||||
session_id: sessionKey,
|
||||
messages: session.messages.map((entry) => ({
|
||||
role: entry.role,
|
||||
content: entry.text,
|
||||
})),
|
||||
},
|
||||
})) as unknown;
|
||||
const assistantText = resolveAssistantTextFromResponse(payload);
|
||||
if (!assistantText) {
|
||||
throw new Error("Custom runtime returned an empty assistant response.");
|
||||
}
|
||||
const assistantTimestamp = Date.now();
|
||||
session.messages.push({
|
||||
role: "assistant",
|
||||
text: assistantText,
|
||||
timestamp: assistantTimestamp,
|
||||
});
|
||||
session.updatedAt = assistantTimestamp;
|
||||
return {
|
||||
status: "completed",
|
||||
runId: runId || null,
|
||||
text: assistantText,
|
||||
};
|
||||
} catch (error) {
|
||||
const health = await this.fetchHealth().catch(() => null);
|
||||
throw new Error(
|
||||
buildChatFailureMessage(
|
||||
502,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
health
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
if (runId) {
|
||||
this.activeRunsByRunId.delete(runId);
|
||||
const activeSessionRunId = this.activeRunIdBySessionKey.get(sessionKey);
|
||||
if (activeSessionRunId === runId) {
|
||||
this.activeRunIdBySessionKey.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async callChatAbort(rawParams: unknown) {
|
||||
const params = isRecord(rawParams) ? rawParams : {};
|
||||
const runId = typeof params.runId === "string" ? params.runId.trim() : "";
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||
const targetRunId = runId || (sessionKey ? this.activeRunIdBySessionKey.get(sessionKey) ?? "" : "");
|
||||
if (!targetRunId) {
|
||||
return { ok: true };
|
||||
}
|
||||
const activeRun = this.activeRunsByRunId.get(targetRunId) ?? null;
|
||||
activeRun?.controller.abort();
|
||||
this.activeRunsByRunId.delete(targetRunId);
|
||||
if (activeRun?.sessionKey) {
|
||||
const activeSessionRunId = this.activeRunIdBySessionKey.get(activeRun.sessionKey);
|
||||
if (activeSessionRunId === targetRunId) {
|
||||
this.activeRunIdBySessionKey.delete(activeRun.sessionKey);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async callSessionsReset(rawParams: unknown) {
|
||||
const params = isRecord(rawParams) ? rawParams : {};
|
||||
const key = typeof params.key === "string" ? params.key.trim() : "";
|
||||
if (!key) {
|
||||
throw new Error("Custom runtime requires key for sessions.reset.");
|
||||
}
|
||||
this.sessions.delete(key);
|
||||
const activeRunId = this.activeRunIdBySessionKey.get(key);
|
||||
if (activeRunId) {
|
||||
this.activeRunsByRunId.get(activeRunId)?.controller.abort();
|
||||
this.activeRunsByRunId.delete(activeRunId);
|
||||
this.activeRunIdBySessionKey.delete(key);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async callAgentWait(rawParams: unknown) {
|
||||
const params = isRecord(rawParams) ? rawParams : {};
|
||||
const runId = typeof params.runId === "string" ? params.runId.trim() : "";
|
||||
return {
|
||||
status: runId && this.activeRunsByRunId.has(runId) ? "running" : "done",
|
||||
};
|
||||
}
|
||||
|
||||
private ensureSession(sessionKey: string, agentId: string, model: string | null): SessionRecord {
|
||||
const existing = this.sessions.get(sessionKey);
|
||||
if (existing) return existing;
|
||||
const session: SessionRecord = {
|
||||
sessionKey,
|
||||
agentId,
|
||||
role: agentId || null,
|
||||
model,
|
||||
updatedAt: null,
|
||||
messages: [],
|
||||
};
|
||||
this.sessions.set(sessionKey, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private async fetchJson<T = unknown>(pathname: string): Promise<T> {
|
||||
return fetchCustomRuntimeJson<T>(this.baseUrl, pathname);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user