diff --git a/.gitignore b/.gitignore index 5bc6b921..68bd5fab 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ src/generated.ts dist/ .wrangler/ .pnpm-store/ +.artifacts/ diff --git a/README.md b/README.md index 55fb765b..8f0ac057 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Crabfleet gives OpenClaw maintainers a fleet dashboard where every Codex crabbox - **Fleet-first workflow.** Create repo-ready Crabboxes from the app, SSH, or the Go CLI and see org Codex instances grouped by person. - **Board-based workflow.** Create cards from prompts, GitHub issues, or PRs. Track them through Todo, Running, Human Review, and Done lanes. +- **Claw Queue workflow.** Pick ClawSweeper-screened OpenClaw issues, choose Codex thinking mode, hand work to local Codex, track parallel plates, and copy maintainer-ready handoffs. - **Issue/PR lookup.** Type `#123` in search to preview matching GitHub issues or PRs across enabled OpenClaw repos and create a card from the match. - **Codex run control.** Start durable run attempts, track heartbeats, watch the Ghostty WASM session grid, and take over only when the selected runtime advertises that capability. - **Interactive Crabboxes.** Start a standalone Codex CLI workspace for manual cloud work and attach it in the same fullscreen Ghostty grid or WebVNC. @@ -61,6 +62,27 @@ Add users/teams to the allowlist and enable repos: - **From issue:** Search GitHub issues → create card - **From PR:** Search GitHub PRs → create card for review/fix +### 3b. Use Claw Queue + +Open `/app/claw-queue` when you want OpenClaw issue triage instead of a generic board card. + +- Configure role, target repo, GitHub login, usage limit, worker count, default Thinking mode, and minimum issue age. +- Paste a specific GitHub issue URL or number when the issue you want is not shown in the queue. +- Choose per-issue Thinking and Proof mode before copying a prompt, tracking manual work, or starting Codex. +- Use Blade actions to inspect issue or PR GitHub pages in a side panel while keeping the queue, loop, or active-work context visible. +- Connect the local Codex bridge with `pnpm openclaw:runner -- --workspace --worktree-dir --port 4545 --max-active 2 --reasoning-effort high --token `. +- Install Tokenjuice with `npm install -g tokenjuice` and `tokenjuice install codex` when you want started workers to compact noisy terminal output. +- Start or safe Track posts a marker-backed "working on this" issue claim through your local `gh` auth before the Codex run is launched or recorded; coverage-investigation tracks do not pretend the issue is claimed. +- Use Command Center first; it picks the next eligible issue, active plate, or blocked state. +- Use `/app/claw-loop` for the full-width Master Loop swimlanes across ready issues, validation, coding, PR readiness, handoff, and completed work. +- Reject unsuitable queue candidates from Claw Queue or Loop to hide them locally with a reason, then restore them from the Local rejects panel if needed. +- Use Active Work to track parallel Codex/tmux/app sessions, copy each Codex worktree path, watch local run logs, and keep each PR visible until it is ready, parked, or archived. +- When the local bridge is connected, Claw Queue can use Gitcrawl's local OpenClaw store for broad queue rows, possible PR coverage, authored open PR rows, and cached check hints while live GitHub remains the final source for claims, PR-limit gates, PR updates, and CI truth. +- Start Codex runs include a bounded local skill packet from `%USERPROFILE%\.codex\skills` and the common OpenClaw maintainer skills checkout by default, so workers know where the relevant OpenClaw, Gitcrawl, Tokenjuice, Crabbox, ClawSweeper, PR review, security, CI, and Codex review skills live on the machine. +- For loopback-only local preview without GitHub OAuth or a bootstrap token, set `CRABFLEET_DEV_IDENTITY=1` so the login screen shows the Dev Identity Apply panel. + +See [Claw Queue docs](docs/claw-queue.md). + ### 4. Watch Runs - Running cards show D1 event logs and heartbeat state diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index c0f4d32c..51a28602 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -220,6 +220,7 @@

Start

Quickstart Architecture

Features

+ Claw Queue Cards Runs Admin diff --git a/docs/api.md b/docs/api.md index e444e234..65467649 100644 --- a/docs/api.md +++ b/docs/api.md @@ -126,6 +126,141 @@ Maintainer+. Searches enabled repos for issue/PR number matches. With `GITHUB_TOKEN`, lookup runs across all enabled repos. Without it, lookup falls back to the preferred repo. +## Claw Queue + +Claw Queue uses same-origin app endpoints for OpenClaw workflow state and a separate local loopback bridge for Codex handoff. + +### GET /api/openclaw/workflow + +Viewer+. Returns the configured OpenClaw workflow pack for the current user. + +Optional query parameters: + +- `repo`: normalized `owner/repo` target repo. +- `login`: GitHub login used for authored open PR monitoring. + +Response includes: + +- `preferences`: role, repo, GitHub login, worker count, Codex reasoning effort, usage limits, and issue age gate. +- `queues`: ClawSweeper-screened queue definitions, search queries, candidates, totals, per-queue errors, and candidate `possiblePrCoverage` warnings when recent open PRs directly reference an issue. +- `pullRequests`: authored open PR summaries with CI and ClawSweeper signals. +- `governor`: whether new work can start, plus open PR and usage-limit reasons. +- `process`: links to OpenClaw contributing files and maintainer guardrails. + +### GET /api/openclaw/issue + +Viewer+. Loads one specific OpenClaw issue by URL, `#number`, or number. Use this when the issue is not currently visible in the queue. + +Optional query parameters: + +- `input`: required issue URL, `#number`, or number. +- `repo`: normalized `owner/repo` fallback for plain numbers. + +Response includes: + +- `repo`: repo used for the lookup. +- `issueNumber`: loaded issue number. +- `candidate`: the same candidate shape used by queue rows, including labels, ClawSweeper readiness, age gate, generated prompt, and `possiblePrCoverage`. +- `coverageError`: warning text when the possible PR coverage scan was partial or failed. + +### PUT /api/openclaw/preferences + +Viewer+. Saves the current user's Claw Queue preferences. + +```json +{ + "roleMode": "trial_maintainer", + "targetRepo": "openclaw/openclaw", + "githubLogin": "octocat", + "activeOpenPrLimit": 10, + "dailyUsageDropLimit": 5, + "maxParallelWorkers": 2, + "codexReasoningEffort": "high", + "minimumIssueAgeHours": 6, + "weeklyRemainingBaseline": 97, + "weeklyRemainingCurrent": 92 +} +``` + +### POST /api/openclaw/handoff + +Viewer+. Builds the short Discord-ready maintainer handoff text. + +```json +{ + "prUrl": "https://github.com/openclaw/openclaw/pull/123", + "issueUrl": "https://github.com/openclaw/openclaw/issues/99", + "title": "#123 Fix queue guard", + "summary": "Queue guard now respects linked PR labels.", + "proof": "pnpm test and Codex review passed.", + "ci": "CI green", + "clawsweeper": "ready for maintainer look" +} +``` + +### Local Codex Bridge + +The browser talks directly to the local bridge configured in Claw Queue, normally `http://127.0.0.1:4545`. + +Bridge endpoints: + +```text +GET /health +GET /runs +GET /runs/:id +GET /runs/:id/log +PATCH /runs/:id +POST /claim +POST /start +POST /track +POST /gitcrawl/coverage +POST /gitcrawl/workflow +``` + +Every bridge request requires: + +```text +Authorization: Bearer +``` + +Start the bridge from the repo checkout: + +```bash +pnpm openclaw:runner -- --workspace --worktree-dir --port 4545 --max-active 2 --reasoning-effort high --token +``` + +Add `--dry-run` to record prompts and Active Work rows without launching Codex. + +`--reasoning-effort` can also be provided with `OPENCLAW_CODEX_REASONING_EFFORT`. The default is `high`. + +By default the bridge scans `%USERPROFILE%\.codex\skills` plus the common OpenClaw maintainer skills checkout at `%LOCALAPPDATA%\OpenClawMaintainer\repos\maintainers\.agents\skills`, then injects a bounded list of relevant local `SKILL.md` paths into every `/start` prompt. Use `--skills-dir ` or `OPENCLAW_CODEX_SKILLS_DIR` to override the primary Codex skill location. Use `--maintainer-skills-dir `, `OPENCLAW_MAINTAINER_SKILLS_DIR`, or `OPENCLAW_MAINTAINERS_DIR` to point at a different maintainer checkout. Use `--no-skill-context` to disable the skill packet. + +The bridge also reports and injects the resolved Tokenjuice command for output compaction. Use `--tokenjuice-bin ` or `TOKENJUICE_BIN` if `tokenjuice` is not on `PATH`. Workers should use `tokenjuice doctor hooks` to verify hook health and `tokenjuice wrap --raw -- ` when exact raw output is required. + +`GET /health` reports `defaultReasoningEffort`, `baseWorkspace`, `worktreeDir`, `worktreeBase`, and `tokenjuiceCommand`. `GET /runs` and `GET /runs/:id` include each run's `codexReasoningEffort`, `proofMode`, `baseWorkspace`, `worktreePath`, and `worktreeBranch` when known. + +For `/start`, `--workspace` is treated as the clean base checkout. The bridge creates a unique git worktree and branch for each Codex process, then launches Codex inside that worktree. This keeps parallel starts isolated from each other. `/track` records external/manual work and does not create a worktree. + +`POST /claim` accepts `{ "repo": "owner/repo", "issueNumber": 123, "issueUrl": "https://github.com/owner/repo/issues/123", "title": "Issue title" }`. It uses the local `gh` CLI to post a marker-backed issue comment, or returns the existing marker-backed claim if the same bridge/user already claimed that issue. Claw Queue calls this before `/start` and `/track`, then stores `claimCommentStatus` and `claimCommentUrl` on the run. + +`POST /gitcrawl/coverage` accepts `{ "repo": "owner/repo", "issueNumbers": [123] }` and returns local Gitcrawl snapshot PR coverage for displayed issues. It checks open PR titles and indexed excerpts, so Codex prompts still tell workers to re-check GitHub before coding. + +`POST /gitcrawl/workflow` accepts `{ "repo": "owner/repo", "githubLogin": "user", "roleMode": "trial_maintainer", "minimumIssueAgeHours": 6 }` and returns one local Gitcrawl snapshot for Claw Queue broad reads: + +- queue definitions and candidates for the selected role, +- possible open PR coverage for those candidates, +- authored open PR rows and any cached check rows for the configured login, +- snapshot metadata including `lastSyncAt`. + +The browser prefers this snapshot for broad queue/coverage reads when the local bridge is connected, then keeps live GitHub data for final issue state, claim comments, PR-limit gates, PR updates, and CI truth. Set `OPENCLAW_GITCRAWL_DB_PATH` on the bridge to point directly at a Gitcrawl SQLite store; otherwise the bridge runs `gitcrawl doctor --json` and caches the result briefly. + +`POST /start` and `POST /track` accept: + +- `codexReasoningEffort`: `low`, `medium`, `high`, or `xhigh`. +- `proofMode`: `auto`, `local`, `crabbox`, `testbox`, or `mantis`. + +The bridge records both values on the run. `/start` launches Codex with `model_reasoning_effort=""`; `/track` only records the manually started work. Omitted or invalid reasoning values fall back to `high` or the bridge's `--reasoning-effort` default. Omitted or invalid proof modes fall back to `auto`. + ## Cards ### POST /api/cards diff --git a/docs/claw-queue-brief.md b/docs/claw-queue-brief.md new file mode 100644 index 00000000..f9ab5adf --- /dev/null +++ b/docs/claw-queue-brief.md @@ -0,0 +1,94 @@ +--- +title: Claw Queue Brief +layout: default +permalink: /claw-queue/brief/ +description: "A human-readable one-page summary of the Claw Queue workflow." +--- + +# Claw Queue: One-Page Summary + +Claw Queue is an OpenClaw-focused workflow layer inside Crabfleet. Its goal is to help contributors and trial maintainers turn good GitHub issues into good PRs without losing track of the process, the proof, or the work already in progress. + +It does not replace GitHub, ClawSweeper, maintainers, or the existing contribution rules. It sits on top of them and makes the path from "this issue looks queueable" to "this PR is ready for maintainer review" clearer and harder to get wrong. + +## The Problem + +OpenClaw already has useful issue queues, labels, ClawSweeper comments, CI checks, Mantis proof when available, and contribution guidance. The problem is that a contributor still has to join those pieces together manually. + +That becomes especially messy when someone is juggling more than one Codex session or local terminal. It is easy to pick an issue that already has a PR, miss a higher-priority queue, forget to wait for ClawSweeper, open a PR without enough proof, lose track of CI, or ask maintainers to review something that is not actually ready. + +## What Claw Queue Does + +Claw Queue turns that scattered process into one operating surface. + +It can: + +- show ClawSweeper-screened queueable issues in priority order, +- prefer issues with no linked/open PR and warn when recent open PRs appear to reference the same issue, +- load a specific issue by URL or number when the user already knows what they want to inspect, +- support contributor, trial-maintainer, and maintainer modes, +- apply personal limits such as open PR count, worker count, usage budget, and minimum issue age, +- choose a default Codex thinking mode, choose a proof route, and override both per issue before launch, +- pass Tokenjuice output-compaction guidance to started workers and copy/paste prompts, +- use Crabbox or Blacksmith Testbox for remote/cross-version validation when local proof is not enough, +- generate a complete Codex-ready prompt for a selected issue, +- open issue and PR context in a side GitHub blade while keeping the queue visible, +- post or reuse a marker-backed issue claim comment before starting or tracking safe work, +- connect to a local Codex bridge to start or track work, +- track active work across Codex, tmux, local shells, or manual copy/paste, +- show a short inline activity tail for bridge-managed Codex runs, +- show a Master Loop swimlane view across ready issues, validation, coding, PR readiness, maintainer handoff, handoff sent, and completed work, +- monitor authored PRs for CI, ClawSweeper readiness, proof labels, and Mantis visibility, +- produce a short Discord-ready maintainer handoff when a PR is ready. + +The important shift is that the page is not only an issue picker. It tries to carry the work through the whole contribution loop. + +For data discovery, Claw Queue follows the same pattern used elsewhere in the OpenClaw ecosystem: archive/cache first for broad issue and PR discovery, live GitHub only for final verification and mutations. The Worker still asks live GitHub for final state, but when the local Codex bridge is connected the page also reads a Gitcrawl workflow snapshot for broad queue rows, possible PR coverage, and authored open PR rows. The local snapshot is not final authority for bypassing live open-PR limits. The next production-grade step is a central Gitcrawl-backed snapshot source so hosted Crabfleet can do the same without relying on each user's local store. + +## How The Workflow Feels + +The user starts in Command Center. If new work is allowed, it shows the next safest issue to pick. If work is already active, it helps resume or close out that plate instead. If limits, GitHub errors, usage budget, or worker capacity block new work, it says so directly. + +Master Loop is the controller view at `/app/claw-loop`. It reads the same queue, active-work, PR, CI, proof, and ClawSweeper state and classifies every visible item into full-width swimlanes. In the current safe phase it observes, explains, and recommends the next action. Later phases can auto-move rows, generate handoffs, and start Codex workers within user limits. + +Queue candidates can be rejected locally from either Claw Queue rows or Loop swimlane candidate cards. This is a personal hide list with a reason and restore action, not a GitHub mutation. It is meant for cases where a trial maintainer manually confirms an item is not suitable, already has a linked PR, needs a decision, or otherwise should not keep appearing as available work. + +When a user starts or tracks an issue, Claw Queue uses the local bridge and the user's `gh` auth to post or find the "working on this" claim comment when the issue is safe to claim. If possible PR coverage is still unknown, Track records the investigation without pretending the issue is claimed. Each active row records the selected thinking mode, proof mode, and claim state, can be resumed, linked to a PR, marked ready, parked, or archived. For Start Codex runs, the local bridge creates a separate git worktree and branch per issue, then exposes that path in Active Work. Worker Runway shows how many local plates are open based on the user's worker count, so running two or three parallel Codex jobs becomes isolated and visible rather than remembered in someone's head. + +For users who cannot or do not want to start Codex directly from the page, Claw Queue still provides a copy/paste prompt. That prompt tells Codex to use Tokenjuice for noisy terminal output when available, re-check the issue and any possible PR coverage, read the current OpenClaw guidance, follow ClawSweeper, keep the fix focused, run tests, use Codex review, monitor CI, and stop only when the PR is genuinely ready for maintainer look. + +The prompt also carries maintainer-handbook guardrails. Release-sensitive work needs release-branch awareness, plugin install/update/SDK/package work needs maintainer discussion and broader contract validation, security-adjacent work should be escalated without public vulnerability metadata, and issues owned by another OpenClaw repo should be routed instead of patched in the wrong place. For a trial maintainer, the target outcome is evidence and a handoff, not merge authority. + +## What Counts As Done + +In this workflow, "done" does not mean "a PR exists." + +Done means: + +- the issue is linked, +- the fix is focused, +- proof or tests are included, +- Codex review has been run, +- CI is green or failures are clearly explained, +- ClawSweeper/status labels indicate readiness, +- the maintainer handoff is short enough to post directly in Discord. + +If proof is not good enough and Mantis is unavailable or broken, the correct outcome is to park the work with a clear blocker note rather than push a weak PR. + +## Why It Matters + +For contributors, Claw Queue should make OpenClaw work less intimidating and less ambiguous. + +For trial maintainers, it provides a responsible way to help with high-priority issues without needing merge permissions. + +For maintainers, it should reduce duplicate, incomplete, or low-proof PRs and make review requests easier to act on. + +For Codex users, it gives the agent a process-aware work order instead of a bare issue link. + +The bigger aim is to make OpenClaw contribution work feel less chaotic: pick the right issue, start it with the right instructions, track the active plates, prove the fix, watch the PR, and hand it to maintainers only when it is ready. + +## Current Shape + +This is currently built as an OpenClaw-specific Crabfleet feature. It can be tested safely against a fork with dummy issues and PRs, and the local Codex bridge supports dry-run mode so the workflow can be demonstrated without launching real work. + +The next useful proof is to test it hard against forked OpenClaw issues and PRs, then decide whether it should remain a Crabfleet feature, be proposed upstream, or become a more general contributor workflow surface. diff --git a/docs/claw-queue.md b/docs/claw-queue.md new file mode 100644 index 00000000..9e30388b --- /dev/null +++ b/docs/claw-queue.md @@ -0,0 +1,392 @@ +--- +title: Claw Queue +layout: default +permalink: /claw-queue/ +description: "Use Claw Queue to pick OpenClaw issues, hand them to Codex, track parallel work, and prepare maintainer handoffs." +--- + +# Claw Queue + +Claw Queue is the OpenClaw contribution cockpit in Crabfleet. It turns the OpenClaw issue queues into a guided workflow for contributors, trial maintainers, and maintainers: pick eligible issues, hand work to Codex, track multiple plates, and copy a short maintainer handoff when the PR is ready. + +It does not merge PRs, land code, enable automerge, or replace maintainer judgement. Trial maintainers should use it to prepare ready-for-review PRs and Discord handoffs. + +Claw Queue also applies the maintainer-handbook rules that matter before a contributor or trial maintainer starts work: release-sensitive fixes need release-branch awareness, plugin install/update/SDK/package changes need maintainer discussion before final shape, and security-adjacent work should be escalated rather than handled as an ordinary public PR. + +## Before You Begin + +You need: + +- Access to the Crabfleet app at `/app/claw-queue`; the full-width loop view is at `/app/claw-loop`. +- GitHub access to the target OpenClaw repo. +- A GitHub login entered in Claw Queue when PR monitoring should count your open PRs. +- A local OpenClaw checkout when you want the local Codex bridge to start real work. +- Codex installed on the machine running the bridge. +- Tokenjuice installed when you want Codex workers to compact noisy terminal output. The recommended setup is `npm install -g tokenjuice`, `tokenjuice install codex`, then `tokenjuice doctor hooks`. + +For dummy testing, use a fork such as `brokemac79/openclaw`, set **Min issue age** to `0`, and create test issues/PRs there. Do not use upstream OpenClaw issues or PRs for dry-run product testing. + +## Quickstart + +1. Open `/app/claw-queue`. +2. Sign in or apply a preview identity in local development. +3. Set **Repo** to the target repo, normally `openclaw/openclaw` or your fork. +4. Set **GitHub login** to the author login used for open PR monitoring. +5. Set **Role**: + - `Contributor`: contributor-safe queue views. + - `Trial maintainer`: priority queues plus maintainer handoff guardrails. + - `Maintainer`: maintainer queue views for users with full permissions. +6. Set **Workers** to the number of local Codex plates you want to juggle. +7. Set **Thinking** to the default Codex reasoning effort for copied prompts and local starts. The default is `High`; individual issue rows can override it before launch. +8. Set **Usage limit** and weekly usage fields when you want Claw Queue to pause new work after a configured daily drop. +9. Connect the local Codex bridge. +10. Use **Command Center** first. It tells you the next safest action. +11. Open `/app/claw-loop` when you want the full-width Master Loop swimlane view. + +## Connect the Local Codex Bridge + +The local bridge is a loopback HTTP service that lets the browser page start or track local Codex work. It persists active-work rows and exposes run status back to the page. + +Use the **Runner command** button in the app, or run: + +```bash +pnpm openclaw:runner -- --workspace --worktree-dir --port 4545 --max-active 2 --reasoning-effort high --token +``` + +`--workspace` should point at a clean base OpenClaw checkout. Each **Start Codex** run creates a separate git worktree under `--worktree-dir`, creates a unique `claw-queue/issue-...` branch, launches Codex inside that worktree, and records the worktree path in Active Work. This is what makes `--max-active 2` or `--max-active 3` safe for parallel fixes. The worktree is intentionally kept after Codex exits so you can inspect, resume, push, or repair the PR branch. + +The bridge also injects a bounded local Codex skills packet into every started run. By default it scans `%USERPROFILE%\.codex\skills` and the common OpenClaw maintainer skills checkout at `%LOCALAPPDATA%\OpenClawMaintainer\repos\maintainers\.agents\skills`, then lists high-signal skills such as `openclaw-pre-pr-check`, `openclaw-pr-maintainer`, `openclaw-testing`, `clawsweeper`, `crabbox`, `gitcrawl`, `tokenjuice`, `pr-cluster`, `review-pr`, `gh-fix-ci`, `gh-address-comments`, `ghsa-opengrep-detector`, `openclaw-opengrep-remediation`, and `codex-review` with absolute `SKILL.md` paths. Override the primary directory with `--skills-dir ` or `OPENCLAW_CODEX_SKILLS_DIR`; override the maintainer skills directory with `--maintainer-skills-dir `, `OPENCLAW_MAINTAINER_SKILLS_DIR`, or `OPENCLAW_MAINTAINERS_DIR`; pass `--no-skill-context` only when a run must avoid local skill hints. + +The bridge also tells each started worker where the `tokenjuice` CLI resolves. Override that with `--tokenjuice-bin ` or `TOKENJUICE_BIN` if the command is not on `PATH`. Tokenjuice is an output-compaction layer only; Claw Queue usage-limit gates still use the weekly usage remaining fields entered in the UI. + +For a non-launching demo: + +```bash +pnpm openclaw:runner -- --workspace --worktree-dir --port 4545 --max-active 2 --reasoning-effort high --token --dry-run +``` + +Paste the runner URL and token into **Codex Handoff**, then click **Test**. + +Use a strong token. Keep the bridge bound to `127.0.0.1` or another loopback host. The page intentionally rejects non-local runner URLs. + +## What the Top Command Center Means + +Command Center is the place to start. It chooses one of three states: + +- **Next eligible issue**: no active plate exists, so it shows the highest-priority queueable issue. +- **Resume or handoff**: a plate exists, so it tells you what to do with the most important active row. +- **Paused or blocked**: GitHub, usage, open PR limits, or bridge state prevent new work. + +The gates show: + +| Gate | Meaning | +| ------ | ---------------------------------------------------------------------------------- | +| GitHub | Queue and PR lookups are loaded, partial, or blocked by GitHub errors/rate limits. | +| Limits | New work is allowed or paused by open PR count or usage drop. | +| Bridge | Local Codex bridge state: connected, dry-run, unknown, or error. | +| Lanes | Filled worker lanes against your configured worker count. | +| Next | The next eligible issue held by the queue planner. | + +Primary actions: + +- **Start**: post/fetch the issue claim comment through the local bridge, then send the generated issue prompt to local Codex. In dry-run mode this records the prompt without launching Codex. +- **Track**: add the issue to Active Work when you started it manually in Codex, tmux, or another app. When the row is safe to claim, Claw Queue posts or reuses the marker-backed claim first; when possible PR coverage is unknown or needs investigation, Track records the investigation without pretending the issue is claimed. +- **Prompt**: copy the full Codex-ready prompt. +- **Blade**: open the issue or PR in a right-side GitHub blade without leaving the workflow page. The blade includes copy/open actions because GitHub can block embedded previews. +- **Card**: create a Crabfleet board card for the issue. +- **Resume**: copy a prompt to resume an active plate. +- **Handoff**: copy a short maintainer-review message for Discord or PR comments. + +If a candidate has possible open PR coverage, **Start** is blocked until you inspect the listed PRs. **Prompt** still works so you can ask Codex to do that preflight, and **Track** still works when you already started an external/manual investigation. + +## Specific Issue Intake + +Use **Specific Issue** when you already know the issue you want but it is not visible in the queue. Paste a GitHub issue URL, `#number`, or number, then click **Load issue**. + +The loaded row uses the same checks and actions as queue rows: + +- live title, author, labels, and ClawSweeper readiness, +- minimum issue age gate, +- possible open PR coverage warning, +- per-issue **Thinking** and **Proof** controls, +- **Copy prompt**, **Track**, **Start Codex**, and **New card** actions. + +For a plain number or `#number`, Claw Queue uses the configured **Repo**. For a full GitHub URL, it uses the repo from the URL. + +## How Issues Are Selected + +Claw Queue uses the OpenClaw queue rules from the "Contributor / Maintainer Start Here" issue, then filters for issues that are: + +- open, +- not linked to a PR, +- marked queueable by ClawSweeper, +- outside the configured minimum issue age, +- not already tracked in Active Work, +- not flagged with possible open PR coverage. + +Trial maintainer ordering is: + +1. `P0` queueable issues. +2. `P1` queueable issues. +3. `P2` queueable issues. +4. Contributor-safe queueable issues with clear fix shape or source reproduction. + +If the same issue appears in multiple queue views, Claw Queue keeps the best priority identity for Worker Runway and Command Center. + +## Maintainer-Handbook Guardrails + +Claw Queue is role-aware. For a trial maintainer, the expected output is a focused PR, proof, Codex review, and a short maintainer handoff. The tool should not ask a trial maintainer to merge, land, close, approve, enable automerge, mutate release branches, or make GHSA state changes. + +The generated prompt asks Codex to stop or park before ordinary coding when the issue crosses a maintainer boundary: + +- release-sensitive fixes: validate or reason about `main` and the relevant `release/*` branch, then include a release-pick note instead of assuming the trial maintainer can pick it; +- plugin install, uninstall, update, packaging, SDK, or package upgrade behavior: require maintainer-channel discussion before the final shape and broaden validation to plugin contract surfaces such as plugin-inspector, kitchen-sink, or crabpot when relevant; +- security-adjacent work: auth, sandbox, command execution, file access, token, updater, provider, GHSA, CVE, advisory, or hardening issues should avoid public vulnerability metadata and be escalated instead of handled as a normal public PR; +- repo ownership: if the surface belongs in ClawHub, skills, plugin-inspector, kitchen-sink, crabpot, crabbox, gitcrawl, ClawSweeper, maintainers, or another OpenClaw repo, route or park it rather than patching the wrong repo; +- validation breadth: narrow changes can use scoped tests, but shared runtime, plugin contracts, release paths, security paths, package management, onboarding, and cross-platform behavior need broader proof, often Crabbox or Blacksmith Testbox. + +## Data Sources And Rate Limits + +The wider OpenClaw ecosystem generally follows an archive-first pattern for broad discovery: + +- use Gitcrawl or refreshed Gitcrawl-store data for issue/PR discovery, clustering, and report generation, +- keep that archive fresh outside the interactive request path, +- use live GitHub only for final truth before comments, labels, PR creation, review, merge, or other mutations, +- fall back to live GitHub search only when the archive is missing, stale, or cannot express the query. + +Claw Queue now follows the same direction in the parts it can control: + +- the Worker still fetches live GitHub data for final safety state: issue lookups, authored PRs, labels, CI/check-runs, and handoff readiness, +- the Worker uses GitHub Search caching and lower-pressure REST fallbacks when Search is rate-limited, +- when the local Codex bridge is connected, the browser also asks the bridge for one Gitcrawl workflow snapshot, +- that local snapshot can replace broad issue queue rows, clear broad PR-coverage uncertainty, and fill authored open PR rows when live authored-PR lookup is unavailable, +- live PR monitor rows are kept when they succeed, especially for CI state; local Gitcrawl only fills gaps such as missing rows or `unknown` cached checks, +- PR limits remain conservative when live lookup fails. A local Gitcrawl count can keep the page informative, but it is not treated as final authority to bypass a live open-PR limit failure. + +The local Gitcrawl workflow snapshot reads open issues, labels, open authored PRs, PR title/body excerpts, and any cached check rows from the user's Gitcrawl store. It is a practical rate-limit safety net, not final proof. Live GitHub checks should still run before starting work, opening/updating PRs, or generating final maintainer handoffs. Claim comments are live GitHub mutations and deliberately go through the local bridge's `gh` CLI instead of a background Codex tool call. + +Use the big page **Refresh** button when you want the normal hybrid load with the local Gitcrawl snapshot applied. Use the small refresh button on a queue or PR row when you want a live GitHub API refresh for that slice without the local Gitcrawl overlay; this is useful when you suspect the local snapshot is stale or a PR has just gone green. + +The intended production version should add a central Gitcrawl-backed snapshot source rather than asking every browser load to hit GitHub Search or every user to maintain a local store. A scheduled OpenClaw service can read Gitcrawl-store, write queue and open-PR snapshots into Crabfleet storage, and let the Worker use those snapshots for broad discovery. + +## Worker Runway + +Worker Runway maps your configured worker count to lanes: + +- Filled lanes are issues already in Active Work. +- Open lanes show the next eligible issue. +- Suggestions skip issues already being worked locally. +- Lane order follows priority and ClawSweeper signals. + +Use **Copy lane plan** when you want a compact summary of all active and suggested work. + +Changing **Workers** changes how many lanes are shown after you save preferences; the page reloads workflow data after the save. It does not automatically start new issues; it only makes room for more suggested or tracked plates. + +Command Center, Worker Runway, Specific Issue, and each suggested issue row have a **Thinking** dropdown. It defaults to the saved Claw Queue setting and controls the generated prompt, Track row, and Start Codex request for that issue action. Start Codex passes the value to the local bridge as `codexReasoningEffort`, and the bridge launches Codex with `model_reasoning_effort=""`. + +They also have a **Proof** dropdown: + +- `Auto proof`: Codex chooses the cheapest credible proof path after intake. +- `Local proof`: prefer local reproduction, focused regression tests, or command-line proof. +- `Crabbox proof`: require live or remote validation before the row is ready, including reported branch/version, latest release, and current main when relevant. +- `Blacksmith Testbox`: use Blacksmith Testbox through Crabbox for remote validation and before/after proof when available. +- `Mantis if available`: ask for or use Mantis when it adds real evidence, then park if proof is still insufficient. + +The selected proof mode is copied into the prompt and stored on Active Work rows. + +## Master Loop And Swimlanes + +Master Loop is the read-only controller for the whole Claw Queue surface. It lives on `/app/claw-loop` so the swimlanes can use the full screen width instead of competing with the queue controls. It observes queues, local Codex runs, authored PRs, usage/open-PR limits, bridge status, proof state, CI, and ClawSweeper readiness. It does not merge, close, approve, or mutate GitHub. Its first job is to show the current state machine and the next safest action. + +The swimlanes are inferred automatically: + +- **Ready to start**: eligible queue items with no known PR coverage. +- **Validating**: items that need PR coverage checks, repro checks, latest release/current main validation, reported-branch validation, Crabbox proof, Blacksmith Testbox proof, or a human blocker decision. +- **Coding**: Codex, tmux, or manual plates before a linked PR is ready. +- **PR not ready**: authored/open PRs or linked rows that still need proof, CI, Codex review, or ClawSweeper readiness. +- **Ready handoff**: PRs/rows that appear ready for maintainer-look Discord text. +- **Handoff sent**: rows where the Discord handoff has been posted and the next step is maintainer feedback, merge, or follow-up. +- **Merged / closed**: completed rows to archive after the short lookback. + +Use **Copy loop brief** when you want a concise status dump. The intended later phases are: auto-move rows, auto-generate handoffs, then optionally auto-start Codex workers within user limits. The default behavior remains observe/recommend first. + +Each swimlane tile has **Blade** and **Open** actions. Candidate tiles also have **Reject**. **Blade** keeps the issue or PR in-context beside the swimlanes; **Open** launches the GitHub page directly in a browser tab. **Reject** hides that repo-specific candidate from your local Claw Queue and Loop views with a short reason, without mutating GitHub or affecting other users. The hidden list is shown as **Local rejects** and individual issues can be restored. + +Claw Queue checks possible PR coverage in two ways. It scans recent open PR titles/bodies for issue references, and it checks the GitHub issue timeline for cross-referenced pull requests. These matches remain visible as `possible PR` because they can be noisy and should be manually validated or rejected. + +## Active Work + +Active Work is the local truth for what Codex or external sessions are working on. + +Rows can come from: + +- **Start**: created by the local Codex bridge. +- **Track**: manually tracked work from copy/paste, tmux, another Codex app, or another local workflow. +- persisted bridge state after reload. + +Rows show the Codex thinking mode, proof route, and claim state that were used when the row was started or tracked, for example `thinking high`, `thinking xhigh`, `proof crabbox`, or `claimed`. + +Started bridge runs also show a short inline activity tail. This is the latest few sanitized status/log lines from the local runner so you can see what an agent is doing without opening the full log viewer. Use **Watch log** when you need the larger bounded log tail. + +The closeout radar shows how many active rows still need: + +- PR link, +- proof/tests, +- Codex review, +- CI/ClawSweeper state, +- ready handoff. + +Use row actions: + +- **Resume**: copy the resume prompt for that plate. +- **Blade**: open the linked PR when present, otherwise the issue, in the side GitHub blade. +- **Handoff**: copy the maintainer handoff. +- **Link PR**: attach the GitHub PR URL. +- **Watch log**: open a live local Codex log viewer. Running rows poll automatically; completed rows show a snapshot. +- **Ready**: mark a row ready for maintainer look once proof, PR, CI, Codex review, and ClawSweeper status are acceptable. +- **Handoff sent**: move a ready row into the waiting-for-maintainer lane after posting the Discord message. +- **Park**: stop when proof is insufficient, Mantis is unavailable, or a maintainer/reporter/security/product decision is needed. +- **Archive**: remove completed, stale, failed, or dry-run rows from the visible active list. + +The log viewer reads the bridge's local JSONL run log and shows a bounded tail, not the full file. It is meant for quick situational awareness while multiple Codex sessions are running. The underlying log path stays local and can be copied when deeper debugging is needed. + +## PR Monitor And Handoffs + +PR Monitor looks for authored open PRs for the configured GitHub login. It summarizes: + +- CI state, +- failing or pending checks, +- Mantis check visibility when present, +- ClawSweeper readiness labels, +- proof status labels. + +Use **Handoff** to generate a short Discord-ready message. It is available even when the row is not fully ready; in that case the copied text includes the current caveat, such as failing CI, pending CI, missing proof, or waiting-on-author state. A good handoff includes: + +Use **Blade** on a PR row when you need to inspect the GitHub page without losing your queue context. If GitHub refuses the embedded preview, use the blade's **Open GitHub** action. + +- PR link, +- issue link, +- one-line summary, +- proof/tests, +- Codex review result, +- CI state, +- ClawSweeper readiness. + +Trial maintainers should copy this to Discord or the maintainer coordination channel. They should not merge, land, squash, or enable automerge. + +## Generated Codex Prompt + +Every issue prompt tells Codex to: + +1. Use the selected `model_reasoning_effort`. +2. Use the selected proof mode. +3. Use Tokenjuice for noisy terminal output when available, with `tokenjuice wrap --raw -- ` as the raw-output escape hatch. +4. Re-check the issue before coding. +5. Confirm it is still open, queueable, and not already covered by a linked/open or possible open fix PR. +6. Read the latest local `AGENTS.md`, `CONTRIBUTING.md`, and pull request template. +7. Apply the maintainer-handbook release, plugin, security, repo-ownership, and validation gates. +8. Follow ClawSweeper's assessment and suggested route. +9. Avoid duplicate claim comments when Claw Queue has already posted or found the issue claim. +10. Keep the fix focused. +11. Run relevant tests and collect proof, including Crabbox, Blacksmith Testbox, or Mantis when they add necessary evidence. +12. Run Codex review before opening or updating the PR. +13. Monitor CI and ClawSweeper after PR updates. +14. Stop only when the PR is ready for maintainer look, or park with a blocker note. + +## Usage And PR Limits + +Claw Queue pauses new work when: + +- authored open PR count reaches the configured personal limit, +- authored open PR count reaches the hard cap, +- weekly usage remaining drops by at least the configured daily percentage limit inside the active 24-hour window, +- GitHub PR lookup fails, because the page cannot safely know whether the PR limit was reached. + +When GitHub rate limits the page, existing local Active Work is still useful. If the local Codex bridge is connected and Gitcrawl is installed with a fresh OpenClaw store, Claw Queue can still load broad queue rows, possible open PR coverage, and authored open PR rows from the local snapshot. If live PR-limit lookup fails, new work should stay paused until the lookup can be refreshed; the local snapshot is useful context, not final authority for bypassing that safety gate. + +## Testing A Demo Safely + +Recommended demo path: + +1. Set **Repo** to a fork. +2. Set **Min issue age** to `0`. +3. Start the local bridge with `--dry-run`. +4. Connect the bridge in Claw Queue. +5. Click **Start** from Command Center. +6. Verify the dry-run row appears in Active Work. +7. Click **Resume**, **Handoff**, and **Copy work brief** to verify copy flows. +8. Archive dry-run rows before ending the demo. + +Dry-run rows are stored by the bridge under the log directory printed at startup. The default location is inside the OS temp directory under `crabfleet-openclaw-codex-runs`. + +## Local Bridge API + +The page uses the local bridge at the configured runner URL: + +```text +GET /health +GET /runs +GET /runs/:id +GET /runs/:id/log +PATCH /runs/:id +POST /claim +POST /start +POST /track +POST /gitcrawl/coverage +POST /gitcrawl/workflow +``` + +Requests require: + +```text +Authorization: Bearer +``` + +The Worker-backed Claw Queue page uses: + +```text +GET /api/openclaw/workflow +GET /api/openclaw/issue +PUT /api/openclaw/preferences +POST /api/openclaw/handoff +``` + +These endpoints are same-origin app endpoints and require the normal Crabfleet session. + +## Troubleshooting + +### GitHub rate limit reached + +Refresh after a short wait. Claw Queue will try lower-pressure REST fallbacks when GitHub Search is limited. If PR lookup still fails or the fallback is only partial, Claw Queue pauses new work because it cannot prove you are under the open PR limit. + +If the local Codex bridge is connected, refresh after Gitcrawl has synced. The queue should show a local Gitcrawl snapshot banner and broad queue/coverage data should come from the snapshot instead of the PR-coverage rate-limit warning. + +### Start is disabled + +Check the Command Center gates. Common causes: + +- local bridge is not connected, +- usage or PR limit is reached, +- issue is inside the configured age gate, +- issue is already in Active Work, +- GitHub lookup is partial or failed. + +### Bridge says missing or invalid token + +Copy the token printed by the runner command into the Token field and click **Test**. If the runner generated a token automatically, use that exact value. + +### No eligible issues appear + +Refresh the workflow. For fork testing, lower **Min issue age** to `0`. For real OpenClaw work, wait until ClawSweeper has marked issues with queueable labels. + +### Active Work shows old dry-run rows + +Use **Archive inactive** or archive individual rows. This only clears the local visible work list; it does not touch GitHub. + +## See Also + +- [One-page Claw Queue brief](/claw-queue/brief/) +- [Quickstart](/quickstart/) +- [Cards](/cards/) +- [Runs](/runs/) +- [API Reference](/api/) diff --git a/docs/index.md b/docs/index.md index d117a284..1b568bfc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,7 @@ The web app at [clawfleet.openclaw.ai/app](https://clawfleet.openclaw.ai/app/) e - **SSH-first onboarding.** Connect through `ssh link@crabd.sh`, complete GitHub sign-in, then use linked-key auth. - **Crabbox control.** Create, attach, share, open WebVNC, and clean up interactive Codex sessions backed by Ghostty WASM tiles. - **Fleet visibility.** The app groups all org Codex instances by person so OpenClaw can supervise live work. +- **Claw Queue.** Trial maintainers and contributors can pick ClawSweeper-screened issues or paste a specific issue, choose Codex thinking and proof mode, hand work to local Codex, track parallel plates, and copy maintainer handoffs. - **Repo-gated cards.** Prompt cards and GitHub issue/PR previews stay scoped to enabled OpenClaw repos. - **Runtime policy.** Crabfleet records runtime selection, capabilities, heartbeat, stall state, and operator intent. - **Admin guardrails.** User/team allowlists, repo allowlists, roles, caps, and `CRABBOX.md` workflow evaluation live in the dashboard. @@ -48,6 +49,7 @@ The web app at [clawfleet.openclaw.ai/app](https://clawfleet.openclaw.ai/app/) e - Durable run attempts with heartbeat, stall handling, operator, runtime reason, and capabilities. - Ghostty WASM fullscreen session grid with D1 event replay, live multiplex PTY attach, WebVNC links for Crabbox leases, and text fallback. - Card diff metadata and compact patch view. +- Claw Queue command center, specific issue intake, worker runway, active-work radar, local Codex bridge integration, local run-log viewer, PR-coverage warnings, and PR handoff generator. - Owner workflow evaluation for repo `CRABBOX.md`. - Worker-served docs at `/docs/` and generated docs at `docs.crabfleet.ai`. @@ -61,6 +63,7 @@ The web app at [clawfleet.openclaw.ai/app](https://clawfleet.openclaw.ai/app/) e ## Pick Your Path - **Trying it.** [Quickstart](/quickstart/) covers login, access, repo setup, cards, and attach. +- **Picking OpenClaw work.** [Claw Queue](/claw-queue/) covers queue triage, local Codex handoff, active work, limits, and maintainer handoffs. - **Understanding the system.** [Architecture](/architecture/) explains the Worker, D1/Kysely, runtime descriptors, and workflow config. - **Operating cards.** [Cards](/cards/) and [Runs](/runs/) cover task state, attempts, terminal attach, and replay. - **Managing access.** [Admin](/admin/) covers users, teams, repos, roles, caps, and policy defaults. diff --git a/migrations/0018_openclaw_workflow.sql b/migrations/0018_openclaw_workflow.sql new file mode 100644 index 00000000..eb9e4da8 --- /dev/null +++ b/migrations/0018_openclaw_workflow.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS openclaw_workflow_preferences ( + subject TEXT PRIMARY KEY, + role_mode TEXT NOT NULL DEFAULT 'trial_maintainer', + target_repo TEXT NOT NULL DEFAULT 'openclaw/openclaw', + github_login TEXT, + active_open_pr_limit INTEGER NOT NULL DEFAULT 10, + hard_open_pr_cap INTEGER NOT NULL DEFAULT 20, + daily_usage_drop_limit INTEGER NOT NULL DEFAULT 5, + max_parallel_workers INTEGER NOT NULL DEFAULT 2, + weekly_remaining_baseline INTEGER, + weekly_remaining_current INTEGER, + usage_window_started_at INTEGER, + updated_at INTEGER NOT NULL, + FOREIGN KEY (subject) REFERENCES users(subject) +); diff --git a/migrations/0019_openclaw_issue_age_preference.sql b/migrations/0019_openclaw_issue_age_preference.sql new file mode 100644 index 00000000..0e72cac5 --- /dev/null +++ b/migrations/0019_openclaw_issue_age_preference.sql @@ -0,0 +1,2 @@ +ALTER TABLE openclaw_workflow_preferences +ADD COLUMN minimum_issue_age_hours INTEGER NOT NULL DEFAULT 6; diff --git a/migrations/0020_openclaw_reasoning_effort_preference.sql b/migrations/0020_openclaw_reasoning_effort_preference.sql new file mode 100644 index 00000000..3955bf9c --- /dev/null +++ b/migrations/0020_openclaw_reasoning_effort_preference.sql @@ -0,0 +1,2 @@ +ALTER TABLE openclaw_workflow_preferences +ADD COLUMN codex_reasoning_effort TEXT NOT NULL DEFAULT 'high'; diff --git a/package.json b/package.json index 59d03eea..b1a2bfee 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "check": "node scripts/generate-assets.mjs && tsgo --noEmit && oxlint --ignore-pattern node_modules --ignore-pattern src/generated.ts . && oxfmt --check .", "check:changed": "pnpm run check", "test:changed": "pnpm run test", + "openclaw:runner": "node scripts/openclaw-codex-runner.mjs", "crabbox:hydrate": "crabbox actions hydrate", "crabbox:run": "crabbox run", "crabbox:stop": "crabbox stop", diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index ae1a2170..7acf8e8a 100755 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -22,7 +22,7 @@ const codePlaceholderPattern = new RegExp(`${codePlaceholder}(\\d+)${codePlaceho const sections = [ ["Start", ["index.md", "quickstart.md", "architecture.md"]], - ["Features", ["cards.md", "runs.md", "admin.md"]], + ["Features", ["claw-queue.md", "cards.md", "runs.md", "admin.md"]], ["Reference", ["api.md", "spec.md"]], ]; @@ -47,6 +47,10 @@ const pages = allPages.filter((page) => !buildExcludes.some((re) => re.test(page const pageMap = new Map(pages.map((page) => [page.rel, page])); const permalinkMap = new Map(); for (const page of pages) { + const implicitPermalink = implicitPagePermalink(page); + if (implicitPermalink) { + permalinkMap.set(normalizePermalink(implicitPermalink), page); + } if (page.frontmatter.permalink) { permalinkMap.set(normalizePermalink(page.frontmatter.permalink), page); } @@ -225,6 +229,17 @@ function outPath(rel, frontmatter = {}) { return rel.replace(/\.md$/, ".html"); } +function implicitPagePermalink(page) { + if (page.outRel === "index.html") return "/"; + if (page.outRel.endsWith("/index.html")) { + return `/${page.outRel.slice(0, -"/index.html".length)}/`; + } + if (page.outRel.endsWith(".html")) { + return `/${page.outRel.slice(0, -".html".length)}/`; + } + return null; +} + function firstHeading(markdown) { return markdown.match(/^#\s+(.+)$/m)?.[1]?.trim(); } diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index 208e6907..b7e933ac 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { css, js, preThemeScript, themeToggleHtml } from "./docs-site-assets.mjs"; @@ -27,10 +28,10 @@ const distDocs = new URL("../dist/docs/", import.meta.url); const appOrigin = "https://clawfleet.openclaw.ai"; await run(process.execPath, [ - new URL("../node_modules/vite/bin/vite.js", import.meta.url).pathname, + fileURLToPath(new URL("../node_modules/vite/bin/vite.js", import.meta.url)), "build", "--config", - new URL("../vite.config.mjs", import.meta.url).pathname, + fileURLToPath(new URL("../vite.config.mjs", import.meta.url)), ]); const [ @@ -134,14 +135,29 @@ function viteAssetUrl(path) { function buildLucideIconScript(iconNodes) { const names = [ + "archive", "book-open", + "brain", "copy", + "clipboard-list", + "external-link", + "flask-conical", + "git-branch", "git-pull-request", "layout-grid", + "list-checks", + "message-square", + "message-square-text", "moon", + "panel-right-open", + "refresh-cw", "settings", + "send", + "search", + "shield-check", "square-terminal", "sun", + "target", "terminal", "x", ]; diff --git a/scripts/openclaw-codex-runner.mjs b/scripts/openclaw-codex-runner.mjs new file mode 100644 index 00000000..c20d006a --- /dev/null +++ b/scripts/openclaw-codex-runner.mjs @@ -0,0 +1,2411 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const args = parseArgs(process.argv.slice(2)); +const host = String(args.host || process.env.OPENCLAW_CODEX_RUNNER_HOST || "127.0.0.1"); +const port = integer(args.port || process.env.OPENCLAW_CODEX_RUNNER_PORT, 4545); +const workspace = path.resolve(String(args.workspace || process.cwd())); +const codexBin = String(args["codex-bin"] || process.env.CODEX_BIN || "codex"); +const codexLaunch = resolveCodexLaunch(codexBin); +const tokenjuiceBin = String(args["tokenjuice-bin"] || process.env.TOKENJUICE_BIN || "tokenjuice"); +const tokenjuiceLaunch = resolveCommandLaunch(tokenjuiceBin); +const providedToken = String(args.token || process.env.OPENCLAW_CODEX_RUNNER_TOKEN || ""); +const token = providedToken || crypto.randomBytes(24).toString("base64url"); +const dryRun = Boolean(args["dry-run"]); +const skillsDir = path.resolve( + String( + args["skills-dir"] || + process.env.OPENCLAW_CODEX_SKILLS_DIR || + process.env.CODEX_SKILLS_DIR || + path.join(os.homedir(), ".codex", "skills"), + ), +); +const skillRoots = relevantSkillRoots(skillsDir); +const includeSkillContext = !args["no-skill-context"]; +const defaultReasoningEffort = reasoningEffort( + args["reasoning-effort"] || process.env.OPENCLAW_CODEX_REASONING_EFFORT, + "high", +); +const maxActive = Math.max( + 1, + Math.min(16, integer(args["max-active"] || process.env.OPENCLAW_CODEX_RUNNER_MAX_ACTIVE, 1)), +); +const defaultLogDir = path.join( + os.tmpdir(), + "crabfleet-openclaw-codex-runs", + sha256(workspace).slice(0, 16), +); +const logDir = path.resolve(String(args["log-dir"] || defaultLogDir)); +const worktreeDir = path.resolve( + String( + args["worktree-dir"] || + process.env.OPENCLAW_CODEX_WORKTREE_DIR || + path.join(logDir, "worktrees"), + ), +); +const worktreeBase = clean( + args["worktree-base"] || process.env.OPENCLAW_CODEX_WORKTREE_BASE || "HEAD", + 160, +); +const statePath = path.join(logDir, "runs.json"); +const runs = new Map(); +const trackReservations = new Map(); +const claimReservations = new Map(); +let persistTimer = null; +let gitcrawlDoctorCache = null; + +if (!fs.existsSync(workspace) || !fs.statSync(workspace).isDirectory()) { + console.error(`Workspace does not exist or is not a directory: ${workspace}`); + process.exit(1); +} + +await loadPersistedRuns(); + +const server = http.createServer((request, response) => { + handleRequest(request, response).catch((error) => { + sendJson( + request, + response, + { ok: false, error: error.message || String(error), claim: error.claim || null }, + integer(error.status, 500), + ); + }); +}); + +server.listen(port, host, () => { + console.log(`Claw Queue Codex bridge listening on http://${host}:${port}`); + console.log(`Workspace: ${workspace}`); + console.log(`Logs: ${logDir}`); + console.log(`Tokenjuice: ${tokenjuiceLaunch.display}`); + if (includeSkillContext) console.log(`Skills: ${skillRoots.join(", ")}`); + console.log(`Token: ${token}`); + if (!providedToken) { + console.log("A token was generated for this run. Paste it into Claw Queue before testing."); + } + if (!isLoopbackHost(host)) { + console.warn("Warning: this bridge is not bound to a loopback-only host."); + } +}); + +async function handleRequest(request, response) { + if (request.method === "OPTIONS") { + response.writeHead(204, corsHeaders(request)); + response.end(); + return; + } + + const url = new URL(request.url || "/", `http://${header(request, "host") || `${host}:${port}`}`); + if (url.pathname === "/health" && request.method === "GET") { + if (!authorized(request)) return unauthorized(request, response); + sendJson(request, response, { + ok: true, + runner: "claw-queue-codex-bridge", + workspace, + codexBin, + codexCommand: codexLaunch.display, + tokenjuiceBin, + tokenjuiceCommand: tokenjuiceLaunch.display, + dryRun, + defaultReasoningEffort, + active: activeRuns().length, + maxActive, + baseWorkspace: workspace, + worktreeDir, + worktreeBase, + logDir, + }); + return; + } + + if (url.pathname === "/runs" && request.method === "GET") { + if (!authorized(request)) return unauthorized(request, response); + sendJson(request, response, { ok: true, runs: visibleRuns() }); + return; + } + + if (url.pathname === "/gitcrawl/coverage" && request.method === "POST") { + if (!authorized(request)) return unauthorized(request, response); + const body = await readJson(request); + const result = await readGitcrawlCoverage(body).catch((error) => ({ + ok: false, + source: "gitcrawl", + error: error.message || String(error), + })); + sendJson(request, response, result); + return; + } + + if (url.pathname === "/gitcrawl/workflow" && request.method === "POST") { + if (!authorized(request)) return unauthorized(request, response); + const body = await readJson(request); + const result = await readGitcrawlWorkflow(body).catch((error) => ({ + ok: false, + source: "gitcrawl", + error: error.message || String(error), + })); + sendJson(request, response, result); + return; + } + + if (url.pathname === "/claim" && request.method === "POST") { + if (!authorized(request)) return unauthorized(request, response); + const body = await readJson(request); + const result = await claimGitHubIssueSerialized(body); + sendJson(request, response, result); + return; + } + + const logMatch = url.pathname.match(/^\/runs\/([^/]+)\/log$/); + if (logMatch && request.method === "GET") { + if (!authorized(request)) return unauthorized(request, response); + const id = decodeURIComponent(logMatch[1] || ""); + const run = runs.get(id); + if (!run) { + sendJson(request, response, { ok: false, error: "run not found" }, 404); + return; + } + const log = await readRunLog(run, url); + sendJson(request, response, { ok: true, run, log }); + return; + } + + const runMatch = url.pathname.match(/^\/runs\/([^/]+)$/); + if (runMatch && request.method === "GET") { + if (!authorized(request)) return unauthorized(request, response); + const run = runs.get(decodeURIComponent(runMatch[1] || "")); + sendJson( + request, + response, + run ? { ok: true, run } : { ok: false, error: "run not found" }, + run ? 200 : 404, + ); + return; + } + + if (runMatch && request.method === "PATCH") { + if (!authorized(request)) return unauthorized(request, response); + const id = decodeURIComponent(runMatch[1] || ""); + const run = runs.get(id); + if (!run) { + sendJson(request, response, { ok: false, error: "run not found" }, 404); + return; + } + const body = await readJson(request); + const updateError = updateRunFromBody(run, body); + if (updateError) { + sendJson(request, response, { ok: false, error: updateError.error }, updateError.status); + return; + } + updateRunClaimFromBody(run, body); + persistRunsSoon(); + sendJson(request, response, { ok: true, run }); + return; + } + + if (url.pathname === "/start" && request.method === "POST") { + if (!authorized(request)) return unauthorized(request, response); + const body = await readJson(request); + const prompt = clean(body.prompt, 100_000); + if (!prompt) { + sendJson(request, response, { ok: false, error: "prompt is required" }, 400); + return; + } + const existing = visibleRunForIssue(integer(body.issueNumber, null), clean(body.issueUrl, 500)); + if (existing) { + updateRunClaimFromBody(existing, body); + sendJson(request, response, { ok: true, duplicate: true, run: existing }, 200); + return; + } + const active = activeRuns(); + if (active.length >= maxActive) { + sendJson( + request, + response, + { + ok: false, + error: `runner already has ${active.length} active Codex run(s) in this workspace`, + run: active[0], + runs: active, + }, + 409, + ); + return; + } + const run = await startCodexRun(body, prompt, { beforePrompt: claimStartedRunIfRequested }); + sendJson(request, response, { ok: true, run }, 202); + return; + } + + if (url.pathname === "/track" && request.method === "POST") { + if (!authorized(request)) return unauthorized(request, response); + const body = await readJson(request); + const run = await trackManualRun(body); + sendJson(request, response, { ok: true, run }, 201); + return; + } + + sendJson(request, response, { ok: false, error: "not found" }, 404); +} + +async function startCodexRun(body, prompt, options = {}) { + const id = `oc-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`; + const logPath = path.join(logDir, `${id}.jsonl`); + const codexReasoningEffort = reasoningEffort( + body.codexReasoningEffort || body.reasoningEffort, + defaultReasoningEffort, + ); + const proofModeValue = proofMode(body.proofMode || body.openClawProofMode, "auto"); + const run = { + id, + status: dryRun ? "dry-run" : "starting", + pid: null, + issueNumber: integer(body.issueNumber, null), + issueUrl: clean(body.issueUrl, 500), + title: clean(body.title, 300), + queueId: clean(body.queueId, 80), + codexReasoningEffort, + proofMode: proofModeValue, + claimCommentUrl: clean(body.claimCommentUrl, 500) || null, + claimCommentStatus: clean(body.claimCommentStatus, 80) || null, + workspace: dryRun ? workspace : null, + baseWorkspace: workspace, + worktreePath: null, + worktreeBranch: null, + worktreeBase, + logPath, + startedAt: new Date().toISOString(), + finishedAt: null, + exitCode: null, + signal: null, + error: null, + note: null, + lastActivityLines: [], + source: "codex", + updatedAt: new Date().toISOString(), + }; + runs.set(id, run); + persistRunsSoon(); + + let log; + try { + await fsp.mkdir(logDir, { recursive: true }); + log = await openLogStream(logPath); + log.write( + `${JSON.stringify({ + type: "request", + at: run.startedAt, + issueNumber: run.issueNumber, + issueUrl: run.issueUrl, + title: run.title, + queueId: run.queueId, + codexReasoningEffort: run.codexReasoningEffort, + proofMode: run.proofMode, + claimCommentUrl: run.claimCommentUrl, + claimCommentStatus: run.claimCommentStatus, + baseWorkspace: run.baseWorkspace, + worktreeBase: run.worktreeBase, + dryRun, + })}\n`, + ); + } catch (error) { + markRunFailed(run, error); + throw error; + } + + if (dryRun) { + log.write(`${JSON.stringify({ type: "prompt", text: prompt })}\n`); + log.end(); + run.finishedAt = new Date().toISOString(); + run.exitCode = 0; + run.updatedAt = run.finishedAt; + persistRunsSoon(); + return run; + } + + let runWorkspace; + try { + runWorkspace = await createRunWorktree(run, log); + } catch (error) { + markRunFailed(run, error); + log.write(`${JSON.stringify({ type: "error", error: run.error, at: run.finishedAt })}\n`); + log.end(); + throw error; + } + + let childEnv; + try { + childEnv = await prepareCodexRunEnvironment(runWorkspace, log); + } catch (error) { + await cleanupRunWorktree(run, log).catch((cleanupError) => { + log.write( + `${JSON.stringify({ + type: "cleanup-error", + error: cleanupError.message || String(cleanupError), + at: new Date().toISOString(), + })}\n`, + ); + }); + markRunFailed(run, error); + log.write(`${JSON.stringify({ type: "error", error: run.error, at: run.finishedAt })}\n`); + log.end(); + throw error; + } + const observer = createRunOutputObserver(); + const codexArgs = [ + "exec", + "--json", + "--sandbox", + "workspace-write", + "-c", + `model_reasoning_effort='${run.codexReasoningEffort}'`, + "-", + ]; + const child = spawn(codexLaunch.command, codexLaunchArgs(codexLaunch, codexArgs), { + cwd: runWorkspace, + env: childEnv, + windowsHide: true, + windowsVerbatimArguments: Boolean(codexLaunch.cmdTarget), + }); + + run.pid = child.pid ?? null; + run.status = "running"; + let childError = null; + let logEnded = false; + const writeLog = (value) => { + appendRunActivity(run, value); + if (!logEnded) log.write(value); + }; + const endLog = () => { + if (logEnded) return; + logEnded = true; + log.end(); + }; + + child.stdin.on("error", (error) => { + writeLog(`${JSON.stringify({ type: "stdin-error", error: error.message || String(error) })}\n`); + }); + child.stdout.on("data", (chunk) => { + observer.push(String(chunk)); + writeLog(chunk); + }); + child.stderr.on("data", (chunk) => { + writeLog(`${JSON.stringify({ type: "stderr", text: String(chunk) })}\n`); + }); + child.once("error", (error) => { + childError = error; + }); + child.once("close", (code, signal) => { + const outcome = childError || code !== 0 ? null : observer.outcome(); + run.status = childError || code !== 0 ? "failed" : outcome?.status || "completed"; + run.exitCode = code; + run.signal = signal; + if (childError) run.error = childError.message || String(childError); + if (outcome?.note && !run.note) run.note = outcome.note; + run.finishedAt = new Date().toISOString(); + run.updatedAt = run.finishedAt; + writeLog( + `${JSON.stringify({ + type: childError ? "error" : "exit", + code, + signal, + error: run.error || null, + at: run.finishedAt, + })}\n`, + ); + endLog(); + persistRunsSoon(); + }); + const assertChildPromptable = () => { + if ( + run.status === "failed" || + child.exitCode !== null || + child.signalCode !== null || + childError + ) { + const message = + run.error || + childError?.message || + `Codex process exited before prompt (code ${child.exitCode ?? run.exitCode ?? "unknown"})`; + run.status = "failed"; + run.error = message; + run.finishedAt = run.finishedAt || new Date().toISOString(); + run.updatedAt = run.finishedAt; + persistRunsSoon(); + throw new Error(message); + } + }; + await new Promise((resolve) => setTimeout(resolve, 300)); + try { + assertChildPromptable(); + } catch (error) { + await cleanupRunWorktree(run, { write() {} }).catch(() => {}); + throw error; + } + try { + await options.beforePrompt?.(run, body, log, assertChildPromptable); + assertChildPromptable(); + } catch (error) { + await stopChild(child); + await cleanupRunWorktree(run, log).catch((cleanupError) => { + writeLog( + `${JSON.stringify({ + type: "cleanup-error", + error: cleanupError.message || String(cleanupError), + at: new Date().toISOString(), + })}\n`, + ); + }); + markRunFailed(run, error); + run.note = clean(error.claim?.error || error.message || String(error), 500); + writeLog( + `${JSON.stringify({ + type: "error", + error: run.error, + claim: error.claim || null, + at: run.finishedAt, + })}\n`, + ); + endLog(); + throw error; + } + assertChildPromptable(); + child.stdin.end(openClawRunnerPrompt(prompt, run)); + return run; +} + +async function claimStartedRunIfRequested(run, body, log, assertChildPromptable = () => {}) { + if (dryRun || !flag(body.claimIssue) || clean(body.claimCommentStatus, 80)) { + return; + } + assertChildPromptable(); + const result = await claimGitHubIssueSerialized(body).catch((error) => ({ + ok: false, + error: error.message || String(error), + claim: error.claim || null, + })); + if (result.ok === false) { + const status = blockedClaimStatus(result.claim?.status) ? 409 : 500; + const error = new Error(result.error || "Claim comment failed"); + error.status = status; + error.claim = result.claim || null; + throw error; + } + run.claimCommentStatus = result.claim?.status || null; + run.claimCommentUrl = result.claim?.url || null; + run.updatedAt = new Date().toISOString(); + log.write( + `${JSON.stringify({ + type: "claim", + at: run.updatedAt, + status: run.claimCommentStatus, + url: run.claimCommentUrl, + claim: result.claim || null, + })}\n`, + ); + persistRunsSoon(); +} + +async function trackManualRun(body) { + const issueNumber = integer(body.issueNumber, null); + const issueUrl = clean(body.issueUrl, 500); + const existing = visibleRunForIssue(issueNumber, issueUrl); + if (existing) return existing; + const key = issueKey(issueNumber, issueUrl); + const pending = key ? trackReservations.get(key) : null; + if (pending) return pending; + + const pendingRun = createManualTrackedRun(body, issueNumber, issueUrl); + if (key) { + trackReservations.set(key, pendingRun); + pendingRun + .finally(() => { + trackReservations.delete(key); + }) + .catch(() => {}); + } + return pendingRun; +} + +async function claimGitHubIssue(body) { + const parsedIssue = parseIssueRef(body.issueUrl); + const repo = normalizeRepo(body.repo || parsedIssue.repo || "openclaw/openclaw"); + const issueNumber = integer(body.issueNumber, null) || parsedIssue.issueNumber; + if (!repo) return { ok: false, error: "repo must be owner/name" }; + if (!issueNumber || issueNumber <= 0) return { ok: false, error: "issueNumber is required" }; + + const issue = await ghJson(["api", `repos/${repo}/issues/${issueNumber}`]); + if (issue?.pull_request) { + return { + ok: false, + error: "Refusing to claim a pull request URL as issue work.", + claim: { + status: "pull-request", + url: clean(issue.html_url, 500) || null, + }, + }; + } + if (clean(issue?.state, 40).toLowerCase() !== "open") { + const state = clean(issue?.state, 40) || "not open"; + return { + ok: false, + error: `Refusing to claim issue because it is ${state}.`, + claim: { + status: "not-open", + url: clean(issue?.html_url, 500) || null, + state, + }, + }; + } + + const currentUserLogin = await ghCurrentUserLogin(); + const marker = claimCommentMarker(repo, issueNumber); + const existingPages = await ghJson([ + "api", + "--paginate", + "--slurp", + `repos/${repo}/issues/${issueNumber}/comments?per_page=100`, + ]); + const existing = flattenGhPages(existingPages); + const existingComment = Array.isArray(existing) + ? existing.find((comment) => String(comment?.body || "").includes(marker)) + : null; + if (existingComment) { + const authorLogin = clean(existingComment?.user?.login, 80).toLowerCase(); + if (authorLogin !== currentUserLogin) { + const author = authorLogin ? `@${authorLogin}` : "an unknown user"; + return { + ok: false, + error: `Issue already has a Claw Queue claim by ${author}`, + claim: { + status: "claimed-by-other", + url: clean(existingComment.html_url, 500) || null, + author: authorLogin || null, + marker, + }, + }; + } + return { + ok: true, + claim: { + status: "already-commented", + url: clean(existingComment.html_url, 500) || null, + marker, + }, + }; + } + + const comment = claimCommentBody(body, repo, issueNumber, marker); + const created = await ghJson( + ["api", "-X", "POST", `repos/${repo}/issues/${issueNumber}/comments`, "--input", "-"], + { input: JSON.stringify({ body: comment }) }, + ); + return { + ok: true, + claim: { + status: "posted", + url: clean(created?.html_url, 500) || null, + marker, + }, + }; +} + +async function claimGitHubIssueSerialized(body) { + const key = claimIssueKey(body); + if (!key) return claimGitHubIssue(body); + const previous = claimReservations.get(key) || Promise.resolve(); + const next = previous.catch(() => {}).then(() => claimGitHubIssue(body)); + const reservation = next + .catch(() => {}) + .finally(() => { + if (claimReservations.get(key) === reservation) claimReservations.delete(key); + }); + claimReservations.set(key, reservation); + return next; +} + +function blockedClaimStatus(status) { + return ["claimed-by-other", "not-open", "pull-request"].includes(clean(status, 80)); +} + +function claimIssueKey(body) { + const parsedIssue = parseIssueRef(body.issueUrl); + const repo = normalizeRepo(body.repo || parsedIssue.repo || "openclaw/openclaw"); + const issueNumber = integer(body.issueNumber, null) || parsedIssue.issueNumber; + return repo && issueNumber && issueNumber > 0 ? `${repo}#${issueNumber}` : ""; +} + +async function ghCurrentUserLogin() { + const user = await ghJson(["api", "user"]); + const login = clean(user?.login, 80).toLowerCase(); + if (!login) throw new Error("gh api user did not return a login"); + return login; +} + +function flattenGhPages(value) { + if (!Array.isArray(value)) return []; + if (value.every((page) => Array.isArray(page))) return value.flat(); + return value; +} + +async function readGitcrawlCoverage(body) { + const repo = normalizeRepo(body.repo || "openclaw/openclaw"); + if (!repo) return { ok: false, error: "repo must be owner/name" }; + const issueNumbers = uniqueIssueNumbers(body.issueNumbers || body.numbers).slice(0, 80); + if (!issueNumbers.length) { + return { ok: true, source: "gitcrawl", repo, coverage: {}, warning: null }; + } + + let sqlite; + try { + sqlite = await import("node:sqlite"); + } catch (error) { + return { + ok: false, + error: `node:sqlite is unavailable in this Node runtime: ${error.message || String(error)}`, + }; + } + + const doctor = await gitcrawlDoctor(); + const dbPath = doctor?.runtime_db_health?.path || doctor?.db_path; + if (!dbPath) return { ok: false, error: "gitcrawl doctor did not report a database path" }; + + const database = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + const coverage = await readGitcrawlCoverageFromDatabase(database, repo, issueNumbers, doctor); + return { + ok: true, + source: "gitcrawl", + repo, + coverage: coverage.coverage, + lastSyncAt: doctor?.last_sync_at || null, + dbPath, + warning: + "Local Gitcrawl fallback checks open PR titles and indexed excerpts; use live GitHub before final claims.", + }; + } finally { + database.close(); + } +} + +async function readGitcrawlWorkflow(body) { + const repo = normalizeRepo(body.repo || "openclaw/openclaw"); + if (!repo) return { ok: false, error: "repo must be owner/name" }; + const roleMode = oneOf( + body.roleMode, + ["contributor", "trial_maintainer", "maintainer"], + "trial_maintainer", + ); + const minimumIssueAgeHours = Math.max(0, Math.min(168, integer(body.minimumIssueAgeHours, 6))); + const githubLogin = clean(body.githubLogin || body.login, 80).toLowerCase(); + + let sqlite; + try { + sqlite = await import("node:sqlite"); + } catch (error) { + return { + ok: false, + error: `node:sqlite is unavailable in this Node runtime: ${error.message || String(error)}`, + }; + } + + const doctor = await gitcrawlDoctor(); + const dbPath = doctor?.runtime_db_health?.path || doctor?.db_path; + if (!dbPath) return { ok: false, error: "gitcrawl doctor did not report a database path" }; + + const database = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + try { + const now = Date.now(); + const queueDefinitions = localOpenClawQueuesForRole(roleMode); + const queues = queueDefinitions.map((definition) => + readGitcrawlQueue(database, repo, definition, now, minimumIssueAgeHours), + ); + const issueNumbers = uniqueIssueNumbers( + queues.flatMap((queue) => queue.candidates.map((candidate) => candidate.number)), + ).slice(0, 80); + const coverage = issueNumbers.length + ? await readGitcrawlCoverageFromDatabase(database, repo, issueNumbers, doctor) + : { coverage: {}, coveredIssueCount: 0 }; + const coverageMap = coverage.coverage || {}; + const queuesWithCoverage = queues.map((queue) => ({ + ...queue, + candidates: queue.candidates.map((candidate) => ({ + ...candidate, + possiblePrCoverage: coverageMap[String(candidate.number)] || [], + prCoverageUnknown: false, + prCoverageWarning: null, + })), + })); + return { + ok: true, + source: "gitcrawl", + repo, + generatedAt: Date.now(), + queues: queuesWithCoverage, + pullRequests: { + items: githubLogin ? readGitcrawlAuthoredPullRequests(database, repo, githubLogin) : [], + error: githubLogin ? null : "GitHub login is not configured", + source: "gitcrawl", + loginConfigured: Boolean(githubLogin), + }, + localCoverage: { + source: "gitcrawl", + lastSyncAt: doctor?.last_sync_at || null, + warning: + "Local Gitcrawl snapshot is used for broad queue and PR coverage reads; live GitHub is still required before claims, comments, PR updates, and final handoff.", + lookupIssueCount: issueNumbers.length, + coveredIssueCount: coverage.coveredIssueCount || 0, + }, + }; + } finally { + database.close(); + } +} + +async function readGitcrawlCoverageFromDatabase(database, repo, issueNumbers, doctor) { + const rows = queryGitcrawlOpenPullRequests(database, repo, issueNumbers); + const coverage = {}; + for (const issueNumber of issueNumbers) coverage[String(issueNumber)] = []; + const coveredIssues = new Set(); + for (const row of rows) { + for (const issueNumber of issueNumbers) { + const reason = gitcrawlPrIssueReferenceReason(repo, row, issueNumber); + if (!reason) continue; + const list = coverage[String(issueNumber)] || []; + if (list.some((item) => item.number === row.number)) continue; + list.push({ + number: row.number, + title: row.title, + url: row.html_url, + author: row.author_login || null, + draft: Boolean(row.is_draft), + updatedAt: row.updated_at_gh || row.updated_at || null, + reason, + }); + coverage[String(issueNumber)] = list; + coveredIssues.add(issueNumber); + } + } + return { + ok: true, + source: "gitcrawl", + repo, + coverage, + lastSyncAt: doctor?.last_sync_at || null, + coveredIssueCount: coveredIssues.size, + }; +} + +function parseIssueRef(value) { + try { + const url = new URL(String(value || "")); + const parts = url.pathname.split("/").filter(Boolean); + const issueNumber = Number(parts[3]); + if ( + url.hostname.toLowerCase() === "github.com" && + parts.length >= 4 && + parts[2] === "issues" && + Number.isInteger(issueNumber) && + issueNumber > 0 + ) { + return { + repo: normalizeRepo(`${parts[0]}/${parts[1]}`), + issueNumber, + }; + } + } catch {} + return { repo: "", issueNumber: null }; +} + +function claimCommentMarker(repo, issueNumber) { + return ``; +} + +function claimCommentBody(body, repo, issueNumber, marker) { + const custom = clean(body.claimComment || body.comment, 2000); + if (custom) return custom.includes(marker) ? custom : `${custom}\n\n${marker}`; + const title = clean(body.title, 180); + return [ + "I'm working on a focused fix for this now.", + title ? `Scope: ${title}` : `Issue: ${repo}#${issueNumber}`, + "I'll keep it scoped, include relevant tests/proof, run Codex review, and only hand off when the PR is ready for maintainer look.", + "", + marker, + ].join("\n"); +} + +async function ghJson(args, options = {}) { + const result = await runCommand("gh", args, { + allowFailure: true, + input: options.input, + }); + if (result.status !== 0) { + throw new Error(`gh ${args.join(" ")} failed: ${clean(result.stderr || result.stdout, 1200)}`); + } + try { + return JSON.parse(result.stdout || "{}"); + } catch (error) { + throw new Error( + `gh ${args.join(" ")} returned invalid JSON: ${error.message || String(error)}`, + ); + } +} + +async function gitcrawlDoctor() { + const envDbPath = clean(process.env.OPENCLAW_GITCRAWL_DB_PATH, 1000); + if (envDbPath) { + return { + db_path: envDbPath, + runtime_db_health: { path: envDbPath }, + last_sync_at: process.env.OPENCLAW_GITCRAWL_LAST_SYNC_AT || null, + }; + } + if (gitcrawlDoctorCache && gitcrawlDoctorCache.expiresAt > Date.now()) { + return gitcrawlDoctorCache.value; + } + const result = await runCommand("gitcrawl", ["doctor", "--json"], { allowFailure: true }); + if (result.status !== 0) { + throw new Error(`gitcrawl doctor failed: ${clean(result.stderr || result.stdout, 1000)}`); + } + try { + const value = JSON.parse(result.stdout); + gitcrawlDoctorCache = { value, expiresAt: Date.now() + 60_000 }; + return value; + } catch (error) { + throw new Error(`gitcrawl doctor returned invalid JSON: ${error.message || String(error)}`); + } +} + +function queryGitcrawlOpenPullRequests(database, repo, issueNumbers) { + const clauses = []; + const params = {}; + issueNumbers.forEach((number, index) => { + params[`n${index}`] = `%${number}%`; + clauses.push(`t.title LIKE $n${index}`); + clauses.push(`t.body_excerpt LIKE $n${index}`); + }); + const where = clauses.length ? `AND (${clauses.join(" OR ")})` : ""; + const statement = database.prepare(` + SELECT + t.number, + t.title, + t.html_url, + t.author_login, + t.is_draft, + t.updated_at_gh, + t.updated_at, + t.body_excerpt + FROM threads t + JOIN repositories r ON r.id = t.repo_id + WHERE r.full_name = $repo + AND t.kind = 'pull_request' + AND t.state = 'open' + ${where} + ORDER BY COALESCE(t.updated_at_gh, t.updated_at) DESC + LIMIT 500 + `); + return statement.all({ ...params, repo }); +} + +function gitcrawlPrIssueReferenceReason(repo, pull, issueNumber) { + const number = String(issueNumber); + const text = `${pull.title || ""}\n${pull.body_excerpt || ""}`; + const repoPattern = escapeRegExp(repo); + if ( + new RegExp( + `\\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\\s+(?:${repoPattern})?#${number}\\b`, + "i", + ).test(text) + ) { + return `Gitcrawl PR text uses a closing reference to #${number}`; + } + if (new RegExp(`github\\.com/${repoPattern}/issues/${number}\\b`, "i").test(text)) { + return `Gitcrawl PR text links the issue URL for #${number}`; + } + if (new RegExp(`(?:^|[^A-Za-z0-9_])#${number}\\b`, "i").test(text)) { + return `Gitcrawl PR text mentions #${number}`; + } + return null; +} + +const localSharedBlockedLabels = [ + "clawsweeper:no-new-fix-pr", + "clawsweeper:linked-pr-open", + "clawsweeper:needs-maintainer-review", + "clawsweeper:needs-product-decision", + "clawsweeper:needs-security-review", + "clawsweeper:needs-info", +]; + +const localOpenClawQueueDefinitions = [ + localPriorityQueue("maintainer-p0", "P0", "Emergency queueable fixes"), + localPriorityQueue("maintainer-p1", "P1", "High-priority queueable fixes"), + localPriorityQueue("maintainer-p2", "P2", "Normal queueable fixes"), + { + id: "contributor-ready", + audience: ["contributor", "trial_maintainer", "maintainer"], + title: "Ready-to-queue fixes", + priority: null, + labels: ["clawsweeper:queueable-fix"], + excludeLabels: localSharedBlockedLabels, + sort: "created", + order: "asc", + why: "Issues ClawSweeper marked as suitable queued-fix candidates without linked fix PRs.", + }, + { + id: "contributor-clear-shape", + audience: ["contributor", "trial_maintainer", "maintainer"], + title: "Clear-shape fixes", + priority: null, + labels: ["clawsweeper:queueable-fix", "clawsweeper:fix-shape-clear"], + excludeLabels: localSharedBlockedLabels, + sort: "created", + order: "asc", + why: "Issues where ClawSweeper found a likely implementation path.", + }, + { + id: "contributor-source-repro", + audience: ["contributor", "trial_maintainer", "maintainer"], + title: "Ready fixes with source repro", + priority: null, + labels: ["clawsweeper:queueable-fix", "clawsweeper:source-repro"], + excludeLabels: localSharedBlockedLabels, + sort: "created", + order: "asc", + why: "Queueable fixes backed by source-level reproduction evidence.", + }, +]; + +function localPriorityQueue(id, priority, title) { + return { + id, + audience: ["trial_maintainer", "maintainer"], + title, + priority, + labels: [priority, "clawsweeper:queueable-fix"], + excludeLabels: localSharedBlockedLabels, + sort: "created", + order: "asc", + why: `${priority} issue triage, queueable, and without a linked fix PR.`, + }; +} + +function localOpenClawQueuesForRole(roleMode) { + return localOpenClawQueueDefinitions.filter((queue) => queue.audience.includes(roleMode)); +} + +function readGitcrawlQueue(database, repo, definition, now, minimumIssueAgeHours) { + const rows = queryGitcrawlOpenIssues(database, repo, definition); + const candidates = rows + .map((row) => localOpenClawCandidateFromThread(row, definition.id, now, minimumIssueAgeHours)) + .filter((candidate) => localOpenClawCandidateMatchesQueue(candidate, definition)) + .sort((left, right) => localOpenClawCandidateSort(left, right, definition)) + .slice(0, 6); + return { + definition, + query: localOpenClawIssueSearchQuery(repo, definition, now), + totalCount: candidates.length, + candidates, + error: null, + source: "gitcrawl", + }; +} + +function queryGitcrawlOpenIssues(database, repo, definition) { + const clauses = []; + const params = { + repo, + createdAfter: new Date(Date.now() - 30 * 24 * 3_600_000).toISOString(), + }; + for (const [index, label] of definition.labels.entries()) { + params[`label${index}`] = `%${label}%`; + clauses.push(`t.labels_json LIKE $label${index}`); + } + for (const [index, label] of definition.excludeLabels.entries()) { + params[`exclude${index}`] = `%${label}%`; + clauses.push(`t.labels_json NOT LIKE $exclude${index}`); + } + const statement = database.prepare(` + SELECT + t.number, + t.title, + t.html_url, + t.author_login, + t.labels_json, + t.created_at_gh, + t.updated_at_gh, + t.updated_at + FROM threads t + JOIN repositories r ON r.id = t.repo_id + WHERE r.full_name = $repo + AND t.kind = 'issue' + AND t.state = 'open' + AND COALESCE(t.created_at_gh, t.updated_at) >= $createdAfter + ${clauses.length ? `AND ${clauses.join("\n AND ")}` : ""} + ORDER BY COALESCE(t.created_at_gh, t.updated_at) ASC + LIMIT 120 + `); + return statement.all(params); +} + +function localOpenClawCandidateFromThread(row, queueId, now, minimumIssueAgeHours) { + const labels = labelsFromJson(row.labels_json); + return { + number: Number(row.number), + title: clean(row.title, 240), + url: clean(row.html_url, 1000) || `https://github.com/openclaw/openclaw/issues/${row.number}`, + author: row.author_login || null, + createdAt: row.created_at_gh || null, + updatedAt: row.updated_at_gh || row.updated_at || null, + labels, + queueId, + signals: localOpenClawIssueSignals(labels, row.created_at_gh, now, minimumIssueAgeHours), + possiblePrCoverage: [], + prCoverageUnknown: false, + prCoverageWarning: null, + source: "gitcrawl", + }; +} + +function localOpenClawIssueSignals(labels, createdAt, now, minimumIssueAgeHours) { + const has = (label) => labels.some((item) => item.toLowerCase() === label.toLowerCase()); + const ageMs = createdAt ? now - Date.parse(createdAt) : NaN; + const ageGate = !Number.isFinite(ageMs) + ? "unknown" + : ageMs < minimumIssueAgeHours * 3_600_000 + ? "too-new" + : ageMs > 30 * 24 * 3_600_000 + ? "too-old" + : "eligible"; + const linkedPr = has("clawsweeper:linked-pr-open"); + const noNewFixPr = has("clawsweeper:no-new-fix-pr"); + const blockers = localSharedBlockedLabels.filter(has); + const queueable = has("clawsweeper:queueable-fix"); + const clawsweeperPass = labels.some((label) => label.toLowerCase().startsWith("clawsweeper:")); + return { + clawsweeperPass, + queueable, + linkedPr, + noNewFixPr, + sourceRepro: has("clawsweeper:source-repro"), + currentMainRepro: has("clawsweeper:current-main-repro"), + fixShapeClear: has("clawsweeper:fix-shape-clear"), + needsLiveValidation: + has("clawsweeper:needs-live-repro") || has("clawsweeper:needs-live-validation"), + blockers, + ageGate, + readyForPickup: + clawsweeperPass && queueable && !linkedPr && !noNewFixPr && ageGate === "eligible", + }; +} + +function localOpenClawCandidateMatchesQueue(candidate, definition) { + const lowerLabels = new Set(candidate.labels.map((label) => label.toLowerCase())); + const has = (label) => lowerLabels.has(label.toLowerCase()); + return ( + definition.labels.every(has) && + !definition.excludeLabels.some(has) && + candidate.signals.ageGate !== "too-old" && + !candidate.signals.linkedPr && + !candidate.signals.noNewFixPr + ); +} + +function localOpenClawCandidateSort(left, right, definition) { + const leftTime = Date.parse(definition.sort === "updated" ? left.updatedAt : left.createdAt) || 0; + const rightTime = + Date.parse(definition.sort === "updated" ? right.updatedAt : right.createdAt) || 0; + return definition.order === "desc" ? rightTime - leftTime : leftTime - rightTime; +} + +function localOpenClawIssueSearchQuery(repo, definition, now) { + const parts = [ + `repo:${repo}`, + "is:issue", + "is:open", + "-linked:pr", + `created:>=${new Date(now - 30 * 24 * 3_600_000).toISOString().slice(0, 10)}`, + ...definition.labels.map((label) => `label:"${label}"`), + ...definition.excludeLabels.map((label) => `-label:"${label}"`), + ]; + return parts.join(" "); +} + +function readGitcrawlAuthoredPullRequests(database, repo, login) { + const statement = database.prepare(` + SELECT + t.id, + t.number, + t.title, + t.html_url, + t.author_login, + t.labels_json, + t.is_draft, + t.updated_at_gh, + t.updated_at, + d.head_ref, + d.head_sha + FROM threads t + JOIN repositories r ON r.id = t.repo_id + LEFT JOIN pull_request_details d ON d.thread_id = t.id + WHERE r.full_name = $repo + AND t.kind = 'pull_request' + AND t.state = 'open' + AND lower(t.author_login) = $login + ORDER BY COALESCE(t.updated_at_gh, t.updated_at) DESC + LIMIT 20 + `); + return statement.all({ repo, login }).map((row) => { + const labels = labelsFromJson(row.labels_json); + return { + number: Number(row.number), + title: clean(row.title, 240), + url: clean(row.html_url, 1000) || `https://github.com/${repo}/pull/${row.number}`, + author: row.author_login || null, + labels, + branch: row.head_ref || null, + headSha: row.head_sha || null, + draft: Boolean(row.is_draft), + updatedAt: row.updated_at_gh || row.updated_at || null, + signals: localOpenClawPrSignals(labels), + checks: localGitcrawlCheckSummary(database, row.id, localOpenClawPrSignals(labels)), + source: "gitcrawl", + }; + }); +} + +function localGitcrawlCheckSummary(database, threadId, signals) { + const rows = database + .prepare( + ` + SELECT name, status, conclusion, started_at, completed_at, fetched_at + FROM pull_request_checks + WHERE thread_id = $threadId + ORDER BY COALESCE(completed_at, started_at, fetched_at) DESC + `, + ) + .all({ threadId }); + if (!rows.length) { + return { + state: "unknown", + total: 0, + failing: [], + pending: [], + timedOut: [], + mantis: signals.mantisRequested ? "requested" : null, + }; + } + const latestByName = new Map(); + for (const row of rows) { + const name = clean(row.name, 160) || "check"; + if (!latestByName.has(name)) latestByName.set(name, row); + } + const latest = [...latestByName.values()]; + const failing = []; + const pending = []; + const timedOut = []; + for (const row of latest) { + const name = clean(row.name, 160) || "check"; + const status = String(row.status || "").toLowerCase(); + const conclusion = String(row.conclusion || "").toLowerCase(); + if (status !== "completed") { + pending.push(name); + continue; + } + if (["timed_out", "startup_failure"].includes(conclusion)) timedOut.push(name); + if ( + ["failure", "timed_out", "cancelled", "action_required", "startup_failure"].includes( + conclusion, + ) + ) { + failing.push(name); + } + } + const mantisCheck = latest.find((row) => /mantis/i.test(String(row.name || ""))); + return { + state: failing.length ? "failing" : pending.length ? "pending" : "green", + total: latest.length, + failing, + pending, + timedOut, + mantis: mantisCheck + ? String(mantisCheck.conclusion || mantisCheck.status || "seen").toLowerCase() + : signals.mantisRequested + ? "requested" + : null, + }; +} + +function localOpenClawPrSignals(labels) { + const lower = labels.map((label) => label.toLowerCase()); + const has = (label) => lower.some((item) => item === label.toLowerCase()); + const includes = (needle) => lower.some((label) => label.includes(needle)); + const statusLabel = + labels.find((label) => label.toLowerCase().startsWith("status:")) || + labels.find((label) => label.toLowerCase().startsWith("clawsweeper:")) || + null; + const needsProof = includes("needs proof") || includes("proof: needed"); + const waitingOnAuthor = includes("waiting on author") || includes("actively grinding"); + return { + readyForMaintainer: + (includes("ready for maintainer look") || has("clawsweeper:ready-for-maintainer-look")) && + !needsProof && + !waitingOnAuthor, + needsProof, + waitingOnAuthor, + reReviewLoop: includes("re-review") || has("clawsweeper:re-review"), + proofSufficient: includes("proof: sufficient"), + mergeReady: includes("merge-ready"), + mantisRequested: lower.some((label) => label.startsWith("mantis:")), + clawsweeperHumanReview: has("clawsweeper:needs-maintainer-review"), + statusLabel, + }; +} + +function labelsFromJson(value) { + try { + const labels = JSON.parse(String(value || "[]")); + return Array.isArray(labels) + ? labels + .map((label) => (typeof label === "string" ? label : label?.name)) + .filter(Boolean) + .map(String) + : []; + } catch { + return []; + } +} + +function normalizeRepo(value) { + const repo = clean(value, 160).toLowerCase(); + return /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(repo) ? repo : ""; +} + +function oneOf(value, values, fallback) { + const text = String(value || ""); + return values.includes(text) ? text : fallback; +} + +function uniqueIssueNumbers(value) { + const raw = Array.isArray(value) ? value : []; + return [...new Set(raw.map((item) => integer(item, null)).filter((item) => item && item > 0))]; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function createManualTrackedRun(body, issueNumber, issueUrl) { + const id = `oc-track-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`; + const now = new Date().toISOString(); + const prompt = clean(body.prompt, 100_000); + const logPath = path.join(logDir, `${id}.jsonl`); + const status = runStatus(body.status, "tracked"); + const codexReasoningEffort = reasoningEffort( + body.codexReasoningEffort || body.reasoningEffort, + defaultReasoningEffort, + ); + const proofModeValue = proofMode(body.proofMode || body.openClawProofMode, "auto"); + const run = { + id, + status: status === "starting" || status === "running" ? "tracked" : status, + pid: null, + issueNumber, + issueUrl, + title: clean(body.title, 300), + queueId: clean(body.queueId, 80), + codexReasoningEffort, + proofMode: proofModeValue, + claimCommentUrl: clean(body.claimCommentUrl, 500) || null, + claimCommentStatus: clean(body.claimCommentStatus, 80) || null, + workspace, + baseWorkspace: workspace, + worktreePath: null, + worktreeBranch: null, + worktreeBase: null, + logPath, + startedAt: now, + finishedAt: null, + exitCode: null, + signal: null, + error: null, + note: clean(body.note, 500) || "Manual track entry for copy/paste or external Codex work.", + lastActivityLines: [], + source: clean(body.source, 80) || "manual", + updatedAt: now, + }; + appendRunActivity(run, "Tracked external/manual Codex work."); + await writeRunLog(run, { + type: "track", + at: now, + prompt, + codexReasoningEffort, + proofMode: proofModeValue, + }); + runs.set(id, run); + persistRunsSoon(); + return run; +} + +async function createRunWorktree(run, log) { + await ensureGitWorkspace(); + await fsp.mkdir(worktreeDir, { recursive: true }); + const worktreeName = runWorktreeName(run); + const target = path.join(worktreeDir, worktreeName); + const branch = runWorktreeBranch(run); + await git(["-C", workspace, "worktree", "add", "-b", branch, target, worktreeBase]); + run.workspace = target; + run.worktreePath = target; + run.worktreeBranch = branch; + run.updatedAt = new Date().toISOString(); + appendRunActivity(run, `Worktree ready: ${branch}`); + log.write( + `${JSON.stringify({ + type: "worktree", + at: run.updatedAt, + path: target, + branch, + baseWorkspace: workspace, + baseRef: worktreeBase, + })}\n`, + ); + persistRunsSoon(); + return target; +} + +async function cleanupRunWorktree(run, log) { + if (!run.worktreePath && !run.worktreeBranch) return; + const at = new Date().toISOString(); + const worktreePath = run.worktreePath; + const branch = run.worktreeBranch; + const removedWorktree = worktreePath + ? await git(["-C", workspace, "worktree", "remove", "--force", worktreePath], { + allowFailure: true, + }) + : null; + const removedBranch = branch + ? await git(["-C", workspace, "branch", "-D", branch], { allowFailure: true }) + : null; + if (removedWorktree && removedWorktree.status !== 0) { + throw new Error(`worktree cleanup failed: ${clean(removedWorktree.stderr, 500)}`); + } + if (removedBranch && removedBranch.status !== 0) { + throw new Error(`branch cleanup failed: ${clean(removedBranch.stderr, 500)}`); + } + run.workspace = null; + run.worktreePath = null; + run.worktreeBranch = null; + run.updatedAt = at; + log.write( + `${JSON.stringify({ + type: "cleanup", + at, + worktreePath, + branch, + })}\n`, + ); + persistRunsSoon(); +} + +async function prepareCodexRunEnvironment(runWorkspace, log) { + const tempRoot = path.join(runWorkspace, ".tmp", "claw-queue"); + const tempDir = path.join(tempRoot, "tmp"); + const corepackHome = path.join(tempRoot, "corepack"); + const npmCache = path.join(tempRoot, "npm-cache"); + await ensureWorktreeGitExclude(runWorkspace, ".tmp/claw-queue/"); + await Promise.all([ + fsp.mkdir(tempDir, { recursive: true }), + fsp.mkdir(corepackHome, { recursive: true }), + fsp.mkdir(npmCache, { recursive: true }), + ]); + const env = { + ...process.env, + COREPACK_ENABLE_DOWNLOAD_PROMPT: process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT || "0", + COREPACK_HOME: process.env.COREPACK_HOME || corepackHome, + OPENCLAW_HEAVY_CHECK_LOCK_SCOPE: process.env.OPENCLAW_HEAVY_CHECK_LOCK_SCOPE || "worktree", + npm_config_cache: process.env.npm_config_cache || process.env.NPM_CONFIG_CACHE || npmCache, + TEMP: tempDir, + TMP: tempDir, + }; + log.write( + `${JSON.stringify({ + type: "environment", + at: new Date().toISOString(), + tempDir, + corepackHome: env.COREPACK_HOME, + npmCache: env.npm_config_cache, + heavyCheckLockScope: env.OPENCLAW_HEAVY_CHECK_LOCK_SCOPE, + })}\n`, + ); + return env; +} + +async function ensureWorktreeGitExclude(runWorkspace, pattern) { + const result = await git(["-C", runWorkspace, "rev-parse", "--git-path", "info/exclude"]); + const excludePath = path.resolve(runWorkspace, result.stdout.trim()); + const existing = await fsp.readFile(excludePath, "utf8").catch(() => ""); + const lines = existing.split(/\r?\n/).map((line) => line.trim()); + if (lines.includes(pattern)) return; + await fsp.mkdir(path.dirname(excludePath), { recursive: true }); + await fsp.appendFile( + excludePath, + `${existing.endsWith("\n") || !existing ? "" : "\n"}${pattern}\n`, + ); +} + +function openClawRunnerPrompt(prompt, run) { + const lines = [ + "Claw Queue runner context:", + "- You are running in a bridge-created git worktree. Do not run `git pull` inside this worktree; the bridge chose the base ref and branch.", + "- Re-check live GitHub state, but if local `gh` or `gitcrawl` cannot read config from outside the worktree sandbox, use the available GitHub connector instead of spending time repairing local CLI config.", + "- The bridge sets worktree-local TEMP/TMP/Corepack/npm cache paths and `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree`; prefer commands that respect those defaults.", + `- Tokenjuice output compaction is expected for noisy terminal output. CLI command: ${tokenjuiceLaunch.display}. Use \`tokenjuice doctor hooks\` to verify Codex hook wiring, \`tokenjuice stats --timezone utc\` for compaction stats, and \`tokenjuice wrap --raw -- \` when exact raw output is required.`, + "- Check required proof tooling early. If Crabbox, Blacksmith Testbox, Mantis, live credentials, or Codex review are unavailable, park with a concise blocker instead of opening an under-proved PR.", + "- Apply the OpenClaw maintainer handbook gates: release-sensitive fixes need release-branch awareness, plugin install/update/SDK/package changes need maintainer-channel discussion, and security-adjacent work must avoid public vulnerability metadata or trial-maintainer direct-to-main assumptions.", + ...localSkillContextLines(), + run.claimCommentStatus + ? `- Claim comment status from Claw Queue: ${run.claimCommentStatus}${run.claimCommentUrl ? ` (${run.claimCommentUrl})` : ""}. Do not post a duplicate claim.` + : "", + ].filter(Boolean); + return `${lines.join("\n")}\n\n${prompt}`; +} + +function localSkillContextLines() { + if (!includeSkillContext) return []; + const skills = discoverRelevantSkills(skillRoots); + if (!skills.length) { + return [ + `- Local Codex skill directories checked: ${skillRoots.join(", ")}. No relevant SKILL.md files were found, so continue with repo guidance and available tools.`, + ]; + } + return [ + `- Local Codex skill directories checked: ${skillRoots.join(", ")}`, + "- Use relevant local skills before acting when they match the work. High-signal skills found:", + ...skills.map((skill) => ` - ${skill.name}: ${skill.path}`), + ]; +} + +function relevantSkillRoots(primaryRoot) { + const roots = [ + primaryRoot, + args["maintainer-skills-dir"], + process.env.OPENCLAW_MAINTAINER_SKILLS_DIR, + process.env.OPENCLAW_MAINTAINERS_DIR + ? path.join(process.env.OPENCLAW_MAINTAINERS_DIR, ".agents", "skills") + : "", + path.join( + os.homedir(), + "AppData", + "Local", + "OpenClawMaintainer", + "repos", + "maintainers", + ".agents", + "skills", + ), + ].filter(Boolean); + const seen = new Set(); + return roots + .map((root) => path.resolve(String(root))) + .filter((root) => { + const key = root.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function discoverRelevantSkills(roots) { + const wanted = [ + "openclaw-pre-pr-check", + "openclaw-pr-maintainer", + "openclaw-testing", + "openclaw-debugging", + "openclaw-qa-testing", + "openclaw-docker-e2e-authoring", + "openclaw-small-bugfix-sweep", + "clawsweeper", + "clawdtributor", + "crabbox", + "gitcrawl", + "tokenjuice", + "pr-cluster", + "review-pr", + "tag-duplicate-prs-issues", + "gh-fix-ci", + "gh-address-comments", + "ghsa-opengrep-detector", + "opengrep-rule-review", + "openclaw-opengrep-remediation", + "crabpot-perf-metrics", + "codex-review", + "agent-transcript", + "tmux-lane-orchestrator", + "technical-documentation", + ]; + const results = []; + const seen = new Set(); + for (const root of roots) { + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) continue; + for (const name of wanted) { + const skillPath = path.join(root, name, "SKILL.md"); + const key = name.toLowerCase(); + if (!seen.has(key) && fs.existsSync(skillPath)) { + seen.add(key); + results.push({ name, path: skillPath }); + } + } + } + return results; +} + +function createRunOutputObserver() { + let buffer = ""; + let outcome = null; + return { + push(text) { + buffer += text; + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ""; + for (const line of lines) { + const inferred = inferRunOutcomeFromLogLine(line); + if (inferred) outcome = inferred; + } + }, + outcome() { + if (!outcome && buffer.trim()) outcome = inferRunOutcomeFromLogLine(buffer); + return outcome; + }, + }; +} + +function inferRunOutcomeFromLogLine(line) { + let parsed; + try { + parsed = JSON.parse(line); + } catch { + return inferRunOutcomeFromText(line); + } + const text = + parsed?.item?.type === "agent_message" + ? parsed.item.text + : parsed?.type === "agent_message" + ? parsed.text || parsed.message + : parsed?.message || parsed?.text || ""; + return inferRunOutcomeFromText(text); +} + +function inferRunOutcomeFromText(value) { + const text = clean(value, 12_000); + if (!text) return null; + const lower = text.toLowerCase(); + const parked = + /\bparked before pr\b/.test(lower) || + /\bpr:\s*not opened\b/.test(lower) || + /\bno pr opened\b/.test(lower) || + /\bdid not open a pr\b/.test(lower) || + /\bblocked proof\b/.test(lower) || + /\bcannot honestly be marked\b/.test(lower); + if (!parked) return null; + return { + status: "parked", + note: summarizeRunOutcomeNote(text), + }; +} + +function summarizeRunOutcomeNote(text) { + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const important = lines.filter((line) => + /parked before pr|did not open a pr|blocked proof|codex review:|ci\/clawsweeper:|crabbox|mantis|live proof/i.test( + line, + ), + ); + return clean((important.length ? important : lines).slice(0, 4).join(" "), 500); +} + +async function ensureGitWorkspace() { + const result = await git(["-C", workspace, "rev-parse", "--is-inside-work-tree"], { + allowFailure: true, + }); + if (result.status !== 0 || result.stdout.trim() !== "true") { + throw new Error(`Workspace is not a git work tree: ${workspace}`); + } +} + +function runWorktreeName(run) { + const issuePart = run.issueNumber ? `issue-${run.issueNumber}` : "issue"; + return safePathSegment(`${issuePart}-${run.id}`); +} + +function runWorktreeBranch(run) { + const issuePart = run.issueNumber ? `issue-${run.issueNumber}` : "issue"; + return `claw-queue/${safeBranchSegment(issuePart)}-${safeBranchSegment(run.id)}`; +} + +function safePathSegment(value) { + return ( + String(value || "run") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120) || "run" + ); +} + +function safeBranchSegment(value) { + return ( + String(value || "run") + .toLowerCase() + .replace(/[^a-z0-9._/-]+/g, "-") + .replace(/\/+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/\.+$/g, "") + .slice(0, 120) || "run" + ); +} + +async function git(args, options = {}) { + return runCommand("git", args, options); +} + +async function runCommand(command, commandArgs, options = {}) { + const launch = resolveCommandLaunch(command); + const child = spawn(launch.command, commandLaunchArgs(launch, commandArgs), { + cwd: workspace, + env: process.env, + windowsHide: true, + windowsVerbatimArguments: Boolean(launch.cmdTarget), + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + if (options.input !== undefined) child.stdin?.end(options.input); + else child.stdin?.end(); + const status = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 0)); + }); + const result = { status, stdout, stderr }; + if (status !== 0 && !options.allowFailure) { + throw new Error( + `${command} ${commandArgs.join(" ")} failed with ${status}: ${clean(stderr || stdout, 1000)}`, + ); + } + return result; +} + +async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + const closed = new Promise((resolve) => child.once("close", resolve)); + if (!child.killed) child.kill(); + await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 1500))]); +} + +async function writeRunLog(run, event) { + appendRunActivity(run, event); + await fsp.mkdir(logDir, { recursive: true }); + await fsp.appendFile(run.logPath, `${JSON.stringify(event)}\n`, "utf8"); +} + +function appendRunActivity(run, value) { + if (!run) return; + const lines = runActivityLines(value); + if (!lines.length) return; + const current = Array.isArray(run.lastActivityLines) ? run.lastActivityLines : []; + run.lastActivityLines = [...current, ...lines].slice(-4); +} + +function runActivityLines(value) { + if (value === null || value === undefined) return []; + if (typeof value === "object" && !Buffer.isBuffer(value)) { + const type = clean(value.type, 40); + const text = clean(value.text || value.error || value.label || value.status || value.path, 220); + if (type === "prompt") return []; + return [clean([type, text].filter(Boolean).join(": "), 240)].filter(Boolean); + } + const text = String(value || "") + .split(/\r?\n/) + .map((line) => clean(runActivityLineText(line), 240)) + .filter(Boolean) + .filter((line) => !/"type":"prompt"/.test(line)) + .slice(-4); + return text; +} + +function runActivityLineText(line) { + const text = String(line || "").trim(); + if (!text) return ""; + try { + const parsed = JSON.parse(text); + if (parsed?.type === "prompt") return ""; + const type = clean(parsed?.type, 40); + const body = clean( + parsed?.message || + parsed?.text || + parsed?.error || + parsed?.label || + parsed?.status || + parsed?.event || + "", + 220, + ); + return [type, body].filter(Boolean).join(": "); + } catch { + return text; + } +} + +function openLogStream(logPath) { + const stream = fs.createWriteStream(logPath, { flags: "a" }); + return new Promise((resolve, reject) => { + function cleanup() { + stream.off("open", onOpen); + stream.off("error", onError); + } + function onOpen() { + cleanup(); + resolve(stream); + } + function onError(error) { + cleanup(); + reject(error); + } + stream.once("open", onOpen); + stream.once("error", onError); + }); +} + +function markRunFailed(run, error) { + run.status = "failed"; + run.error = error.message || String(error); + run.finishedAt = new Date().toISOString(); + run.updatedAt = run.finishedAt; + persistRunsSoon(); +} + +function activeRuns() { + return [...runs.values()].filter((run) => runIsLive(run)); +} + +function runIsLive(run) { + return !run?.finishedAt && (run?.status === "starting" || run?.status === "running"); +} + +function visibleRuns() { + return unarchivedRuns().slice(0, 50); +} + +function unarchivedRuns() { + return [...runs.values()] + .filter((run) => !run.archivedAt) + .sort((left, right) => runSortTime(right) - runSortTime(left)); +} + +function visibleRunForIssue(issueNumber, issueUrl) { + const key = issueKey(issueNumber, issueUrl); + if (!key) return null; + return ( + unarchivedRuns().find( + (run) => runBlocksDuplicateWork(run) && issueKey(run.issueNumber, run.issueUrl) === key, + ) || null + ); +} + +function runBlocksDuplicateWork(run) { + return ["starting", "running", "tracked", "ready", "parked", "handoff-sent"].includes( + run?.status, + ); +} + +async function readRunLog(run, url) { + const bytesParam = url.searchParams.get("bytes") ?? undefined; + const entriesParam = url.searchParams.get("entries") ?? undefined; + const maxBytes = Math.max(4096, Math.min(1_000_000, integer(bytesParam, 200_000))); + const maxEntries = Math.max(20, Math.min(500, integer(entriesParam, 160))); + const logPath = safeRunLogPath(run); + let stat; + try { + stat = await fsp.stat(logPath); + } catch (error) { + if (error?.code === "ENOENT") { + return { + path: logPath, + exists: false, + size: 0, + truncated: false, + entries: [], + text: "", + }; + } + throw error; + } + + const start = Math.max(0, stat.size - maxBytes); + const length = stat.size - start; + const handle = await fsp.open(logPath, "r"); + let text = ""; + try { + const buffer = Buffer.alloc(length); + await handle.read(buffer, 0, length, start); + text = buffer.toString("utf8"); + } finally { + await handle.close(); + } + + let truncated = start > 0; + if (truncated) { + const newline = text.indexOf("\n"); + if (newline !== -1) text = text.slice(newline + 1); + } + const lines = text.split(/\r?\n/).filter(Boolean); + const selected = lines.slice(-maxEntries); + return { + path: logPath, + exists: true, + size: stat.size, + truncated: truncated || lines.length > selected.length, + entries: selected.map((line, index) => + normalizeRunLogLine(line, lines.length - selected.length + index), + ), + text: selected.join("\n"), + updatedAt: new Date(stat.mtimeMs).toISOString(), + }; +} + +function safeRunLogPath(run) { + const fallback = path.join(logDir, `${run.id}.jsonl`); + const resolved = path.resolve(String(run.logPath || fallback)); + const root = path.resolve(logDir); + const relative = path.relative(root, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("run log path is outside the bridge log directory"); + } + return resolved; +} + +function normalizeRunLogLine(line, index) { + try { + const parsed = JSON.parse(line); + return normalizeRunLogEvent(parsed, index, line); + } catch { + return { + index, + type: "output", + label: "Output", + text: clean(line, 8000), + }; + } +} + +function normalizeRunLogEvent(event, index, line) { + const type = clean(event?.type, 80) || "event"; + const at = clean(event?.at, 80) || null; + const base = { index, type, at }; + if (type === "request") { + return { + ...base, + label: "Start request", + text: [ + event.issueNumber ? `Issue #${event.issueNumber}` : "", + clean(event.title, 220), + event.queueId ? `Queue ${clean(event.queueId, 80)}` : "", + event.codexReasoningEffort ? `thinking ${clean(event.codexReasoningEffort, 20)}` : "", + event.proofMode ? `proof ${clean(event.proofMode, 20)}` : "", + event.dryRun ? "dry-run" : "", + ] + .filter(Boolean) + .join(" | "), + }; + } + if (type === "prompt") { + return { + ...base, + label: "Prompt", + text: summarizePrompt(event.text), + }; + } + if (type === "worktree") { + return { + ...base, + label: "Worktree", + text: [ + event.path ? `path ${clean(event.path, 500)}` : "", + event.branch ? `branch ${clean(event.branch, 220)}` : "", + event.baseRef ? `base ${clean(event.baseRef, 120)}` : "", + ] + .filter(Boolean) + .join(" | "), + }; + } + if (type === "environment") { + return { + ...base, + label: "Runner environment", + text: [ + event.tempDir ? `temp ${clean(event.tempDir, 500)}` : "", + event.corepackHome ? `corepack ${clean(event.corepackHome, 500)}` : "", + event.heavyCheckLockScope ? `lock ${clean(event.heavyCheckLockScope, 80)}` : "", + ] + .filter(Boolean) + .join(" | "), + }; + } + if (type === "stderr") { + return { + ...base, + label: "stderr", + text: clean(event.text, 8000), + }; + } + if (type === "stdin-error" || type === "error") { + return { + ...base, + label: "Error", + text: clean(event.error || event.text || line, 8000), + }; + } + if (type === "exit") { + return { + ...base, + label: "Exit", + text: `code=${event.code ?? "null"}${event.signal ? ` signal=${event.signal}` : ""}`, + }; + } + return { + ...base, + label: eventLabel(type), + text: runLogEventText(event, line), + }; +} + +function summarizePrompt(value) { + const text = clean(value, 12_000); + const firstLines = text.split(/\r?\n/).slice(0, 28).join("\n"); + return text.length > firstLines.length ? `${firstLines}\n...` : firstLines; +} + +function runLogEventText(event, fallback) { + const candidates = [ + event?.message, + event?.text, + event?.summary, + event?.content, + event?.prompt, + event?.error, + event?.delta, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) return clean(candidate, 8000); + } + return clean(JSON.stringify(event), 8000) || clean(fallback, 8000); +} + +function eventLabel(type) { + return type.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function issueKey(issueNumber, issueUrl) { + const normalized = integer(issueNumber, null); + let parsedIssueNumber = normalized; + try { + const url = new URL(String(issueUrl || "")); + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length >= 4 && parts[2] === "issues") { + const urlIssueNumber = integer(parts[3], null); + parsedIssueNumber = urlIssueNumber || parsedIssueNumber; + if (parsedIssueNumber) { + return `${url.hostname.toLowerCase()}/${parts[0].toLowerCase()}/${parts[1].toLowerCase()}#${parsedIssueNumber}`; + } + } + } catch {} + return parsedIssueNumber ? `#${parsedIssueNumber}` : ""; +} + +function runSortTime(run) { + const parsed = Date.parse(run?.updatedAt || run?.startedAt || ""); + return Number.isFinite(parsed) ? parsed : 0; +} + +function updateRunFromBody(run, body) { + const status = body.status === "archive" ? "archived" : runStatus(body.status, run.status); + if ((status === "starting" || status === "running") && status !== run.status) { + return { + error: "cannot set live status with PATCH", + status: 409, + }; + } + if (runIsLive(run) && status !== run.status) { + return { + error: "cannot change status while Codex process is active", + status: 409, + }; + } + const now = new Date().toISOString(); + run.status = status; + run.updatedAt = now; + if (body.note !== undefined) run.note = clean(body.note, 500); + if (body.prUrl !== undefined) run.prUrl = clean(body.prUrl, 500); + if (status === "archived") run.archivedAt = now; + if ( + (status === "ready" || + status === "handoff-sent" || + status === "parked" || + status === "completed") && + !run.finishedAt + ) { + run.finishedAt = now; + } + return null; +} + +function updateRunClaimFromBody(run, body) { + const claimCommentUrl = clean(body.claimCommentUrl, 500); + const claimCommentStatus = clean(body.claimCommentStatus, 80); + let changed = false; + if (claimCommentUrl && !run.claimCommentUrl) { + run.claimCommentUrl = claimCommentUrl; + changed = true; + } + if (claimCommentStatus && !run.claimCommentStatus) { + run.claimCommentStatus = claimCommentStatus; + changed = true; + } + if (changed) { + run.updatedAt = new Date().toISOString(); + persistRunsSoon(); + } +} + +function runStatus(value, fallback) { + const status = clean(value, 40); + return [ + "tracked", + "starting", + "running", + "completed", + "failed", + "dry-run", + "parked", + "ready", + "handoff-sent", + "stale", + "archived", + ].includes(status) + ? status + : fallback; +} + +function reasoningEffort(value, fallback) { + const effort = clean(value, 20).toLowerCase(); + return ["low", "medium", "high", "xhigh"].includes(effort) ? effort : fallback; +} + +function proofMode(value, fallback) { + const mode = clean(value, 20).toLowerCase(); + return ["auto", "local", "crabbox", "testbox", "mantis"].includes(mode) ? mode : fallback; +} + +async function loadPersistedRuns() { + let changed = false; + try { + const text = await fsp.readFile(statePath, "utf8"); + const parsed = JSON.parse(text); + const now = new Date().toISOString(); + for (const run of Array.isArray(parsed?.runs) ? parsed.runs : []) { + if (!run?.id) continue; + const restored = { ...run }; + if (restored.status === "starting" || restored.status === "running") { + restored.status = "stale"; + restored.error = restored.error || "Runner restarted before this Codex process finished."; + restored.finishedAt = restored.finishedAt || now; + restored.updatedAt = now; + changed = true; + } + runs.set(String(restored.id), restored); + } + if (changed) persistRunsSoon(); + } catch (error) { + if (error?.code !== "ENOENT") { + console.warn(`Could not load run state: ${error.message || String(error)}`); + } + } +} + +function persistRunsSoon() { + if (persistTimer) return; + persistTimer = setTimeout(() => { + persistTimer = null; + persistRuns().catch((error) => { + console.warn(`Could not persist run state: ${error.message || String(error)}`); + }); + }, 25); +} + +async function persistRuns() { + await fsp.mkdir(logDir, { recursive: true }); + const persistedRuns = [...runs.values()] + .sort((left, right) => { + if (Boolean(left.archivedAt) !== Boolean(right.archivedAt)) { + return left.archivedAt ? 1 : -1; + } + return runSortTime(right) - runSortTime(left); + }) + .slice(0, 200); + const payload = JSON.stringify({ version: 1, runs: persistedRuns }, null, 2); + const tempPath = `${statePath}.${process.pid}.tmp`; + await fsp.writeFile(tempPath, payload, "utf8"); + await fsp.rename(tempPath, statePath); +} + +async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > 1_000_000) throw new Error("request body is too large"); + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString("utf8"); + return text ? JSON.parse(text) : {}; +} + +function authorized(request) { + return timingSafeEqualText(header(request, "authorization"), `Bearer ${token}`); +} + +function unauthorized(request, response) { + sendJson(request, response, { ok: false, error: "missing or invalid runner token" }, 401); +} + +function sendJson(request, response, body, status = 200) { + response.writeHead(status, { + ...corsHeaders(request), + "content-type": "application/json; charset=utf-8", + }); + response.end(JSON.stringify(body)); +} + +function corsHeaders(request) { + const origin = header(request, "origin"); + const headers = { + "access-control-allow-headers": "authorization, content-type", + "access-control-allow-methods": "GET, POST, PATCH, OPTIONS", + "access-control-max-age": "600", + }; + if (!origin || allowedOrigin(origin)) { + headers["access-control-allow-origin"] = origin || "http://127.0.0.1"; + if (header(request, "access-control-request-private-network") === "true") { + headers["access-control-allow-private-network"] = "true"; + } + } + return headers; +} + +function allowedOrigin(origin) { + try { + const url = new URL(origin); + return ( + isLoopbackHost(url.hostname) || + url.hostname === "clawfleet.openclaw.ai" || + url.hostname === "crabfleet.openclaw.ai" || + url.hostname === "crabyard.openclaw.ai" + ); + } catch { + return false; + } +} + +function isLoopbackHost(value) { + const host = String(value || "") + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +function header(request, name) { + const value = request.headers[name.toLowerCase()]; + return Array.isArray(value) ? value[0] || "" : value || ""; +} + +function timingSafeEqualText(left, right) { + const leftBuffer = Buffer.from(String(left)); + const rightBuffer = Buffer.from(String(right)); + if (leftBuffer.length !== rightBuffer.length) return false; + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function parseArgs(argv) { + const result = { _: [] }; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (!item.startsWith("--")) { + result._.push(item); + continue; + } + const equal = item.indexOf("="); + if (equal !== -1) { + result[item.slice(2, equal)] = item.slice(equal + 1); + continue; + } + const key = item.slice(2); + const next = argv[index + 1]; + if (next && !next.startsWith("--")) { + result[key] = next; + index += 1; + } else { + result[key] = true; + } + } + return result; +} + +function resolveCodexLaunch(value) { + if (process.platform !== "win32") return { command: value, args: [], display: value }; + const commandPath = resolveWindowsCommand(value); + if (!commandPath) return { command: value, args: [], display: value }; + const lower = commandPath.toLowerCase(); + if (lower.endsWith(".exe")) return { command: commandPath, args: [], display: commandPath }; + if (lower.endsWith(".cmd") || lower.endsWith(".bat") || lower.endsWith(".ps1")) { + const npmJs = path.join( + path.dirname(commandPath), + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + if (fs.existsSync(npmJs)) return { command: "node.exe", args: [npmJs], display: commandPath }; + if (lower.endsWith(".ps1")) { + return { + command: "powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", commandPath], + display: commandPath, + }; + } + return { + command: process.env.ComSpec || "cmd.exe", + cmdTarget: commandPath, + args: [], + display: commandPath, + }; + } + const siblingExe = `${commandPath}.exe`; + if (fs.existsSync(siblingExe)) return { command: siblingExe, args: [], display: siblingExe }; + const siblingCmd = `${commandPath}.cmd`; + if (fs.existsSync(siblingCmd)) { + const npmJs = path.join( + path.dirname(siblingCmd), + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + if (fs.existsSync(npmJs)) return { command: "node.exe", args: [npmJs], display: siblingCmd }; + return { + command: process.env.ComSpec || "cmd.exe", + cmdTarget: siblingCmd, + args: [], + display: siblingCmd, + }; + } + return { command: commandPath, args: [], display: commandPath }; +} + +function resolveCommandLaunch(value) { + if (process.platform !== "win32") return { command: value, args: [], display: value }; + const commandPath = resolveWindowsCommand(value); + if (!commandPath) return { command: value, args: [], display: value }; + const lower = commandPath.toLowerCase(); + if (lower.endsWith(".exe")) return { command: commandPath, args: [], display: commandPath }; + if (lower.endsWith(".ps1")) { + return { + command: "powershell.exe", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", commandPath], + display: commandPath, + }; + } + if (lower.endsWith(".cmd") || lower.endsWith(".bat")) { + return { + command: process.env.ComSpec || "cmd.exe", + cmdTarget: commandPath, + args: [], + display: commandPath, + }; + } + const siblingExe = `${commandPath}.exe`; + if (fs.existsSync(siblingExe)) return { command: siblingExe, args: [], display: siblingExe }; + const siblingCmd = `${commandPath}.cmd`; + if (fs.existsSync(siblingCmd)) { + return { + command: process.env.ComSpec || "cmd.exe", + cmdTarget: siblingCmd, + args: [], + display: siblingCmd, + }; + } + return { command: commandPath, args: [], display: commandPath }; +} + +function codexLaunchArgs(launch, args) { + return commandLaunchArgs(launch, args); +} + +function commandLaunchArgs(launch, args) { + if (launch.cmdTarget) { + return ["/d", "/c", ["call", cmdQuote(launch.cmdTarget), ...args.map(cmdQuote)].join(" ")]; + } + return [...launch.args, ...args]; +} + +function cmdQuote(value) { + return `"${String(value).replaceAll('"', '\\"')}"`; +} + +function resolveWindowsCommand(value) { + if (/[\\/]/.test(value)) return path.resolve(value); + const hasExtension = Boolean(path.extname(value)); + const names = hasExtension + ? [value] + : [`${value}.exe`, `${value}.cmd`, `${value}.bat`, `${value}.ps1`, value]; + for (const directory of String(process.env.PATH || "").split(path.delimiter)) { + if (!directory) continue; + for (const name of names) { + const candidate = path.join(directory, name); + if (fs.existsSync(candidate)) return candidate; + } + } + return ""; +} + +function clean(value, max) { + return String(value ?? "") + .trim() + .slice(0, max); +} + +function integer(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; +} + +function flag(value) { + return value === true || value === 1 || String(value).toLowerCase() === "true"; +} + +function sha256(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} diff --git a/src/app.html b/src/app.html index 7afbe5a4..00d8fd2b 100644 --- a/src/app.html +++ b/src/app.html @@ -165,6 +165,13 @@ border-color: var(--primary-dark); box-shadow: 0 4px 10px rgb(94 106 210 / 0.35); } + button.primary[disabled], + button.primary[disabled]:hover { + border-color: var(--line); + background: var(--panel-2); + color: var(--muted); + box-shadow: none; + } button.ghost { border-color: transparent; background: transparent; @@ -216,6 +223,14 @@ border-color: var(--primary); box-shadow: 0 0 0 3px rgb(94 106 210 / 0.12); } + select option { + background: #ffffff; + color: #111827; + } + select option:checked { + background: var(--primary); + color: #ffffff; + } textarea { min-height: 80px; resize: vertical; @@ -227,6 +242,103 @@ font-size: 13px; font-weight: 600; } + .label-text { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + } + .help-dot { + position: relative; + width: 16px; + height: 16px; + display: inline-grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: var(--muted); + cursor: help; + font-size: 11px; + font-weight: 800; + line-height: 1; + } + .help-dot::after { + content: attr(data-help); + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + z-index: 30; + width: max-content; + max-width: min(280px, 80vw); + padding: 8px 10px; + border: 1px solid var(--line-strong); + border-radius: 6px; + background: var(--panel-2); + box-shadow: var(--shadow-lg); + color: var(--ink); + font-size: 12px; + font-weight: 500; + line-height: 1.4; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 2px); + transition: + opacity 0.12s ease, + transform 0.12s ease; + white-space: normal; + } + .help-dot:hover, + .help-dot:focus-visible { + color: var(--ink); + outline: 0; + } + .help-dot:hover::after, + .help-dot:focus-visible::after { + opacity: 1; + transform: translate(-50%, 0); + } + .candidate-select { + min-width: 132px; + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-2); + color: var(--muted); + padding: 0 9px; + } + .proof-select { + min-width: 152px; + } + .candidate-select svg { + flex: 0 0 auto; + width: 15px; + height: 15px; + } + .candidate-select select { + min-width: 88px; + height: 30px; + border: 0; + background: transparent; + box-shadow: none; + color: var(--ink); + padding: 0; + font-size: 12px; + font-weight: 750; + } + .candidate-select select option { + background: #ffffff; + color: #111827; + } + .proof-select select { + min-width: 106px; + } + .candidate-select select:focus { + box-shadow: none; + outline: 0; + } a { color: var(--primary); text-decoration: none; @@ -235,6 +347,32 @@ a:hover { color: var(--primary-dark); } + button.text-link { + min-width: 0; + height: auto; + justify-content: flex-start; + border: 0; + background: transparent; + box-shadow: none; + color: var(--primary); + padding: 0; + font: inherit; + font-weight: 800; + text-align: left; + text-decoration: none; + white-space: normal; + } + button.text-link:hover { + color: var(--primary-dark); + background: transparent; + box-shadow: none; + } + button.text-link.inline { + display: inline; + min-height: 0; + font-weight: 800; + vertical-align: baseline; + } .app { min-height: 100vh; @@ -589,7 +727,9 @@ .nav-actions { display: inline-flex; align-items: center; + flex-wrap: wrap; gap: 8px; + min-width: 0; } .rail button { width: auto; @@ -610,6 +750,7 @@ } .rail button span { line-height: 1; + white-space: nowrap; } .rail button.active { background: rgb(255 255 255 / 0.06); @@ -632,6 +773,9 @@ margin: 0 auto; padding: 34px 0 56px; } + .shell.wide { + width: min(1760px, calc(100vw - 48px)); + } .top { display: grid; grid-template-columns: minmax(260px, 1fr) auto; @@ -732,7 +876,7 @@ } .dashboard-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(82px, 1fr)); gap: 16px; } .fleet-control-panel { @@ -1346,6 +1490,166 @@ overflow: auto; padding: 20px; } + .github-blade { + position: fixed; + inset: 0; + z-index: 35; + display: none; + justify-content: end; + background: rgb(0 0 0 / 0.32); + backdrop-filter: blur(5px); + } + .github-blade.open { + display: grid; + } + .github-blade-panel { + width: min(780px, calc(100vw - 32px)); + height: 100%; + display: grid; + grid-template-rows: auto auto auto minmax(0, 1fr); + border-left: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow-xl); + overflow: hidden; + } + .github-blade-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 18px 20px; + border-bottom: 1px solid var(--line); + } + .github-blade-head h2 { + min-width: 0; + overflow: hidden; + margin: 5px 0 0; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 19px; + font-weight: 760; + } + .github-blade-head p { + margin: 5px 0 0; + color: var(--muted); + font-size: 12px; + } + .github-blade-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 20px; + border-bottom: 1px solid var(--line); + } + .github-blade-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + } + .github-blade-url { + overflow: hidden; + padding: 10px 20px; + border-bottom: 1px solid var(--line); + color: var(--muted); + } + .github-blade-url code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .github-blade-preview { + min-height: 0; + overflow: auto; + background: var(--paper); + padding: 18px 20px 24px; + } + .github-blade-card { + display: grid; + gap: 14px; + } + .github-blade-meta, + .github-blade-labels { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .github-blade-body { + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: rgb(255 255 255 / 0.03); + } + .github-blade-body pre { + overflow: auto; + max-height: calc(100vh - 330px); + margin: 0; + padding: 14px; + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; + font: + 12px/1.55 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + } + .github-blade-comments { + display: grid; + gap: 10px; + } + .github-blade-comments header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + .github-blade-comments h3 { + margin: 0; + color: var(--ink); + font-size: 15px; + font-weight: 760; + } + .github-blade-comment-list { + display: grid; + gap: 10px; + } + .github-blade-comment { + display: grid; + gap: 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgb(255 255 255 / 0.03); + padding: 12px; + } + .github-blade-comment-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + } + .github-blade-comment-head strong { + color: var(--ink); + } + .github-blade-comment pre { + overflow: auto; + max-height: 360px; + margin: 0; + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; + font: + 12px/1.55 ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; + } .form-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -1906,61 +2210,1377 @@ font-size: 13px; } - @keyframes infraDrift { - 0%, - 100% { - transform: translate(-50%, -50%) rotateX(56deg) rotateZ(-38deg) translateY(0); - } - 50% { - transform: translate(-50%, -50%) rotateX(56deg) rotateZ(-38deg) translateY(-10px); - } + .openclaw-page { + display: grid; + gap: 18px; } - - @keyframes slideInRight { - from { - opacity: 0; - transform: translateX(100%); - } - to { - opacity: 1; - transform: translateX(0); - } + .openclaw-loop-page { + gap: 16px; } - - @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } + .openclaw-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 16px 18px; } - - .drawer.open { - animation: fadeIn 0.2s ease; + .openclaw-toolbar h2, + .workflow-section h2 { + margin: 5px 0 0; + color: var(--ink); + font-size: 18px; + font-weight: 720; } - - .drawer.open .panel { - animation: slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1); + .openclaw-toolbar button, + .pr-row button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; } - - @media (max-width: 1320px) { - .top { - grid-template-columns: 1fr; - } - .status-strip { - min-width: 0; - } - .dashboard-grid { - grid-template-columns: 1fr; - } - .fleet-control-panel { - grid-template-columns: 1fr; - } - .board { - grid-template-columns: repeat(2, minmax(248px, 1fr)); - } - .admin-grid { + .openclaw-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + } + .workflow-banner { + border: 1px solid rgb(245 158 11 / 0.35); + border-radius: 8px; + background: rgb(245 158 11 / 0.08); + color: var(--ink); + padding: 10px 12px; + font-size: 13px; + } + .workflow-banner.error { + border-color: rgb(239 68 68 / 0.34); + background: rgb(239 68 68 / 0.1); + } + .workflow-banner.local-snapshot { + border-color: rgb(16 185 129 / 0.34); + background: rgb(16 185 129 / 0.08); + } + .openclaw-command-center { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.34fr); + grid-template-areas: + "main actions" + "gates gates"; + gap: 12px; + align-items: stretch; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(135deg, rgb(16 185 129 / 0.08), transparent 36%), var(--dashboard-surface); + padding: 14px; + } + .openclaw-command-center.warn { + background: + linear-gradient(135deg, rgb(245 158 11 / 0.08), transparent 36%), var(--dashboard-surface); + } + .openclaw-command-center.danger { + background: + linear-gradient(135deg, rgb(239 68 68 / 0.08), transparent 36%), var(--dashboard-surface); + } + .command-main { + grid-area: main; + min-width: 0; + display: grid; + align-content: center; + gap: 7px; + } + .command-title-row { + min-width: 0; + display: flex; + align-items: center; + gap: 9px; + } + .command-title-row h2 { + min-width: 0; + overflow: hidden; + margin: 0; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 20px; + font-weight: 760; + } + .command-pulse { + width: 10px; + height: 10px; + flex: 0 0 auto; + border-radius: 999px; + background: var(--success); + box-shadow: 0 0 0 5px rgb(16 185 129 / 0.1); + } + .command-pulse.warn { + background: var(--warning); + box-shadow: 0 0 0 5px rgb(245 158 11 / 0.1); + } + .command-pulse.danger { + background: #f87171; + box-shadow: 0 0 0 5px rgb(239 68 68 / 0.1); + } + .command-main p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; + } + .command-subline { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .command-subline span { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + padding: 4px 8px; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 700; + } + .command-actions { + grid-area: actions; + display: grid; + grid-template-columns: repeat(2, minmax(112px, 1fr)); + gap: 8px; + align-content: center; + min-width: 0; + } + .command-mode-controls { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr; + gap: 8px; + min-width: 0; + } + .command-mode-controls .candidate-select { + min-width: 0; + width: 100%; + } + .command-mode-controls .candidate-select select { + min-width: 0; + flex: 1 1 auto; + padding-right: 18px; + } + .command-actions button { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 10px; + white-space: nowrap; + } + .command-actions .candidate-select { + width: 100%; + } + .command-gates { + grid-area: gates; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; + } + .command-gate { + min-width: 0; + display: grid; + gap: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 9px 10px; + } + .command-gate.ok { + border-color: rgb(16 185 129 / 0.24); + background: rgb(16 185 129 / 0.08); + } + .command-gate.warn { + border-color: rgb(245 158 11 / 0.28); + background: rgb(245 158 11 / 0.08); + } + .command-gate.danger { + border-color: rgb(239 68 68 / 0.34); + background: rgb(239 68 68 / 0.08); + } + .command-gate span, + .command-gate small { + min-width: 0; + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; + } + .command-gate span { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + } + .command-gate strong { + min-width: 0; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + } + .command-gate small { + font-size: 11px; + } + .openclaw-settings { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) auto; + gap: 10px; + align-items: end; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 14px; + } + .openclaw-settings.skeleton { + color: var(--muted); + font-size: 13px; + } + .openclaw-settings .settings-error { + grid-column: 1 / -1; + } + .openclaw-runner { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(220px, 1.25fr) minmax(180px, 0.8fr) auto; + gap: 10px; + align-items: end; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 14px; + } + .openclaw-runner h2 { + margin: 5px 0 0; + color: var(--ink); + font-size: 18px; + font-weight: 720; + } + .runner-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: max-content; + } + .runner-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + } + .openclaw-worker-runway { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 14px; + } + .openclaw-worker-runway h2 { + margin: 5px 0 0; + color: var(--ink); + font-size: 18px; + font-weight: 720; + } + .openclaw-worker-runway header p { + margin: 5px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .runway-head-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + } + .runway-head-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + } + .worker-lanes { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + gap: 10px; + } + .worker-lane { + min-width: 0; + display: grid; + gap: 10px; + align-content: start; + min-height: 210px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 12px; + } + .worker-lane.suggested { + border-color: rgb(16 185 129 / 0.24); + background: linear-gradient(180deg, rgb(16 185 129 / 0.08), transparent 54%), var(--panel); + } + .worker-lane.filled { + border-color: rgb(245 158 11 / 0.28); + background: linear-gradient(180deg, rgb(245 158 11 / 0.08), transparent 54%), var(--panel); + } + .worker-lane.empty { + border-style: dashed; + } + .worker-lane-head { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 9px; + align-items: center; + } + .lane-number { + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: var(--ink); + font-size: 12px; + font-weight: 800; + } + .worker-lane-head strong, + .worker-lane-body a, + .worker-lane-body .text-link { + min-width: 0; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + } + .worker-lane-head strong { + display: block; + font-size: 13px; + } + .worker-lane-head span:last-child, + .worker-lane-body p, + .worker-lane-empty p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .worker-lane-body { + min-width: 0; + display: grid; + gap: 8px; + } + .worker-lane-body a, + .worker-lane-body .text-link { + font-size: 13px; + font-weight: 700; + } + .worker-lane-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + .worker-lane-actions button { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 9px; + font-size: 12px; + white-space: nowrap; + } + .worker-lane-empty { + display: grid; + place-items: center; + align-content: center; + gap: 10px; + min-height: 126px; + color: var(--muted); + text-align: center; + } + .worker-lane-empty svg { + width: 28px; + height: 28px; + } + .openclaw-loop-panel { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 14px; + } + .openclaw-loop-panel h2 { + margin: 5px 0 0; + color: var(--ink); + font-size: 18px; + font-weight: 720; + } + .openclaw-loop-panel header p { + margin: 5px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .loop-head-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + } + .loop-head-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + } + .loop-decision { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 12px; + } + .loop-decision > div:first-child { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; + } + .loop-decision strong { + color: var(--ink); + font-size: 14px; + } + .loop-decision p { + grid-column: 2; + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .loop-gates { + display: inline-flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + } + .loop-swimlanes { + display: grid; + grid-template-columns: repeat(7, minmax(220px, 1fr)); + gap: 12px; + overflow-x: auto; + padding-bottom: 4px; + } + .loop-lane { + min-width: 220px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; + align-content: start; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 10px; + } + .loop-lane.ok { + border-color: rgb(16 185 129 / 0.22); + } + .loop-lane.warn { + border-color: rgb(245 158 11 / 0.28); + } + .loop-lane header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + } + .loop-lane strong { + display: block; + color: var(--ink); + font-size: 12px; + line-height: 1.3; + overflow-wrap: anywhere; + } + .loop-lane header span:not(.chip) { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 11px; + line-height: 1.35; + } + .loop-lane-items { + display: grid; + align-content: start; + gap: 7px; + } + .loop-card { + min-width: 0; + display: grid; + gap: 5px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--panel-2); + color: var(--ink); + padding: 8px; + } + .loop-card:hover { + border-color: var(--line-strong); + color: var(--ink); + } + .loop-card strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .loop-card small { + color: var(--muted); + font-size: 11px; + line-height: 1.35; + } + .loop-card-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + } + .loop-card-actions button { + min-height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 7px; + font-size: 11px; + white-space: nowrap; + } + .loop-card-actions button svg { + width: 13px; + height: 13px; + } + .rejected-issues { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 12px; + } + .rejected-issues.compact { + margin-top: 12px; + } + .rejected-issues header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + .rejected-issues h2 { + margin: 3px 0 0; + color: var(--ink); + font-size: 16px; + } + .rejected-issue-list { + display: grid; + gap: 8px; + } + .rejected-issue { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--panel-2); + padding: 8px; + } + .rejected-issue strong { + display: block; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + } + .rejected-issue small { + display: block; + margin-top: 2px; + color: var(--muted); + font-size: 11px; + } + .openclaw-active-work { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 14px; + } + .openclaw-active-work h2 { + margin: 5px 0 0; + color: var(--ink); + font-size: 18px; + font-weight: 720; + } + .active-work-tools { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; + } + .active-work-tools button, + .active-work-detail button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + } + .active-work-mission { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.58fr) auto; + gap: 10px; + align-items: stretch; + border: 1px solid var(--line); + border-radius: 8px; + background: linear-gradient(135deg, rgb(94 106 210 / 0.12), transparent 44%), var(--panel); + padding: 12px; + } + .mission-focus, + .mission-stack { + min-width: 0; + } + .mission-focus { + display: grid; + align-content: center; + gap: 7px; + } + .mission-focus-head { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .mission-focus h3 { + margin: 0; + color: var(--ink); + font-size: 17px; + line-height: 1.25; + } + .mission-focus p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .mission-stack { + display: grid; + gap: 6px; + } + .mission-stack button { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 8px; + align-items: center; + justify-items: start; + min-height: 36px; + padding: 7px 9px; + border-color: var(--line); + background: var(--panel-2); + text-align: left; + } + .mission-stack button span:last-child { + min-width: 0; + display: flex; + gap: 6px; + color: var(--muted); + font-size: 12px; + white-space: nowrap; + } + .mission-stack strong { + color: var(--ink); + } + .mission-rank { + width: 22px; + height: 22px; + display: inline-grid; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + } + .mission-rank.ok { + border-color: rgb(16 185 129 / 0.28); + color: var(--success); + } + .mission-rank.warn { + border-color: rgb(245 158 11 / 0.32); + color: var(--warning); + } + .mission-rank.danger { + border-color: rgb(239 68 68 / 0.34); + color: #f87171; + } + .mission-actions { + display: grid; + gap: 8px; + align-content: center; + } + .mission-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + } + .active-work-radar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; + } + .radar-cell { + min-width: 0; + display: grid; + gap: 4px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 9px 10px; + } + .radar-cell.ok { + border-color: rgb(16 185 129 / 0.24); + background: linear-gradient(180deg, rgb(16 185 129 / 0.08), transparent 70%), var(--panel); + } + .radar-cell.warn { + border-color: rgb(245 158 11 / 0.28); + background: linear-gradient(180deg, rgb(245 158 11 / 0.08), transparent 70%), var(--panel); + } + .radar-cell span, + .radar-cell small { + min-width: 0; + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; + } + .radar-cell span { + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + } + .radar-cell strong { + color: var(--ink); + font-size: 22px; + line-height: 1.05; + } + .radar-cell small { + font-size: 11px; + } + .active-work-list { + display: grid; + gap: 8px; + } + .active-work-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(180px, 0.34fr); + gap: 12px; + align-items: center; + min-height: 76px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 10px; + } + .active-work-main { + min-width: 0; + display: grid; + gap: 6px; + } + .active-work-main a, + .active-work-main .text-link { + min-width: 0; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + } + .active-work-next, + .active-work-note { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .active-work-next { + color: var(--ink); + } + .active-work-activity { + display: grid; + gap: 4px; + max-width: 720px; + border: 1px solid var(--line); + border-radius: 7px; + background: rgb(15 23 42 / 0.34); + padding: 7px; + } + .active-work-activity code { + min-width: 0; + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + } + .active-work-checklist { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + .check-step { + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + padding: 3px 7px; + font-size: 11px; + font-weight: 700; + line-height: 1.1; + } + .check-step.done { + border-color: rgb(16 185 129 / 0.28); + background: rgb(16 185 129 / 0.1); + color: var(--success); + } + .check-step.warn { + border-color: rgb(245 158 11 / 0.32); + background: rgb(245 158 11 / 0.08); + color: var(--warning); + } + .active-work-detail { + min-width: 0; + display: grid; + gap: 7px; + justify-items: end; + } + .active-work-detail code { + max-width: 100%; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-2); + color: var(--muted); + padding: 6px 8px; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + } + .active-work-pr { + max-width: 100%; + display: inline-flex; + align-items: center; + gap: 6px; + overflow: hidden; + border: 1px solid rgb(16 185 129 / 0.28); + border-radius: 999px; + background: rgb(16 185 129 / 0.1); + color: var(--success); + padding: 5px 8px; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 700; + } + .active-work-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + } + .active-work-actions button { + min-height: 30px; + padding: 0 10px; + font-size: 12px; + } + .active-work-pr-form { + width: min(100%, 420px); + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 6px; + align-items: center; + } + .active-work-pr-form input { + min-height: 32px; + padding: 7px 9px; + font-size: 12px; + } + .active-work-pr-form button { + min-height: 32px; + padding: 0 10px; + font-size: 12px; + white-space: nowrap; + } + .active-run-log { + grid-column: 1 / -1; + display: grid; + gap: 10px; + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: rgb(2 6 23 / 0.5); + padding: 10px; + } + .active-run-log-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 10px; + } + .active-run-log-head > div:first-child { + min-width: 0; + display: grid; + gap: 4px; + } + .active-run-log-head strong { + min-width: 0; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 12px; + } + .active-run-log-actions, + .active-run-log-meta { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 6px; + } + .active-run-log-actions button { + min-height: 30px; + padding: 0 9px; + font-size: 12px; + } + .active-run-log-meta { + justify-content: flex-start; + } + .active-run-log-meta span { + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel); + color: var(--muted); + padding: 4px 8px; + font-size: 11px; + font-weight: 700; + } + .active-run-log-stream { + display: grid; + gap: 6px; + max-height: 310px; + overflow: auto; + overscroll-behavior: contain; + border: 1px solid var(--line); + border-radius: 8px; + background: #05070d; + padding: 8px; + } + .run-log-entry { + display: grid; + gap: 5px; + border-left: 3px solid var(--line-strong); + border-radius: 6px; + background: rgb(255 255 255 / 0.03); + padding: 7px 8px; + } + .run-log-entry.ok { + border-left-color: var(--success); + } + .run-log-entry.warn { + border-left-color: var(--warning); + } + .run-log-entry.danger { + border-left-color: #f87171; + } + .run-log-entry-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + } + .run-log-entry-head time { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + text-transform: none; + } + .run-log-entry pre { + margin: 0; + overflow: auto; + color: #dbe4f0; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + } + .active-run-log .terminal-command { + width: 100%; + justify-content: flex-start; + overflow: hidden; + border-color: var(--line); + background: var(--panel); + } + .active-run-log .terminal-command code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .openclaw-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 0.42fr); + gap: 16px; + align-items: start; + } + .openclaw-main, + .openclaw-side, + .workflow-section { + min-width: 0; + } + .workflow-section { + display: grid; + gap: 12px; + } + .workflow-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .queue-block, + .pr-row { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 14px; + } + .queue-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + } + .queue-head-actions { + display: inline-flex; + align-items: center; + gap: 7px; + } + .queue-head-actions .icon-only, + .pr-row-head .icon-only { + width: 34px; + padding: 0; + } + .queue-head h3 { + margin: 0; + color: var(--ink); + font-size: 15px; + font-weight: 720; + } + .queue-head p, + .pr-detail { + margin: 4px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .query-line { + min-width: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-2); + color: var(--muted); + padding: 7px 9px; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + } + .candidate-list, + .pr-list { + display: grid; + gap: 8px; + } + .candidate-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 74px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-2); + padding: 10px; + } + .candidate-main { + min-width: 0; + display: grid; + gap: 6px; + } + .candidate-main a, + .candidate-main .text-link, + .pr-row-head a, + .pr-row-head .text-link { + min-width: 0; + overflow: hidden; + color: var(--ink); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + } + .candidate-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + .candidate-actions { + display: inline-flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + max-width: 560px; + } + .candidate-actions button.icon-only { + width: 36px; + padding: 0; + } + .candidate-actions button { + white-space: nowrap; + } + button.danger-subtle { + border-color: rgb(239 68 68 / 0.34); + background: rgb(239 68 68 / 0.08); + color: #fca5a5; + } + button.danger-subtle:hover { + border-color: rgb(239 68 68 / 0.52); + background: rgb(239 68 68 / 0.14); + } + .chip.ok { + border-color: rgb(16 185 129 / 0.28); + background: rgb(16 185 129 / 0.12); + color: var(--success); + } + .chip.warn { + border-color: rgb(245 158 11 / 0.32); + background: rgb(245 158 11 / 0.1); + color: var(--warning); + } + .chip.danger { + border-color: rgb(239 68 68 / 0.34); + background: rgb(239 68 68 / 0.1); + color: #f87171; + } + .pr-monitor { + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 14px; + } + .specific-issue-panel { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--dashboard-surface); + padding: 14px; + } + .specific-issue-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; + } + .specific-issue-form button { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + white-space: nowrap; + } + .specific-issue-row { + background: var(--panel); + } + .coverage-warning { + display: flex; + align-items: flex-start; + gap: 7px; + max-width: 680px; + border: 1px solid rgb(239 68 68 / 0.28); + border-radius: 8px; + background: rgb(239 68 68 / 0.08); + color: var(--muted); + padding: 8px 10px; + font-size: 12px; + line-height: 1.45; + } + .coverage-warning svg { + flex: 0 0 auto; + width: 15px; + height: 15px; + color: #f87171; + } + .coverage-warning a, + .coverage-warning .text-link { + color: #fb7185; + font-weight: 750; + } + .pr-row { + background: var(--panel); + } + .pr-row-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + } + .handoff-box { + margin-top: 4px; + } + .handoff-box textarea { + min-height: 150px; + font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + } + + @keyframes infraDrift { + 0%, + 100% { + transform: translate(-50%, -50%) rotateX(56deg) rotateZ(-38deg) translateY(0); + } + 50% { + transform: translate(-50%, -50%) rotateX(56deg) rotateZ(-38deg) translateY(-10px); + } + } + + @keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .drawer.open { + animation: fadeIn 0.2s ease; + } + + .drawer.open .panel { + animation: slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + + @media (max-width: 1320px) { + .top { + grid-template-columns: 1fr; + } + .status-strip { + min-width: 0; + } + .dashboard-grid { + grid-template-columns: 1fr; + } + .openclaw-grid { + grid-template-columns: 1fr; + } + .openclaw-settings { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .openclaw-runner { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .openclaw-command-center { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "main" + "actions" + "gates"; + } + .command-actions { + grid-template-columns: repeat(4, minmax(112px, max-content)); + justify-content: start; + } + .command-mode-controls { + grid-template-columns: repeat(2, minmax(156px, max-content)); + } + .active-work-mission { + grid-template-columns: minmax(0, 1fr) minmax(220px, 0.7fr); + } + .mission-actions { + grid-column: 1 / -1; + grid-template-columns: repeat(2, minmax(0, max-content)); + justify-content: start; + } + .openclaw-settings button, + .runner-actions, + .active-work-tools { + grid-column: 1 / -1; + } + .runner-actions, + .active-work-tools { + justify-content: flex-start; + } + .fleet-control-panel { + grid-template-columns: 1fr; + } + .board { + grid-template-columns: repeat(2, minmax(248px, 1fr)); + } + .admin-grid { grid-template-columns: 1fr; } } @@ -1984,6 +3604,14 @@ min-width: 0; margin-right: auto; } + .nav-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); + width: 100%; + } + .nav-actions button { + width: 100%; + } .shell { width: calc(100vw - 32px); padding: 18px 0 32px; @@ -2011,6 +3639,12 @@ } .toolbar, .status-strip, + .openclaw-toolbar, + .openclaw-summary, + .openclaw-settings, + .openclaw-runner, + .active-work-row, + .candidate-row, .fleet-control-grid, .dev-identity-panel, .board, @@ -2019,6 +3653,107 @@ .session-head { grid-template-columns: 1fr; } + .openclaw-active-work .workflow-section-head { + display: grid; + grid-template-columns: 1fr; + align-items: start; + } + .active-run-log-head { + display: grid; + grid-template-columns: 1fr; + } + .active-run-log-actions { + justify-content: flex-start; + } + .command-title-row { + align-items: start; + } + .command-title-row h2 { + white-space: normal; + } + .command-actions, + .command-gates { + grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); + } + .command-mode-controls { + grid-template-columns: 1fr; + } + .command-actions button { + width: 100%; + } + .openclaw-worker-runway .workflow-section-head { + display: grid; + grid-template-columns: 1fr; + align-items: start; + } + .runway-head-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); + justify-content: stretch; + width: 100%; + } + .runway-head-actions .chip, + .runway-head-actions button { + width: 100%; + } + .worker-lanes, + .worker-lane-actions { + grid-template-columns: 1fr; + } + .active-work-mission, + .mission-actions { + grid-template-columns: 1fr; + } + .mission-stack button span:last-child { + flex-wrap: wrap; + white-space: normal; + } + .active-work-tools { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(108px, 1fr)); + justify-content: stretch; + width: 100%; + } + .active-work-tools .chip, + .active-work-tools button { + width: 100%; + } + .candidate-actions { + display: flex; + flex-wrap: wrap; + max-width: none; + width: 100%; + } + .candidate-actions button { + flex: 1 1 128px; + min-width: 118px; + width: auto; + } + .candidate-actions button.icon-only { + flex: 0 0 40px; + width: 40px; + min-width: 40px; + } + .specific-issue-form { + grid-template-columns: 1fr; + } + .candidate-actions .candidate-select, + .worker-lane-actions .candidate-select { + width: 100%; + } + .active-work-detail { + justify-items: stretch; + } + .active-work-actions { + justify-content: stretch; + } + .active-work-actions button { + flex: 1 1 auto; + } + .active-work-pr-form { + width: 100%; + grid-template-columns: 1fr; + } .session-grid { grid-template-columns: 1fr; --session-tile-height: 430px; diff --git a/src/app/main.jsx b/src/app/main.jsx index f2c76ffd..005cc778 100644 --- a/src/app/main.jsx +++ b/src/app/main.jsx @@ -1,6 +1,15 @@ -import { render } from "preact"; +import { Fragment, render } from "preact"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { api } from "./api.js"; +import { + buildOpenClawIssuePrompt, + openClawCandidatePriorityLabel, + openClawCandidatePriorityRank, + openClawProofModeLabel, + openClawProofModes, + openClawReasoningEffortLabel, + openClawReasoningEfforts, +} from "../openclaw-workflow.ts"; import { canMaintain, canOwn, @@ -38,6 +47,13 @@ const loginReturnKey = "crabbox-login-return"; const skipAutoGithubLoginKey = "crabbox-skip-auto-github-login"; const githubAutoLoginReadyKey = "crabbox-github-auto-login-ready"; const sessionLayoutStorageKey = "crabbox-session-layout-v1"; +const clawQueuePath = "/app/claw-queue"; +const clawLoopPath = "/app/claw-loop"; +const openClawRunnerUrlStorageKey = "crabbox-openclaw-runner-url"; +const openClawRunnerTokenStorageKey = "crabbox-openclaw-runner-token"; +const openClawRejectedIssuesStorageKey = "crabbox-openclaw-rejected-issues-v1"; +const openClawVisibleQueueCandidateLimit = 6; +const defaultOpenClawRunnerUrl = "http://127.0.0.1:4545"; const emptyState = { cards: [], interactiveSessions: [], @@ -97,6 +113,15 @@ function App() { ); const [sessionLayout, setSessionLayout] = useState(loadSessionLayout); const [terminalStatus, setTerminalStatus] = useState({}); + const [githubBlade, setGithubBlade] = useState(null); + const [openClawState, setOpenClawState] = useState({ + loading: false, + data: null, + error: "", + handoffText: "", + }); + const [openClawRunner, setOpenClawRunner] = useState(loadOpenClawRunnerSettings); + const [openClawRejectedIssues, setOpenClawRejectedIssues] = useState(loadOpenClawRejectedIssues); const stateRef = useRef(state); const authMethodsRef = useRef(authMethods); const signedInRef = useRef(signedIn); @@ -104,9 +129,12 @@ function App() { const focusedSessionIdRef = useRef(focusedSessionId); const drawersRef = useRef(drawers); const sharedRef = useRef({ id: sharedSessionId, token: sharedToken }); + const githubBladeRef = useRef(githubBlade); const stateRetryTimer = useRef(null); const refPreviewTimer = useRef(null); const refPreviewSeq = useRef(0); + const openClawSeq = useRef(0); + const openClawRunnerAutoChecked = useRef(false); const draggedSessionId = useRef(null); const autoLoginStarted = useRef(false); @@ -123,6 +151,7 @@ function App() { focusedSessionIdRef.current = focusedSessionId; drawersRef.current = drawers; sharedRef.current = { id: sharedSessionId, token: sharedToken }; + githubBladeRef.current = githubBlade; useEffect(() => { void loadState(); @@ -190,6 +219,41 @@ function App() { void openInitialSessionLink(); }, [sharedSessionId, signedIn, state.interactiveSessions]); + useEffect(() => { + if (!signedIn || !["openclaw", "loop"].includes(appView)) return; + void loadOpenClawWorkflow(); + }, [signedIn, appView]); + + useEffect(() => { + if (!signedIn || !["openclaw", "loop"].includes(appView) || openClawRunner.status !== "unknown") + return; + if (!openClawRunner.saved) return; + if (openClawRunnerAutoChecked.current) return; + openClawRunnerAutoChecked.current = true; + void checkOpenClawRunner(openClawRunner).catch(() => {}); + }, [ + signedIn, + appView, + openClawRunner.status, + openClawRunner.url, + openClawRunner.token, + openClawRunner.saved, + ]); + + useEffect(() => { + if ( + !signedIn || + !["openclaw", "loop"].includes(appView) || + openClawRunner.status !== "connected" + ) + return; + void refreshOpenClawRunnerRuns().catch(() => {}); + const interval = setInterval(() => { + void refreshOpenClawRunnerRuns().catch(() => {}); + }, 10000); + return () => clearInterval(interval); + }, [signedIn, appView, openClawRunner.status, openClawRunner.url, openClawRunner.token]); + async function loadState() { try { const nextState = await api("/api/state", { authOptional: true }); @@ -418,17 +482,35 @@ function App() { } function setAppView(value) { - const next = value === "board" ? "board" : "fleet"; + const next = + value === "board" + ? "board" + : value === "openclaw" + ? "openclaw" + : value === "loop" + ? "loop" + : "fleet"; setAppViewState(next); closeAllDrawers(); if (!history.pushState) return; const url = new URL(location.href); - url.pathname = next === "board" ? "/app/board" : "/app/fleet"; + url.pathname = + next === "board" + ? "/app/board" + : next === "openclaw" + ? clawQueuePath + : next === "loop" + ? clawLoopPath + : "/app/fleet"; url.search = ""; history.pushState(null, "", url); } function closeTopDrawer() { + if (githubBladeRef.current) { + setGithubBlade(null); + return true; + } const order = ["card", "interactive", "run", "sessions", "admin"]; const id = order.findLast((key) => drawers[key]); if (!id) return false; @@ -468,11 +550,350 @@ function App() { return; } const url = new URL(location.href); - url.pathname = options.grid ? "/sessions" : appView === "board" ? "/app/board" : "/app/fleet"; + url.pathname = options.grid + ? "/sessions" + : appView === "board" + ? "/app/board" + : appView === "openclaw" + ? clawQueuePath + : appView === "loop" + ? clawLoopPath + : "/app/fleet"; url.search = ""; history.replaceState(null, "", url); } + async function loadOpenClawWorkflow(params = {}) { + const seq = ++openClawSeq.current; + const query = new URLSearchParams(); + if (params.repo) query.set("repo", params.repo); + if (params.login) query.set("login", params.login); + const rejected = openClawRejectedIssueNumbersForRepo( + openClawRejectedIssues, + params.repo || openClawState.data?.repo || openClawState.data?.preferences?.targetRepo, + ); + if (rejected.length) query.set("rejected", rejected.join(",")); + const queryText = query.toString(); + setOpenClawState((current) => ({ ...current, loading: true, error: "" })); + try { + const data = await api(`/api/openclaw/workflow${queryText ? `?${queryText}` : ""}`); + if (seq !== openClawSeq.current) return; + const hydrated = params.skipLocalCoverage + ? data + : await hydrateOpenClawWorkflowWithLocalCoverage(data, openClawRunner).catch(() => data); + if (seq !== openClawSeq.current) return; + setOpenClawState((current) => ({ ...current, loading: false, data: hydrated, error: "" })); + } catch (error) { + if (seq !== openClawSeq.current) return; + setOpenClawState((current) => ({ + ...current, + loading: false, + error: error.message || "Claw Queue failed to load", + })); + } + } + + async function updateOpenClawPreferences(preferences) { + const result = await api("/api/openclaw/preferences", { + method: "PUT", + body: preferences, + }); + setOpenClawState((current) => ({ + ...current, + data: current.data ? { ...current.data, preferences: result.preferences } : current.data, + })); + await loadOpenClawWorkflow({ + repo: result.preferences?.targetRepo, + login: result.preferences?.githubLogin, + }); + } + + async function createOpenClawCandidateCard(candidate, codexReasoningEffort, proofMode) { + const prompt = openClawCandidatePrompt(candidate, codexReasoningEffort, proofMode); + await api("/api/cards", { + method: "POST", + body: { + title: `OpenClaw #${candidate.number}: ${candidate.title}`, + prompt, + repo: candidate.repo || openClawState.data?.repo || "openclaw/openclaw", + source: "Issue", + runtime: "auto", + policy: "open_pr", + }, + }); + await loadState(); + } + + async function copyOpenClawCandidatePrompt(candidate, codexReasoningEffort, proofMode) { + await copyText(openClawCandidatePrompt(candidate, codexReasoningEffort, proofMode)); + } + + async function claimOpenClawIssueWork(candidate, runner) { + const response = await fetch(`${resolveRunnerUrl(runner.url)}/claim`, { + method: "POST", + headers: { + ...openClawRunnerHeaders(runner), + "content-type": "application/json", + }, + body: JSON.stringify({ + repo: candidate.repo || openClawState.data?.repo || "openclaw/openclaw", + issueNumber: candidate.number, + issueUrl: candidate.url, + title: candidate.title, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.ok === false) { + throw new Error(body.error || `Claim comment failed with ${response.status}`); + } + return body.claim || null; + } + + function updateOpenClawRunnerSettings(next) { + const settings = { + ...openClawRunner, + ...next, + url: next.url === undefined ? openClawRunner.url : String(next.url), + token: String(next.token ?? openClawRunner.token ?? ""), + }; + try { + localStorage.setItem(openClawRunnerUrlStorageKey, settings.url); + localStorage.setItem(openClawRunnerTokenStorageKey, settings.token); + } catch {} + setOpenClawRunner(settings); + return settings; + } + + async function checkOpenClawRunner(settings = openClawRunner) { + const target = updateOpenClawRunnerSettings(settings); + setOpenClawRunner((current) => ({ ...current, status: "checking", error: "" })); + try { + const response = await fetch(`${resolveRunnerUrl(target.url)}/health`, { + headers: openClawRunnerHeaders(target), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.ok !== true || body.runner !== "claw-queue-codex-bridge") { + throw new Error(body.error || `Runner returned ${response.status}`); + } + const runs = await fetchOpenClawRunnerRuns(target); + setOpenClawRunner((current) => ({ + ...current, + status: "connected", + error: "", + info: body, + runs, + runsError: "", + })); + const currentWorkflow = openClawState.data; + if (currentWorkflow) { + void hydrateOpenClawWorkflowWithLocalCoverage(currentWorkflow, { + ...target, + status: "connected", + }) + .then((data) => { + setOpenClawState((latest) => + latest.data === currentWorkflow ? { ...latest, data } : latest, + ); + }) + .catch(() => {}); + } + return body; + } catch (error) { + setOpenClawRunner((current) => ({ + ...current, + status: "error", + error: error.message || "Could not reach local Codex runner", + })); + throw error; + } + } + + async function startOpenClawCodexWork(candidate, codexReasoningEffort, proofMode) { + if (!openClawState.data?.governor?.canStartNewWork) { + throw new Error("New work is paused by the current OpenClaw limits"); + } + const target = openClawRunner; + setOpenClawRunner((current) => ({ + ...current, + startingIssue: candidate.number, + error: "", + })); + try { + const prompt = openClawCandidatePrompt( + candidate, + codexReasoningEffort, + proofMode, + "bridge-managed", + ); + const response = await fetch(`${resolveRunnerUrl(target.url)}/start`, { + method: "POST", + headers: { + ...openClawRunnerHeaders(target), + "content-type": "application/json", + }, + body: JSON.stringify({ + repo: candidate.repo || openClawState.data?.repo || "openclaw/openclaw", + issueNumber: candidate.number, + issueUrl: candidate.url, + title: candidate.title, + queueId: candidate.queueId, + codexReasoningEffort: openClawNormalizeReasoningEffort(codexReasoningEffort), + proofMode: openClawNormalizeProofMode(proofMode), + claimIssue: true, + prompt, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.ok === false) { + throw new Error(body.error || `Runner returned ${response.status}`); + } + const runs = await fetchOpenClawRunnerRuns(target).catch(() => + body.run ? [body.run, ...(openClawRunner.runs || [])] : openClawRunner.runs || [], + ); + setOpenClawRunner((current) => ({ + ...current, + status: "connected", + startingIssue: null, + lastRun: body.run || body, + runs, + runsError: "", + })); + return body; + } catch (error) { + setOpenClawRunner((current) => ({ + ...current, + startingIssue: null, + error: error.message || "Could not start local Codex", + })); + throw error; + } + } + + async function trackOpenClawCandidateWork(candidate, codexReasoningEffort, proofMode) { + const target = openClawRunner; + setOpenClawRunner((current) => ({ + ...current, + trackingIssue: candidate.number, + error: "", + })); + try { + const prompt = openClawCandidatePrompt(candidate, codexReasoningEffort, proofMode); + const hasPossiblePrCoverage = openClawCandidateHasPrCoverage(candidate); + const coverageUnknown = openClawCandidateCoverageUnknown(candidate); + const skipClaimForCoverage = hasPossiblePrCoverage || coverageUnknown; + const claim = skipClaimForCoverage ? null : await claimOpenClawIssueWork(candidate, target); + const response = await fetch(`${resolveRunnerUrl(target.url)}/track`, { + method: "POST", + headers: { + ...openClawRunnerHeaders(target), + "content-type": "application/json", + }, + body: JSON.stringify({ + repo: candidate.repo || openClawState.data?.repo || "openclaw/openclaw", + issueNumber: candidate.number, + issueUrl: candidate.url, + title: candidate.title, + queueId: candidate.queueId, + codexReasoningEffort: openClawNormalizeReasoningEffort(codexReasoningEffort), + proofMode: openClawNormalizeProofMode(proofMode), + source: "manual", + prompt, + note: skipClaimForCoverage + ? "Tracked as a PR coverage investigation. No issue claim was posted." + : "Tracked for copy/paste, tmux, or external Codex work.", + claimCommentUrl: claim?.url || null, + claimCommentStatus: claim?.status || null, + }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.ok === false) { + throw new Error(body.error || `Runner returned ${response.status}`); + } + if (body.run && claim?.status) { + body.run = { + ...body.run, + claimCommentUrl: claim.url || body.run.claimCommentUrl || null, + claimCommentStatus: claim.status, + }; + } + const runs = await fetchOpenClawRunnerRuns(target).catch(() => + body.run ? [body.run, ...(openClawRunner.runs || [])] : openClawRunner.runs || [], + ); + setOpenClawRunner((current) => ({ + ...current, + status: "connected", + trackingIssue: null, + lastRun: body.run || body, + runs, + runsError: "", + })); + return body; + } catch (error) { + setOpenClawRunner((current) => ({ + ...current, + trackingIssue: null, + error: error.message || "Could not track local Codex work", + })); + throw error; + } + } + + async function updateOpenClawRunnerRun(run, patch) { + const target = openClawRunner; + const response = await fetch( + `${resolveRunnerUrl(target.url)}/runs/${encodeURIComponent(run.id)}`, + { + method: "PATCH", + headers: { + ...openClawRunnerHeaders(target), + "content-type": "application/json", + }, + body: JSON.stringify(patch), + }, + ); + const body = await response.json().catch(() => ({})); + if (!response.ok || body.ok === false) { + throw new Error(body.error || `Runner returned ${response.status}`); + } + const runs = await fetchOpenClawRunnerRuns(target).catch(() => + (openClawRunner.runs || []).map((item) => (item.id === body.run?.id ? body.run : item)), + ); + setOpenClawRunner((current) => ({ ...current, runs, runsError: "" })); + return body.run; + } + + async function refreshOpenClawRunnerRuns(settings = openClawRunner) { + const target = settings; + setOpenClawRunner((current) => ({ ...current, runsLoading: true, runsError: "" })); + try { + const runs = await fetchOpenClawRunnerRuns(target); + setOpenClawRunner((current) => ({ + ...current, + status: current.status === "error" ? "connected" : current.status, + runs, + runsLoading: false, + runsError: "", + })); + return runs; + } catch (error) { + setOpenClawRunner((current) => ({ + ...current, + runsLoading: false, + runsError: error.message || "Could not load local Codex runs", + })); + throw error; + } + } + + async function createOpenClawHandoff(input) { + const result = await api("/api/openclaw/handoff", { + method: "POST", + body: input, + }); + setOpenClawState((current) => ({ ...current, handoffText: result.text || "" })); + return result.text || ""; + } + function setTheme(value) { setThemeState(value === "light" ? "light" : "dark"); } @@ -543,7 +964,10 @@ function App() { function preserveLoginReturnUrl() { try { - if (sharedRef.current.id) sessionStorage.setItem(loginReturnKey, location.href); + const url = new URL(location.href); + if (sharedRef.current.id || isLoginReturnUrl(url)) { + sessionStorage.setItem(loginReturnKey, url.href); + } } catch {} } @@ -807,6 +1231,44 @@ function App() { }); } + function rejectOpenClawIssue(candidate, reason = "") { + if (!candidate?.number) return; + const fallbackReason = openClawCandidateHasPrCoverage(candidate) + ? "Linked/open PR exists" + : "Not suitable for this queue"; + const note = window.prompt( + "Reject this issue from your local Claw Queue?", + reason || fallbackReason, + ); + if (note === null) return; + const key = openClawRejectedIssueKey(candidate); + if (!key) return; + setOpenClawRejectedIssues((current) => { + const next = { + ...current, + [key]: { + key, + number: candidate.number, + title: candidate.title || "", + url: candidate.url || "", + reason: note.trim() || fallbackReason, + rejectedAt: Date.now(), + }, + }; + saveOpenClawRejectedIssues(next); + return next; + }); + } + + function restoreOpenClawIssue(number) { + setOpenClawRejectedIssues((current) => { + const next = { ...current }; + delete next[String(number)]; + saveOpenClawRejectedIssues(next); + return next; + }); + } + const props = { state, appView, @@ -863,6 +1325,25 @@ function App() { removeRepo, refreshWorkflow, updatePolicy, + openClawState, + openClawRejectedIssues, + rejectOpenClawIssue, + restoreOpenClawIssue, + openClawRunner, + loadOpenClawWorkflow, + updateOpenClawPreferences, + createOpenClawCandidateCard, + copyOpenClawCandidatePrompt, + updateOpenClawRunnerSettings, + checkOpenClawRunner, + refreshOpenClawRunnerRuns, + trackOpenClawCandidateWork, + updateOpenClawRunnerRun, + startOpenClawCodexWork, + createOpenClawHandoff, + githubBlade, + openGithubBlade: (target) => setGithubBlade(openGithubBladeTarget(target)), + closeGithubBlade: () => setGithubBlade(null), }; return ; @@ -898,6 +1379,7 @@ function CrabfleetApp(props) { + ); } @@ -1065,6 +1547,24 @@ function AppShell(props) { Board + + -
+
-

{props.appView === "board" ? "Board" : productName}

+

+ {props.appView === "board" + ? "Board" + : props.appView === "openclaw" + ? "Claw Queue" + : props.appView === "loop" + ? "Master Loop" + : productName} +

{props.appView === "board" ? "Prompt cards and run attempts, separated from the live crabbox fleet." - : "All visible Codex crabboxes grouped by person, with SSH, WebVNC, and OpenClaw supervision."} + : props.appView === "openclaw" + ? "ClawSweeper-screened issues, Codex handoff, PR readiness, and maintainer review notes." + : props.appView === "loop" + ? "Full-width swimlanes for watching queue candidates, active Codex plates, PR state, proof, CI, and maintainer handoff." + : "All visible Codex crabboxes grouped by person, with SSH, WebVNC, and OpenClaw supervision."}

+ ))} + + + + +
+ + + ); +} + +function OpenClawPage(props) { + const workflow = props.openClawState.data; + const preferences = workflow?.preferences; + const governor = workflow?.governor; + const queues = filterOpenClawRejectedQueues(workflow?.queues || [], props.openClawRejectedIssues); + const pullRequests = workflow?.pullRequests?.items || []; + const [actionError, setActionError] = useState(""); + const [busyIssue, setBusyIssue] = useState(null); + const [reasoningOverrides, setReasoningOverrides] = useState({}); + const [proofOverrides, setProofOverrides] = useState({}); + const [specificIssue, setSpecificIssue] = useState({ + input: "", + loading: false, + error: "", + result: null, + }); + const canCreate = canMaintain(props.user) && Boolean(governor?.canStartNewWork); + const canStartWork = Boolean(governor?.canStartNewWork); + const defaultReasoningEffort = openClawNormalizeReasoningEffort( + preferences?.codexReasoningEffort, + ); + function candidateReasoningEffort(candidate) { + const key = openClawIssueKey(candidate?.number, candidate?.url); + return openClawNormalizeReasoningEffort( + key ? reasoningOverrides[key] : null, + defaultReasoningEffort, + ); + } + function handleReasoningChange(candidate, value) { + const key = openClawIssueKey(candidate?.number, candidate?.url); + if (!key) return; + setReasoningOverrides((current) => ({ + ...current, + [key]: openClawNormalizeReasoningEffort(value, defaultReasoningEffort), + })); + } + function candidateProofMode(candidate) { + const key = openClawIssueKey(candidate?.number, candidate?.url); + return openClawNormalizeProofMode( + key ? proofOverrides[key] : null, + candidate?.signals?.needsLiveValidation ? "crabbox" : "auto", + ); + } + function handleProofModeChange(candidate, value) { + const key = openClawIssueKey(candidate?.number, candidate?.url); + if (!key) return; + setProofOverrides((current) => ({ + ...current, + [key]: openClawNormalizeProofMode(value, candidateProofMode(candidate)), + })); + } + async function handleLoadSpecificIssue(input = specificIssue.input) { + const value = String(input || "").trim(); + if (!value) { + setSpecificIssue((current) => ({ + ...current, + error: "Paste a GitHub issue URL, #number, or number first.", + result: null, + })); + return; + } + setSpecificIssue((current) => ({ + ...current, + input: value, + loading: true, + error: "", + result: null, + })); + try { + const query = new URLSearchParams({ input: value }); + if (workflow?.repo) query.set("repo", workflow.repo); + const result = await api(`/api/openclaw/issue?${query.toString()}`); + const hydrated = await hydrateOpenClawSpecificIssueWithLocalCoverage( + result, + props.openClawRunner, + ).catch(() => result); + setSpecificIssue({ + input: value, + loading: false, + error: hydrated.coverageError || "", + result: { + ...hydrated, + candidate: hydrated.candidate ? { ...hydrated.candidate, repo: hydrated.repo } : null, + }, + }); + } catch (error) { + setSpecificIssue((current) => ({ + ...current, + loading: false, + error: error.message || "Could not load issue", + result: null, + })); + } + } + async function handleTrackCandidate( + candidate, + codexReasoningEffort = candidateReasoningEffort(candidate), + proofMode = candidateProofMode(candidate), + ) { + setActionError(""); + try { + await props.trackOpenClawCandidateWork(candidate, codexReasoningEffort, proofMode); + } catch (error) { + setActionError(error.message || "Could not track local Codex work"); + } + } + async function handleStartCandidate( + candidate, + codexReasoningEffort = candidateReasoningEffort(candidate), + proofMode = candidateProofMode(candidate), + ) { + setActionError(""); + try { + await props.startOpenClawCodexWork(candidate, codexReasoningEffort, proofMode); + } catch (error) { + setActionError(error.message || "Could not start local Codex"); + } + } + async function handleCopyCandidate( + candidate, + codexReasoningEffort = candidateReasoningEffort(candidate), + proofMode = candidateProofMode(candidate), + ) { + await props.copyOpenClawCandidatePrompt(candidate, codexReasoningEffort, proofMode); + } + async function handleCreateCandidate( + candidate, + codexReasoningEffort = candidateReasoningEffort(candidate), + proofMode = candidateProofMode(candidate), + ) { + setActionError(""); + setBusyIssue(candidate.number); + try { + await props.createOpenClawCandidateCard(candidate, codexReasoningEffort, proofMode); + } catch (error) { + setActionError(error.message || "Could not create card"); + } finally { + setBusyIssue(null); + } + } + return ( +
+
+
+
WORK QUEUE
+

{workflow?.repo || "openclaw/openclaw"}

+
+ +
+ {props.openClawState.error ? ( +
{props.openClawState.error}
+ ) : null} + {actionError ?
{actionError}
: null} + {workflow?.localCoverage?.source === "gitcrawl" ? ( +
+ Local Gitcrawl queue snapshot active + {workflow.localCoverage.lastSyncAt + ? `; last sync ${formatOpenClawTimestamp(workflow.localCoverage.lastSyncAt)}` + : ""} + {workflow.localCoverage.coveredIssueCount + ? `; ${workflow.localCoverage.coveredIssueCount} displayed issue${workflow.localCoverage.coveredIssueCount === 1 ? "" : "s"} flagged with possible PR coverage` + : ""} + . +
+ ) : null} +
+ + + + +
+ {governor?.reasons?.length ? ( +
{governor.reasons.join(" ")}
+ ) : null} + + setSpecificIssue((current) => ({ ...current, input: value }))} + onLoad={handleLoadSpecificIssue} + onReasoningChange={handleReasoningChange} + onProofModeChange={handleProofModeChange} + onCopyPrompt={handleCopyCandidate} + onTrack={handleTrackCandidate} + onStartCodex={handleStartCandidate} + onCreate={handleCreateCandidate} + onOpenGithubBlade={props.openGithubBlade} + /> + + + + +
+
+
+
+
+
QUEUES
+

ClawSweeper-screened issues

+
+
+ {queues.length ? ( + queues.map((queue) => ( + props.loadOpenClawWorkflow({ skipLocalCoverage: true })} + onOpenGithubBlade={props.openGithubBlade} + /> + )) + ) : ( +
No queue data loaded.
+ )} +
+
+ +
+ +
+ ); +} + +function OpenClawLoopPage(props) { + const workflow = props.openClawState.data; + const preferences = workflow?.preferences; + const governor = workflow?.governor; + const queues = filterOpenClawRejectedQueues(workflow?.queues || [], props.openClawRejectedIssues); + const pullRequests = workflow?.pullRequests?.items || []; + return ( +
+
+
+
MASTER LOOP
+

{workflow?.repo || "openclaw/openclaw"}

+
+ +
+ {props.openClawState.error ? ( +
{props.openClawState.error}
+ ) : null} +
+ + + + +
+ + +
+ ); +} + +function OpenClawPreferencesPanel({ preferences, loading, onSave }) { + const [draft, setDraft] = useState(() => openClawPreferenceDraft(preferences)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + setDraft(openClawPreferenceDraft(preferences)); + }, [ + preferences?.updatedAt, + preferences?.targetRepo, + preferences?.githubLogin, + preferences?.minimumIssueAgeHours, + preferences?.codexReasoningEffort, + ]); + if (!preferences) return
Loading settings...
; + async function submit(event) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + const now = Date.now(); + const weeklyRemainingBaseline = nullableFormNumber(draft.weeklyRemainingBaseline); + const weeklyRemainingCurrent = nullableFormNumber(draft.weeklyRemainingCurrent); + const savedWeeklyRemainingBaseline = nullableFormNumber(preferences.weeklyRemainingBaseline); + const savedWeeklyRemainingCurrent = nullableFormNumber(preferences.weeklyRemainingCurrent); + const usageValuesChanged = + weeklyRemainingBaseline !== savedWeeklyRemainingBaseline || + weeklyRemainingCurrent !== savedWeeklyRemainingCurrent; + const usageValuesCleared = + weeklyRemainingBaseline === null && weeklyRemainingCurrent === null; + await onSave({ + roleMode: draft.roleMode, + targetRepo: draft.targetRepo, + githubLogin: draft.githubLogin, + activeOpenPrLimit: Number(draft.activeOpenPrLimit), + dailyUsageDropLimit: Number(draft.dailyUsageDropLimit), + maxParallelWorkers: Number(draft.maxParallelWorkers), + minimumIssueAgeHours: Number(draft.minimumIssueAgeHours), + codexReasoningEffort: draft.codexReasoningEffort, + weeklyRemainingBaseline, + weeklyRemainingCurrent, + usageWindowStartedAt: usageValuesCleared + ? null + : usageValuesChanged + ? now + : preferences.usageWindowStartedAt, + }); + } catch (error) { + setError(error.message || "Could not save Claw Queue settings"); + } finally { + setBusy(false); + } + } + const disabled = busy || loading; + return ( +
+ + + + + + + + + + + + {error ?
{error}
: null} +
+ ); +} + +function OpenClawRunnerPanel({ runner, preferences, onChange, onCheck }) { + const status = runner?.status || "unknown"; + const command = openClawRunnerCommand(runner, preferences); + const invalidToken = /missing or invalid runner token/i.test(String(runner?.error || "")); + async function clearRunnerToken() { + const next = onChange({ token: "" }); + await Promise.resolve(onCheck(next)).catch(() => {}); + } + async function generateRunnerToken() { + const next = onChange({ + token: openClawGenerateRunnerToken(), + status: "unknown", + error: "Token changed. Restart the local runner with Runner command, then click Test.", + }); + await copyText(openClawRunnerCommand(next, preferences)); + } + return ( +
+
+
CODEX HANDOFF
+

Local Codex bridge

+
+ + +
+ + {status} + + + + {invalidToken ? ( + + ) : null} + {invalidToken ? ( + + ) : null} +
+ {runner?.error ?
{runner.error}
: null} + {invalidToken ? ( +
+ The bridge is reachable, but the saved token does not match the running runner. If the + runner was started without a token, use Clear token. Otherwise use New token + command, + restart the runner with the copied command, then click Test. +
+ ) : null} + {runner?.info?.dryRun ? ( +
+ Dry-run bridge: Start Codex records prompts and active-work rows without launching Codex. +
+ ) : null} + {runner?.lastRun ? ( +
+ Started issue #{runner.lastRun.issueNumber || runner.lastRun.issue_number}:{" "} + {runner.lastRun.threadId || + runner.lastRun.sessionId || + runner.lastRun.id || + "runner accepted"} +
+ ) : null} +
+ ); +} + +function OpenClawSpecificIssuePanel({ + specificIssue, + runner, + canCreate, + canStartWork, + busyIssue, + reasoningEffort, + proofMode, + onInput, + onLoad, + onReasoningChange, + onProofModeChange, + onCopyPrompt, + onTrack, + onStartCodex, + onCreate, + onOpenGithubBlade, +}) { + const candidate = specificIssue?.result?.candidate || null; + const issueInWork = candidate + ? openClawRunnerHasIssueRun(runner, candidate.number, candidate.url) + : false; + const candidateEffort = candidate ? reasoningEffort(candidate) : "high"; + const candidateProof = candidate ? proofMode(candidate) : "auto"; + const hasPrCoverage = candidate ? openClawCandidateHasPrCoverage(candidate) : false; + const coverageUnknown = candidate ? openClawCandidateCoverageUnknown(candidate) : false; + return ( +
+
+
+
SPECIFIC ISSUE
+

Paste an issue to work from

+

Use a GitHub issue URL, #number, or number when the queue has not surfaced it.

+
+
+
{ + event.preventDefault(); + onLoad(); + }} + > + onInput(event.currentTarget.value)} + /> + +
+ {specificIssue?.error ? ( +
{specificIssue.error}
+ ) : null} + {candidate ? ( +
+
+ +
+ {candidate.author ? @{candidate.author} : null} + + {candidate.signals.readyForPickup ? "ready" : candidate.signals.ageGate} + + {candidate.signals.queueable ? queueable : null} + {candidate.signals.sourceRepro ? source repro : null} + {candidate.signals.needsLiveValidation ? ( + live proof + ) : null} + {hasPrCoverage ? possible PR : null} + {issueInWork ? in work : null} +
+ {hasPrCoverage ? ( + + ) : null} +
+
+ + + + + + + +
+
+ ) : null} +
+ ); +} + +function OpenClawCommandCenter({ + workflow, + workflowError, + queues, + runner, + preferences, + governor, + canCreate, + canStartWork, + busyIssue, + reasoningEffort, + onReasoningChange, + proofMode, + onProofModeChange, + onRefresh, + onCopyPrompt, + onTrack, + onStartCodex, + onCreate, + onOpenGithubBlade, +}) { + const plan = openClawCommandPlan({ + workflow, + workflowError, + queues, + runner, + preferences, + governor, + }); + const candidate = plan.candidate; + const run = plan.run; + const candidateEffort = candidate ? reasoningEffort(candidate) : "high"; + const candidateProof = candidate ? proofMode(candidate) : "auto"; + return ( +
+
+
COMMAND CENTER
+
+ +

{plan.headline}

+
+

{plan.detail}

+
+ {plan.subject ? {plan.subject} : null} + {plan.meta.map((item) => ( + {item} + ))} +
+
+
+ {run ? ( + <> + + + + + + ) : candidate ? ( + <> +
+ + +
+ + + + + + + ) : ( + <> + + + + )} +
+
+ {plan.gates.map((gate) => ( +
+ {gate.label} + {gate.value} + {gate.detail} +
+ ))} +
+
+ ); +} + +function OpenClawWorkerRunway({ + queues, + runner, + preferences, + canCreate, + canStartWork, + busyIssue, + reasoningEffort, + onReasoningChange, + proofMode, + onProofModeChange, + onCopyPrompt, + onTrack, + onStartCodex, + onCreate, + onOpenGithubBlade, +}) { + const runway = openClawWorkerRunway(queues, runner, preferences); + const [copied, setCopied] = useState(false); + async function copyPlan() { + await copyText(openClawWorkerRunwayBrief(runway, runner)); + setCopied(true); + setTimeout(() => setCopied(false), 1600); + } + return ( +
+
+
+
WORKER RUNWAY
+

Parallel plate planner

+

{runway.summary}

+
+
+ + {runway.filled}/{runway.capacity} filled + + + {runway.empty} open {runway.empty === 1 ? "lane" : "lanes"} + + +
+
+ {runway.bridgeWarning ?
{runway.bridgeWarning}
: null} +
+ {runway.lanes.map((lane) => ( + + ))} +
+
+ ); +} + +function OpenClawMasterLoopPanel({ + queues, + runner, + pullRequests, + governor, + rejected, + onReject, + onRestore, + onOpenGithubBlade, +}) { + const loop = openClawMasterLoopPlan({ queues, runner, pullRequests, governor }); + const openItemBlade = (item) => + onOpenGithubBlade({ + url: item.url, + title: item.title, + kind: githubKindFromUrl(item.url), + }); + return ( +
+
+
+
MASTER LOOP
+

Autopilot swimlanes

+

{loop.summary}

+
+
+ {loop.mode} + {loop.itemsTotal} visible items + +
+
+ {loop.blockers.length ?
{loop.blockers.join(" ")}
: null} +
+
+ + {loop.decision.action} +

{loop.decision.reason}

+
+
+ {loop.gates.map((gate) => ( + + {gate.label}: {gate.value} + + ))} +
+
+
+ {loop.lanes.map((lane) => ( +
+
+
+ {lane.title} + {lane.description} +
+ {lane.items.length} +
+
+ {lane.items.length ? ( + lane.items.slice(0, 4).map((item) => ( +
openItemBlade(item)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openItemBlade(item); + } + }} + > + {item.badge} + {item.title} + {item.detail} +
+ + {item.candidate ? ( + + ) : null} + +
+
+ )) + ) : ( +
No items.
+ )} +
+
+ ))} +
+ +
+ ); +} + +function OpenClawRejectedIssuesPanel({ rejected, onRestore, compact = false }) { + const items = Object.entries(rejected || {}) + .map(([key, item]) => ({ ...item, key: item.key || key })) + .sort((left, right) => Number(right.rejectedAt || 0) - Number(left.rejectedAt || 0)); + if (!items.length) return null; + const visibleItems = compact ? items.slice(0, 4) : items; + return ( +
+
+
+
LOCAL REJECTS
+

Hidden from your queue

+
+ {items.length} +
+
+ {visibleItems.map((item) => ( +
+
+ + #{item.number} {item.title || "OpenClaw issue"} + + {item.reason || "Rejected locally"} +
+ +
+ ))} +
+
+ ); +} + +function OpenClawWorkerLane({ + lane, + runner, + canCreate, + canStartWork, + busyIssue, + reasoningEffort, + onReasoningChange, + proofMode, + onProofModeChange, + onCopyPrompt, + onTrack, + onStartCodex, + onCreate, + onOpenGithubBlade, +}) { + const prUrl = openClawValidPrUrl(lane.run?.prUrl); + const priorityLabel = lane.candidate ? openClawCandidatePriorityLabel(lane.candidate) : null; + const candidateEffort = lane.candidate ? reasoningEffort(lane.candidate) : "high"; + const candidateProof = lane.candidate ? proofMode(lane.candidate) : "auto"; + return ( +
+
+ {lane.index} +
+ {lane.title} + {lane.subtitle} +
+
+ {lane.run ? ( +
+ +
+ {openClawRunLabel(lane.run)} + {lane.run.queueId ? {lane.run.queueId} : null} + {lane.run.codexReasoningEffort ? ( + thinking {lane.run.codexReasoningEffort} + ) : null} + {lane.run.proofMode ? proof {lane.run.proofMode} : null} + {prUrl ? PR linked : null} +
+

{openClawRunNextAction(lane.run)}

+
+ + + +
+
+ ) : lane.candidate ? ( +
+ +
+ eligible + {priorityLabel ? {priorityLabel} : null} + {lane.candidate.queueId} + {lane.candidate.signals.sourceRepro ? source repro : null} + {lane.candidate.signals.currentMainRepro ? ( + current main + ) : null} + {openClawCandidateHasPrCoverage(lane.candidate) ? ( + possible PR + ) : null} +
+ {openClawCandidateHasPrCoverage(lane.candidate) ? ( + + ) : null} +

{openClawCandidateWhy(lane.candidate)}

+
+ + + + + + + +
+
+ ) : ( +
+ +

No eligible queue item for this lane yet.

+
+ )} +
+ ); +} + +function OpenClawActiveWorkPanel({ runner, onRefresh, onUpdate, onOpenGithubBlade }) { + const runs = Array.isArray(runner?.runs) ? runner.runs : []; + const mission = openClawMissionControl(runs, runner); + const readinessRadar = openClawReadinessRadar(runs); + const activeCount = runs.filter((run) => openClawRunOpenPlate(run)).length; + const maxActive = runner?.info?.maxActive || "-"; + const readyCount = runs.filter((run) => run?.status === "ready").length; + const parkedCount = runs.filter((run) => run?.status === "parked").length; + const inactiveRuns = runs.filter((run) => openClawRunInactive(run)); + const [busyRun, setBusyRun] = useState(null); + const [actionError, setActionError] = useState(""); + const [prEditor, setPrEditor] = useState({ runId: null, value: "" }); + const [logViewer, setLogViewer] = useState({ + runId: null, + loading: false, + error: "", + log: null, + }); + const selectedLogRun = logViewer.runId + ? runs.find((run) => run.id === logViewer.runId) || null + : null; + const selectedLogRunState = selectedLogRun + ? `${selectedLogRun.id}:${selectedLogRun.status}:${selectedLogRun.updatedAt}:${selectedLogRun.finishedAt}` + : ""; + useEffect(() => { + if (!selectedLogRun || runner?.status !== "connected") return; + let cancelled = false; + async function refreshLog(silent = true) { + setLogViewer((current) => + current.runId === selectedLogRun.id + ? { ...current, loading: silent ? current.loading : true, error: "" } + : current, + ); + try { + const result = await fetchOpenClawRunnerRunLog(runner, selectedLogRun); + if (cancelled) return; + setLogViewer((current) => + current.runId === selectedLogRun.id + ? { ...current, loading: false, error: "", log: result.log } + : current, + ); + } catch (error) { + if (cancelled) return; + setLogViewer((current) => + current.runId === selectedLogRun.id + ? { + ...current, + loading: false, + error: error.message || "Could not load Codex log", + } + : current, + ); + } + } + void refreshLog(true); + if (!openClawRunActive(selectedLogRun)) { + return () => { + cancelled = true; + }; + } + const interval = setInterval(() => void refreshLog(true), 2500); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [selectedLogRunState, logViewer.runId, runner?.url, runner?.token, runner?.status]); + async function openRunLog(run, silent = false) { + setLogViewer((current) => ({ + runId: run.id, + loading: !silent, + error: "", + log: current.runId === run.id ? current.log : null, + })); + try { + const result = await fetchOpenClawRunnerRunLog(runner, run); + setLogViewer((current) => + current.runId === run.id + ? { ...current, loading: false, error: "", log: result.log } + : current, + ); + } catch (error) { + setLogViewer((current) => + current.runId === run.id + ? { ...current, loading: false, error: error.message || "Could not load Codex log" } + : current, + ); + } + } + async function update(run, patch) { + setActionError(""); + setBusyRun(run.id); + try { + await onUpdate(run, patch); + } catch (error) { + setActionError(error.message || "Could not update active work"); + throw error; + } finally { + setBusyRun(null); + } + } + async function archiveInactiveRuns() { + setActionError(""); + setBusyRun("archive-inactive"); + try { + for (const run of inactiveRuns) { + await onUpdate(run, { status: "archive" }); + } + } catch (error) { + setActionError(error.message || "Could not archive inactive work"); + } finally { + setBusyRun(null); + } + } + function editPr(run) { + setPrEditor({ runId: run.id, value: run?.prUrl || "" }); + } + async function savePr(event, run) { + event.preventDefault(); + const prUrl = openClawValidPrUrl(prEditor.value); + if (prEditor.value.trim() && !prUrl) { + setActionError("PR URL must be a https://github.com/owner/repo/pull/123 URL."); + return; + } + try { + await update(run, { prUrl }); + setPrEditor({ runId: null, value: "" }); + } catch {} + } + return ( +
+
+
+
ACTIVE WORK
+

Codex issue runs

+
+
+ + {activeCount}/{maxActive} active + + {readyCount} ready + {parkedCount} parked + {inactiveRuns.length ? {inactiveRuns.length} inactive : null} + {inactiveRuns.length ? ( + + ) : null} + +
+
+ {actionError ?
{actionError}
: null} + {runner?.runsError ?
{runner.runsError}
: null} + {runs.length ? ( +
+
+
+ {mission.label} + {mission.plateCount} plates +
+

{mission.headline}

+

{mission.detail}

+
+ {mission.items.length ? ( +
+ {mission.items.map((item) => ( + + ))} +
+ ) : null} +
+ + +
+
+ ) : null} + {runs.length ? ( +
+ {readinessRadar.map((item) => ( +
+ {item.label} + {item.value} + {item.detail} +
+ ))} +
+ ) : null} + {runs.length ? ( +
+ {runs.slice(0, 12).map((run) => { + const isLive = openClawRunActive(run); + const checklist = openClawRunChecklist(run); + const prUrl = openClawValidPrUrl(run?.prUrl); + const runWorktree = openClawRunWorktreePath(run); + return ( +
+
+ +
+ {openClawRunLabel(run)} + {run.queueId ? {run.queueId} : null} + {run.source ? {run.source} : null} + {run.codexReasoningEffort ? ( + thinking {run.codexReasoningEffort} + ) : null} + {run.proofMode ? proof {run.proofMode} : null} + {run.claimCommentStatus ? ( + + {run.claimCommentStatus === "already-commented" ? "claim found" : "claimed"} + + ) : null} + {runWorktree ? worktree : null} + {run.worktreeBranch ? {run.worktreeBranch} : null} + {run.pid ? pid {run.pid} : null} + {openClawRunWhen(run)} +
+
+ {checklist.map((item) => ( + + {item.label} + + ))} +
+

{openClawRunNextAction(run)}

+ {openClawRunActivityLines(run).length ? ( +
+ {openClawRunActivityLines(run).map((line) => ( + {line} + ))} +
+ ) : null} + {run.note ?

{run.note}

: null} +
+
+ {prUrl ? ( + + + PR linked + + ) : null} + {run.id} +
+ + + + + + {run.logPath ? ( + + ) : null} + {runWorktree ? ( + + ) : null} + + + + + +
+ {prEditor.runId === run.id ? ( +
savePr(event, run)}> + + setPrEditor({ runId: run.id, value: event.currentTarget.value }) + } + /> + + +
+ ) : null} +
+ {logViewer.runId === run.id ? ( + openRunLog(run)} + onClose={() => + setLogViewer({ runId: null, loading: false, error: "", log: null }) + } + /> + ) : null} +
+ ); + })} +
+ ) : ( +
No Codex issue runs yet.
+ )} +
+ ); +} + +function OpenClawRunLogPanel({ run, log, loading, error, onRefresh, onClose }) { + const entries = Array.isArray(log?.entries) ? log.entries : []; + return ( +
+
+
+ LIVE LOG + {run.id} +
+
+ + {openClawRunActive(run) ? "polling" : "snapshot"} + + + +
+
+ {error ?
{error}
: null} +
+ {log?.exists === false ? "No log file yet" : `${entries.length} events`} + {log?.truncated ? tail view : null} + {log?.size ? {formatBytes(log.size)} : null} + {log?.updatedAt ? ( + updated {openClawRunWhen({ startedAt: log.updatedAt })} + ) : null} +
+
+ {entries.length ? ( + entries.map((entry) => ( +
+
+ {entry.label || entry.type || "Event"} + {entry.at ? : null} +
+
{openClawRunLogEntryText(entry)}
+
+ )) + ) : ( +
{loading ? "Loading Codex log..." : "No log entries yet."}
+ )} +
+ {log?.path ? ( + + ) : null} +
+ ); +} + +function OpenClawQueue({ + queue, + canCreate, + canStartWork, + runner, + busyIssue, + reasoningEffort, + onReasoningChange, + proofMode, + onProofModeChange, + onCopyPrompt, + onTrack, + onStartCodex, + onCreate, + onReject, + onRefresh, + onOpenGithubBlade, +}) { + return ( +
+
+
+

{queue.definition.title}

+

{queue.definition.why}

+
+
+ {queue.totalCount} + +
+
+ {queue.query} + {queue.error ? ( +
{queue.error}
+ ) : null} +
+ {queue.candidates.length ? ( + queue.candidates.map((candidate) => { + const issueInWork = openClawRunnerHasIssueRun(runner, candidate.number, candidate.url); + const candidateEffort = reasoningEffort(candidate); + const candidateProof = proofMode(candidate); + const hasPrCoverage = openClawCandidateHasPrCoverage(candidate); + return ( +
+
+ +
+ {candidate.author ? @{candidate.author} : null} + + {candidate.signals.readyForPickup ? "ready" : candidate.signals.ageGate} + + {candidate.signals.sourceRepro ? source repro : null} + {candidate.signals.currentMainRepro ? ( + current main + ) : null} + {candidate.signals.needsLiveValidation ? ( + live proof + ) : null} + {hasPrCoverage ? possible PR : null} + {issueInWork ? in work : null} +
+ {hasPrCoverage ? ( + + ) : null} +
+
+ + + + + + + + +
+
+ ); + }) + ) : ( +
No matching issues.
+ )} +
+
+ ); +} + +function OpenClawPullRequests({ + pullRequests, + error, + handoffText, + onHandoff, + onRefresh, + onOpenGithubBlade, +}) { + const [busyPr, setBusyPr] = useState(null); + const [refreshingPr, setRefreshingPr] = useState(null); + return ( +
+
+
+
PR MONITOR
+

Authored open PRs

+
+
+ {error ?
{error}
: null} +
+ {pullRequests.length ? ( + pullRequests.map((pr) => { + const handoffReady = openClawPrReadyForHandoff(pr); + return ( +
+
+ + + +
+
+ + {openClawPrStatusLabel(pr.signals)} + + + CI {pr.checks.state} + + {pr.signals.mergeReady ? merge ready : null} + {pr.signals.clawsweeperHumanReview ? ( + human review + ) : null} + {pr.signals.needsProof ? needs proof : null} + {pr.signals.waitingOnAuthor ? ( + waiting on author + ) : null} + {pr.signals.proofSufficient ? ( + proof sufficient + ) : null} + {pr.checks.mantis ? Mantis {pr.checks.mantis} : null} +
+ {pr.checks.failing.length ? ( +

Failing: {pr.checks.failing.slice(0, 3).join(", ")}

+ ) : pr.checks.pending.length ? ( +

Pending: {pr.checks.pending.slice(0, 3).join(", ")}

+ ) : null} + +
+ ); + }) + ) : ( +
No authored open PRs found.
+ )} +
+