@50arms/platform-mcp 0.0.0-stage → 0.1.436

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +114 -2
  2. package/dist/access-request.js +101 -0
  3. package/dist/agent-gate.js +69 -0
  4. package/dist/board-id.js +27 -0
  5. package/dist/bounded-retry.js +25 -0
  6. package/dist/checkout-docs-root.js +54 -0
  7. package/dist/clear-control.js +77 -0
  8. package/dist/compaction-control.js +142 -0
  9. package/dist/credential.js +104 -0
  10. package/dist/danxbot-api-examples.js +97 -0
  11. package/dist/dashboard-identity.js +14 -0
  12. package/dist/design-screenshot.js +138 -0
  13. package/dist/digest-hold.js +67 -0
  14. package/dist/docs-tools.js +140 -0
  15. package/dist/durable-write.js +56 -0
  16. package/dist/entrypoint.js +35 -0
  17. package/dist/forbidden-hint.js +23 -0
  18. package/dist/guarded-calls.js +21 -0
  19. package/dist/handlers.js +385 -0
  20. package/dist/heartbeat-reporter.js +156 -0
  21. package/dist/http-client.js +190 -0
  22. package/dist/index.js +1128 -0
  23. package/dist/json-body.js +12 -0
  24. package/dist/key-revoked-halt.js +30 -0
  25. package/dist/lifecycle.js +113 -0
  26. package/dist/listen.js +999 -0
  27. package/dist/listener-reminder.js +119 -0
  28. package/dist/machine-daemon/autostart.js +160 -0
  29. package/dist/machine-daemon/beat.js +77 -0
  30. package/dist/machine-daemon/cli.js +42 -0
  31. package/dist/machine-daemon/install.js +168 -0
  32. package/dist/machine-daemon/state.js +64 -0
  33. package/dist/machine-daemon/version.js +13 -0
  34. package/dist/machine-daemon.js +553 -0
  35. package/dist/machine-toolkit.js +460 -0
  36. package/dist/mini-app-build.js +159 -0
  37. package/dist/mini-app-checkout.js +215 -0
  38. package/dist/mini-app-output.js +111 -0
  39. package/dist/mini-app-publish.js +154 -0
  40. package/dist/mini-app-restore.js +175 -0
  41. package/dist/one-line.js +25 -0
  42. package/dist/pacing-relay.js +16 -0
  43. package/dist/permission-request.js +134 -0
  44. package/dist/permission-watch.js +145 -0
  45. package/dist/plan-events-relay.js +572 -0
  46. package/dist/plan-events-tool.js +9 -0
  47. package/dist/plugin-publish.js +38 -0
  48. package/dist/plugin-screenshot.js +81 -0
  49. package/dist/request-bound.js +21 -0
  50. package/dist/restart-notice.js +271 -0
  51. package/dist/result-limit.js +31 -0
  52. package/dist/role-instruction-file.js +36 -0
  53. package/dist/route-board-scope.generated.js +93 -0
  54. package/dist/route-board-scope.js +71 -0
  55. package/dist/session-access.js +322 -0
  56. package/dist/session-connection.js +264 -0
  57. package/dist/session-control-file.js +58 -0
  58. package/dist/session-key.js +125 -0
  59. package/dist/session-options.js +60 -0
  60. package/dist/session-stats.js +235 -0
  61. package/dist/session-stream.js +345 -0
  62. package/dist/session-title.js +129 -0
  63. package/dist/shutdown.js +38 -0
  64. package/dist/stop-fixes.js +50 -0
  65. package/dist/subagents-live.js +162 -0
  66. package/dist/terms-halt.js +39 -0
  67. package/dist/text-limit.js +24 -0
  68. package/dist/tool-access.js +114 -0
  69. package/dist/tool-defs.js +58 -0
  70. package/dist/tool-surface.js +59 -0
  71. package/dist/transcript-lineage.js +105 -0
  72. package/dist/types.js +45 -0
  73. package/dist/urgent-records.js +47 -0
  74. package/dist/version-floor.js +146 -0
  75. package/package.json +51 -4
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @50arms/platform-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Stdio MCP server fronting the 50 Arms dashboard HTTP API. Since DX-3465 it exposes three generic tools (`50arms_api`, `50arms_api_list`, `50arms_api_spec` — see "Tool surface" below) that reach any route the caller's credential may reach, plus a few dedicated tools for what no route call can do (`plan_connect`, `request_permission`, `attach_file`, `50arms_slack_reply`) — there is no per-operation tool any more, and since DX-4525 no dispatch-callback tool either: `50arms_api` is every agent's primary channel to the dashboard (ending a dispatch, gate sign-off, Slack update, thread link, Slack recovery, evaluator summary and template-app load/save are all `50arms_api` calls to the routes in `src/dispatch/callback-routes.ts`). The server instructions and the `50arms_api_spec` description tell an agent to read the spec before its first call to a route it has not called before.
4
+
5
+ `50arms_api` is Zod-validated at the MCP boundary, fetches under the hood, and passes the server response back to the agent verbatim. Refusal envelopes (`{error, ...extra}` with `failed_gate`, `non_terminal_phases`, `offending_keys`, etc.) come through as `{ok: false, status, body}` so the agent can pick the right next action without guessing. 5xx and network failures throw.
6
+
7
+ ## Env vars (required, fail-loud at boot)
8
+
9
+ | Var | Purpose |
10
+ |---|---|
11
+ | `DANXBOT_DASHBOARD_URL` | Dashboard base, e.g. `http://danxbot-dashboard:5555` (inside compose) or `http://localhost:5555` (host). An operator session gets it from the 50arms plugin: its `dashboard_url` option (default the hosted dashboard), chosen per session folder: see the repo's `.claude/rules/dashboard-api-access.md`, the one owner of that rule |
12
+ | `DANX_DASHBOARD_CREDENTIAL` | A DISPATCH's credential: the bearer verbatim, substituted into the materialized `.mcp.json` from its overlay (DX-2862, `src/credential.ts`). Authoritative when set; it never signs out. An operator session sets nothing: with no credential the server boots SIGNED OUT, every tool except `plan_connect` returns one "not signed in" halt, and `plan_connect` requests access (below). `restart_notice` uses the same resolver, so a session's tools and its predecessor lookups can never sign in as two different identities; the relay in this server (`plan_events_wait`) signs in with the server's own credential |
13
+ | `DANXBOT_TEAM` | OPTIONAL, non-secret (DX-4446): the team slug (or id) whose approvers see this session's access request in their danxbot site. Set it in your shell or a repo's committed `.claude/settings.json` `env` block. Unset, the request is answered by link only. A value that is not a team slug fails at boot; a team the dashboard does not know makes `plan_connect` answer `unknown_team` (fix `DANXBOT_TEAM`), never a silent link fallback |
14
+ | `DANXBOT_BOARD_ID` | OPTIONAL (DX-4555, DX-5512): the dispatch's board, the board's ref, the positive integer that numbers it within its team (never its database id; the dispatch injects it). Anything else exits at boot. Unset (the 50arms plugin's own server) the server has no default board: a call that needs a board must carry its `board` argument or the dashboard answers 400 (plan and plan-session routes take their boards from the plan and need none) |
15
+
16
+ BOARD-ONLY (DX-1171): board is the first-level concept; repo is DERIVED from board server-side, never passed. At boot the package reads the dispatch's board ref from `DANXBOT_BOARD_ID` and appends it as `?board=<ref>` (and `body.board` on create) to every `/api/issues/*` request, except `GET /api/issues` (the list), whose board is OPTIONAL (DX-4542): there it sends a board only when the caller names one, because no board is the team's whole list across every board. Each tool exposes an optional `board` arg - a board ref, the number (or its decimal text) - that overrides it per-call for cross-board reads/writes (unknown board -> 404). The dashboard owns board->repo resolution; this package never sends `repo`.
17
+
18
+ ## Signing in (DX-4390): request and approve
19
+
20
+ An operator session has no stored credential. Signed out, `plan_connect` (optionally with `title`, the session's own Claude title) posts `POST /api/access-requests` and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`: show the user the code and open the URL; they approve in the dashboard. The server polls `POST /api/access-requests/claim` in the background, so the key is stored when the approval lands; the next `plan_connect` waits up to about 45 seconds for it, then connects, or answers `approval_pending`. One tool call never blocks for minutes. `denied` is reported once; a request that lapsed (the dashboard deletes it within about a minute of expiry, so a claim answers 404) starts a fresh one on the next call.
21
+
22
+ - **Storage.** `~/.config/danxbot/session-keys/<sha12(dashboard url)>/<session id>.json`, mode 0600, written write-then-rename, pruned after 7 days. One file per dashboard and per session; this server reads it at boot, and `restart_notice` reads the same file through the `session-key` credential source and proves the fingerprint. The key lapses on the dashboard after 24 hours without use (every authenticated call, the relay's heartbeat included, renews it), and is revocable from the account page.
23
+ - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `plan_connect`: a new request in the same call). That is a key that lapsed. A key a PERSON revoked is answered `401 key_revoked` (DX-4418): the key file is deleted and EVERY dashboard tool, `plan_connect` included, returns one stop halt (who revoked it and when; commit work in progress and end; a dispatched agent makes no further call), for the rest of the process. No access request is made. The worker finds the revocation itself and ends the dispatch as revoked (every dashboard route, the dispatch-complete one included, refuses a revoked key). `plan_events_wait` answers `stopped` with the reason `key_revoked` the same way. **A 404 on create** means the dashboard predates request-and-approve and says so.
24
+ - A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
25
+
26
+ ### Asking for one more permission (DX-4434)
27
+
28
+ A call the dashboard refuses `403 {error: "forbidden", permission}` means the key lacks that one permission. `request_permission({permissions, reason})` asks the key's owner to grant it: it posts `POST /api/permission-requests` (the key's own bearer, through `SessionAccess.guard`, so a revoked or signed-out session never asks) and answers at once `{state: "approval_required", approvalUrl, confirmCode, expiresAt, instruction}`. The agent shows the person the code and URL and keeps working; this process polls `POST /api/permission-requests/:publicId/claim` in the background (`src/permission-request.ts`, the same poll shape as above) and a grant is live on the key's very next call, with the same key. The dashboard does not dedupe, so this server keeps one in-flight request per permission set (sorted, de-duplicated): the same set again while pending answers `approval_pending` with the original code and URL and posts nothing. A decided, expired or reaped request frees its set so a later call asks again. A dashboard 4xx on the create (`unknown_permission`, `already_held`, `owner_lacks_permission`, `key_not_live`, 429) is returned verbatim as `{ok: false, status, body}`; 5xx throws.
29
+
30
+ The hint: every dashboard result that is `403 {error: "forbidden", permission}` (and only that) gains one `reminders[]` entry naming the permission, `request_permission`, and saying to raise a problem on the card if blocked (`src/forbidden-hint.ts`, applied once in `guardedCall` in `src/index.ts`).
31
+
32
+ ### Which tools a session sees (DX-4523)
33
+
34
+ A session is shown only the tools it can call. `src/tool-access.ts` declares, for every tool, what makes it usable, and its one function `toolsFor({permissions, callbacks})` maps what a session holds to the tool names it advertises. `src/tool-surface.ts` applies it with the SDK's `tool.enable()` / `disable()`, so `notifications/tools/list_changed` is sent and Claude Code refetches; nothing else decides visibility, and the danxbot service's budgets (`src/tokens/mcp-surface.ts`) read the same function.
35
+
36
+ - **Bootstrap set**, for every session and the whole of a signed-out one: `plan_connect`, `request_permission` and the `50arms_api` trio.
37
+ - **Permission tools** (`attach_file` needs `cards.edit` for both targets): shown once the key holds the permission the tool's dashboard route needs.
38
+ - **Callback tool** (`50arms_slack_reply`, the only one, DX-4525): shown when the worker injected its URL (`DANXBOT_SLACK_REPLY_URL`, `<dashboard>/api/slack/reply`, no dispatch id) AND the key holds `slack.threads.post`, so a non-Slack dispatch never sees it. It posts with the dispatch's own credential (`DANX_DASHBOARD_CREDENTIAL`), the same one `50arms_api` uses.
39
+ - **When it is applied.** At boot, as a session with no key; then, if the session holds a key (a dispatch's env credential, or a stored session key), right away from `GET /api/auth/permissions`; a dispatch retries that read three times and fails the boot rather than start without its tools. After `plan_connect` signs a session in. And again when a person approves a `request_permission` grant: the dashboard ends the key's open `/api/stream` with `event: end {reason: "permissions_changed"}`, this process holds one stream open on the key-self topic `key:self` (`src/permission-watch.ts`; any key may subscribe with no permission, because a dispatch's agent role holds no read-floor key), and after EVERY (re)connect re-reads the key's permissions (a failed read is retried with bounded backoff) and enables what is newly allowed in ONE `list_changed`. It reconnects with backoff after a drop, and stops when stdin closes. A grant that adds no tool, and a repeated read, switch nothing and send no notification, because a changed tool list costs the session its prompt cache.
40
+ - A stream the dashboard refuses (any 4xx) is logged and ends the watch; that session's grants then show their tools at its next `plan_connect`. A permission read the dashboard refuses (401/403, other than the Terms halt) fails a dispatch's boot rather than quietly narrowing its tools.
41
+
42
+ ## Tool surface (DX-3465 — one generic tool, not one per operation)
43
+
44
+ All exposed as `mcp__platform__<name>` once wired through the workspace `mcp.template.json`. There is no per-operation tool any more (no `issue_get`, `issue_create`, `plan_add_note`, …) — three tools cover the whole dashboard API (a fourth, `request_permission`, asks for more access):
45
+
46
+ | Tool | Purpose |
47
+ |---|---|
48
+ | `50arms_api` | `{method, path, query?, body?}` — calls any dashboard route the caller's credential may reach (card/plan CRUD, comments, dependencies, problems/solutions, quality gates, retro, triage, transitions, …). Returns `{ok, status, body}`; `body` carries the route's own response, or its structured error verbatim when `ok:false`. While this session is plan-connected, the dashboard's own reminders for it (today: `plan_list.listener_unattached`, attached by the server to `GET /api/plan-sessions/me/listener-health` when the event listener is unattached past the startup grace, DX-3997) are relayed into the body's `reminders[]`; the MCP carries no reminder text or state of its own and re-reads at most every 30s. |
49
+ | `50arms_api_list` | No args. Lists the routes this caller's credential may reach as `{routes: ["METHOD /path"]}` (one bare string per route, exactly what `50arms_api_spec` takes; a dispatch's own callbacks are in it) — call this first when you don't know the route for what you need to do. |
50
+ | `50arms_api_spec` | `{endpoints}` (comma-joined `METHOD /path` list, required, min 1). Returns the exact OpenAPI contract for those routes — call this when you know the route but not its exact request/response shape. |
51
+ | `attach_file` | `{target?, card_id, path, replaces?, primary?, filename?, content_type?}` — the server reads the LOCAL file at `path` (absolute, `/tmp/a.png` or `C:\x\a.png`) and uploads it, so its bytes never pass through the agent's context. `replaces: <attachment id>` swaps that attachment's file in place (`PUT /api/issues/:id/attachments/:aid`: same id, name and cover flag) instead of adding one; `primary: true` makes it the card's single cover, atomically with the write. Reply: `{issue: <compact row>, attachment_id, url, thumbnail_url, is_primary}`. `target: "inline_image"` (DX-5241) instead uploads a local png/jpg/gif/webp/avif (25 MB, no SVG) to `POST /api/uploads/images` with no card, takes only `path`, and replies `{url, markdown}` for embedding in any markdown body such as a `POST /api/feedback` body. Shown to a key holding `cards.edit` (DX-4523); a dispatch profile still receives it only when its catalog selection names it (`slack-agent` and `work`, DX-4386, through `extraTools`). A screenshot of browser-visible work: first `primary: true`, then `replaces` at each milestone. |
52
+ | `mini_app_publish` | `{target: {repo?, name}, dir, note, destination: {kind: "card_design", card_id, design_no | design_title + design_element}}` — builds a page with the repo's OWN mini app and publishes it as a revision of one of an existing card's designs, or as a new design on it (DX-5294, replacing `design_publish`; DX-4077, DX-5164, DX-5193). A card holds several designs: pass exactly one of `destination.design_no` (add a revision to that design, `POST /api/issues/<card>/designs/<no>/revisions`) or `destination.design_title` (start a new design, `POST /api/issues/<card>/designs`; the title is fixed for good), both-or-neither is refused before anything is built; `destination.design_element` (DX-5856) is required with `design_title` and refused with `design_no`: it names the one thing the design decides ("Indicator pill") and is sent as `element`, and the design joins that element's open design problem or opens one (a card has one open design problem per element); the answer carries `{card_id, design_no, revision_no, dashboard_url, local_html_path, screenshot}`. `target.name` names one of the repo's mini app targets, read from the dashboard (`GET /api/repos/<repo>`, `mini_app_targets`, edited in Settings > Board > General), never from a file; `target.repo` (a repo's name) defaults to the repo of the card's board (`GET /api/boards/<board>`) and names another repo's mini app (danxbot's own) when given. A target is `{name, command}`: a build `command` (an argument list, run in the repo root with NO shell: a bare program name, then a committed repo-relative script, then its own arguments). For each build the tool makes a fresh empty folder in the OS temp area (never inside the repo) and runs the command with the design folder's repo-relative path (forward slashes) and that output folder (absolute) appended as its LAST TWO arguments; the command writes `index.html`, one `.js` and one `.css` into it, the tool reads them and deletes the folder, so two builds never share a folder, nothing in the person's checkout is ever deleted, and no leftover output can exist. The command runs in a short-lived child process with a 5-minute limit, so the server never holds the design's checkout files open (DX-4727); once the process exits its pipes get 2 s to close, then the call stops waiting and a leftover child is killed on POSIX only (its process group; on Windows `taskkill /T` cannot reach an orphan whose parent has exited, so it may keep running); the command's own output comes back verbatim when it fails (only its last 1 MiB is kept, and the message says so when it was cut), and nothing is read or posted then. Refused before anything is built or posted, naming the target and field: a program with a path separator or whitespace, an unsafe script path (a `..` segment, a backslash, an absolute path or a drive letter), a `.cmd`/`.bat` shim on Windows (`npm`, `npx`, `yarn`, `pnpm`: name `node` and a committed script), a script that is not tracked by git or has changes against HEAD (the named script only, not what it imports), and a design folder that is tracked by git or not ignored by it. The page is written to `<tmpdir>/danx-design/<build-id>.html` before the upload, the revision carries `design_dir` (the folder, repo-relative) and the folder's text files as `source_files`, and a 1280x800 screenshot is taken when Playwright resolves from the repo or this server's cwd (`screenshot: "none: <reason>"` otherwise, never a failed publish). A new design may carry `problem_statement`, `problem_summary` and `problem_context` (DX-6000): the designer's own text for the element's design problem, REQUIRED by the server when the publish opens that problem (the card has no open design problem for that element: its first design, or the first after one was chosen) and refused when it adds a design to an open one; the tool passes them verbatim and judges nothing. `destination.card_id` is required and names an existing card (any but an Epic or Feature); the tool never creates a card, and a refused revision comes back verbatim with the `card_id`. Shown to a key holding `cards.edit`; opt-in, so a dispatch profile receives it only when its catalog selection names it (none does yet). |
53
+ | `plugin_publish` | `{dir, plugin, note, name?}` — builds a card-type plugin project with its own `npm run build` in a short-lived child process, archives its source (`source.tar.gz`: git-tracked plus untracked-unignored files, else what the top-level `.gitignore` leaves, minus `node_modules`, `.git`, `dist`; never `.env*`; a lockfile is required) and uploads both as a new revision of the plugin `plugin` (created on the first publish), `POST /api/plugins/<id>/revisions` (DX-5237). The build, archive and upload are `@danxbot/plugin-sdk/publish`, the code `danx-plugin publish` runs too. The answer carries `{plugin, plugin_id, revision_id, revision_no, approval_url, screenshot}`; `approval_url` is Settings > Plugins, where a team admin approves the revision. A 1280x800 screenshot of the plugin's own render is taken when Playwright resolves from the project or this server's cwd: a headless browser opens the dashboard's public `/plugin-preview` page, which draws the built plugin's `CardView` on the app's own runtime (React, `@danxbot/ui`, the card kit) with a sample card, the build served from memory so nothing is uploaded first, and the capture waits until the page reports `ready` (`screenshot: "none: <reason>"` when there is no browser, the dashboard predates the page, or the plugin fails to render; never a failed publish). A refused upload comes back verbatim with the `step` refused. Shown to a key holding `plugins.publish`; opt-in like `mini_app_publish`. |
54
+ | `docs_search` | `{source, query, limit?}` — searches a docs root with the shared `@danxbot/docs` engine and answers `{query, results: [{id, type, title, summary, file, bytes, lines, look_in?}]}`, best first, **never a page body** (DX-5582). `source` is `{kind: "local"}` (this checkout's `docs/pages`, the server's cwd), `{kind: "dir", path}` (another docs root on this machine) or `{kind: "remote", repo}` (`repo` is the repo's name; a team repo's hosted docs, `GET /api/docs/<repo>/search`, `docs.view`, the repo must be on the caller's team's boards) or `{kind: "product"}` (50 Arms' own product docs, which every team reads, `GET /api/product-docs/search`; a repo can be named `danxbot` and is still an ordinary repo). A local or dir `file` is an absolute path; a remote `file` is the page's path inside that repo's docs. `bytes` and `lines` let the agent read the file whole or with offset/limit. A remote search validates the repo's docs strictly (422 when a page is invalid), while a local or dir search skips files it cannot read or parse and reports how many as `skipped_files` (DX-5597). A whitespace-only query is refused 400 for every source. Shown to a key holding `docs.view`; opt-in like `plugin_publish`. |
55
+ | `docs_read` | `{source, id}` — answers `{id, path, bytes, lines}`: the page as a file on this machine, never its text. A local or dir page is read where it is (nothing copied); a remote page is fetched from `GET /api/docs/<repo>/pages/<id>` and written to `<tmp>/danx-docs/<repo>/<id>.md`, and a product page from `GET /api/product-docs/pages/<id>` to `<tmp>/danx-product-docs/<id>.md` (named by the requested id, written under a temp name and renamed into place), replaced on every call. The agent then reads it with its own file tools. An unknown id is refused with the nearest ids. Same gate and opt-in as `docs_search` (DX-5582). |
56
+ | `request_permission` | `{permissions, reason}` — asks a person to grant this key ONE MORE permission (see "Asking for one more permission" above). Answers at once with the approval URL and confirm code; the grant is picked up in the background. |
57
+
58
+ There is no dedicated `resource_fields` tool (removed DX-3465 — it was a thin wrapper `50arms_api` already reaches with no agent-specific behavior earning it its own tool). What an opt-in field TREE may name for one resource (`{resource, description, always, hashes, fields, relations}`) is `50arms_api({method:"GET", path:"/api/resources/<resource>/fields"})` — install-global, no `board`; `resource: "issue"` is the root, and a relation's own `resource` in the response is what to call this again with, one level deeper.
59
+
60
+ A dedicated tool survives only for something no `50arms_api` call can do, never a card/plan/dispatch-callback operation: `request_permission` (asks for a permission its key lacks), `plan_connect` (binds the session to a plan; the plugin's relay loop starts on it), `card_connect` (DX-5553: holds ONE card that is on no plan instead, or lets it go; `POST|DELETE /api/plan-sessions/me/card`; the same relay then carries that card's and its descendants' events), and the two that read LOCAL files, `attach_file` and `50arms_slack_reply` (`files[]`, see `src/lifecycle.ts`). The live list is always `tool-defs.json` in this package, regenerated by `npm run gen-tool-defs` — read it rather than trusting a list copied into prose.
61
+
62
+ Card and plan reads/writes go through `50arms_api` against the issue/plan route families documented in `GET /api/openapi` (via `50arms_api_spec`) — `POST /api/issues`, `GET /api/issues/:id` (or `/batch` for many at once, DX-2727), `PATCH /api/issues/:id/edit`, `POST /api/issues/:id/transition`, the problems/solutions/dependencies/comments/quality-gates/retro sub-resource routes, and the equivalent `/api/plans/*` family (`GET /api/plans/:planId` — cheap scalars by default, `fields` opts into `cards`/`records`/`architecture`/`session_history`/`worker_dispatches`; `GET /api/plans` for the list, with an optional `status` filter over the same computed `complete`/`awaiting-session`/`building`/`planning` value `GET /api/plans/:planId` returns when asked: `status` is opt-in, so request it with `fields={status:true}`).
63
+
64
+ ## `plan_events_wait` — a working session's plan events (DX-4233)
65
+
66
+ A program calls this, not a person: the danxbot Claude Code plugin's relay loop, inside the session, calls it over and over on the session's own platform MCP server and hands every event it gets to the session. An orchestrator dispatch (a headless session that works one plan) has no plugin relay, so it keeps ONE background waiter sub-agent that loops on this tool until events exist and returns them with the last cursor, and the orchestrator passes that cursor to the next waiter it arms (DX-4667); the `orchestrator` profile alone is granted the tool on a dispatch (it is an opt-in tool). The `bridge` subcommand is gone: no process is spawned, no `npx` cold start, and nothing outlives the session, because the stream lives in the MCP server, which ends with its session.
67
+
68
+ Input `{plan_id: number | null, cursor: string | null, timeout_ms: 1000..30000, transcript_path?: string}`. `plan_id` is `null` for a signed-in session on no plan (DX-4882): its stream still carries the pacing and resume messages of its Claude account, and it receives no plan events. A move between a plan and `null` starts clean like a move between plans: the plan-scoped records of the plan left are dropped, the session-scoped ones kept. Answer, one of:
69
+
70
+ - `{events: [{cursor: string, text: string, urgent: boolean}]}`: every record the caller has not delivered, in arrival order (empty at the timeout). Each record carries the cursor to store once THAT record is delivered. There is no `id`: the caller compares nothing. `urgent` (DX-4721) is decided in one place by the record's kind (`src/urgent-records.ts`): true for a critical usage-pacing stop and the revoked-key notice, false for everything else (activity events, nudge, resume message, digest, the relay's failure notice, an unreadable line). The caller adds an urgent record to a running turn at once and delivers any other after the turn ends; `urgent` never changes order, seq or acknowledgement. The dashboard writes no operator-stop record yet; when it does, it becomes one more kind in that function.
71
+ - `{stopped: {reason, detail, fix}}`: a terminal outcome (`not_connected`, `plan_archived`, `unauthorized`, `key_revoked`, `mint_refused`, `mint_bad_response`, `session_is_worker`, `superseded`, `replaced`, `revoked`, `refused`, `credential_unavailable`, `relay_failed`). `fix` is the remedy text (`STOP_FIXES`, `src/stop-fixes.ts`). The next call starts from scratch.
72
+
73
+ The contract the plugin builds against:
74
+
75
+ - **One rule, by arrival.** The server gives every record (a dashboard event, a digest, a notice, a pacing or resume message) the next number of a sequence that only ever increases (`seq`). A call drops every record whose seq is at or below the cursor's and re-answers everything above it until a later call's cursor passes it. So a call cancelled by a plugin reload loses nothing, and a dashboard event that arrives after a higher-numbered one (the dashboard numbers at insert and publishes at commit) is simply the next record: never dropped, delivered once.
76
+ - **The cursor** is the string `<seq>:<id>,<id>,...`: the record's seq, a colon, then the dashboard event ids delivered up to and including it, in arrival order, at most the last 64 (`CURSOR_IDS_MAX`); `0:` is the start. Every number is base-10 with no leading zero, the ids are distinct and positive, and anything else is refused loudly (the call errors). The plugin stores it verbatim and sends it back verbatim. The ids are what makes a restart exact: after a restart of the MCP server (a resumed session) the fresh relay continues the sequence from the cursor's seq, resumes the stream after its highest id, and seeds the listener's duplicate guard with all of them, so the events the dashboard replays from its 30 s commit-order overlap are dropped when already delivered and delivered when never delivered. The same holds across the 40 s gap below. A crash between delivering a record and storing its cursor can repeat that one record; nothing is lost.
77
+ - **The accepted restart gap.** Only an insert that commits more than the dashboard's 30 s overlap after the event it follows can be missed, and only by a restart resume (the dashboard's own limit); a running relay never misses one. A burst of more than 64 events inside the overlap can replay the oldest once (a duplicate, never a loss).
78
+ - **One plan.** `plan_id` is the number of the plan the session is on (the `n` of `PLAN-n`, within the team). A plan different from the last call's drops the plan-scoped records buffered or held for the plan it left (dashboard events, the digest, the idle nudge), so a plan move starts clean. Session-scoped records (the pacing and auto-resume messages, which are never replayed, and the relay's own failure and revoked-key notices) are kept across a plan move and across the gap below. One wait is pending at a time: a newer call supersedes the pending one (which answers empty at once); an aborted request drops its waiter.
79
+ - **One heartbeat window.** `POST /api/plan-sessions/me/heartbeat` every 15 s while a call is pending or was made within the last 40 s; after that the stream is closed and heartbeats stop, so a dead or unloaded plugin module reads not-healthy (the dashboard's liveness window is 60 s). The plugin therefore waits 20 s per call and backs off at most 10 s between failed calls. Stats and the transcript title ride the heartbeat when `transcript_path` is given. The sequence survives the gap, so a cursor stays valid across it.
80
+ - **Failures inside the server.** A transient mint failure (network, timeout, 408, 429, 5xx) and a dropped stream back off 1 s doubling to 30 s (the one schedule, `listen.ts`); a lapsed ticket lease (15 min) re-mints and re-reads a failed plan inventory; three heartbeats in a row that fail to land on an open stream force a fresh connection silently (a line on stderr, then an immediate heartbeat on the new stream), and the session is told only once the heartbeat has failed continuously for `HEARTBEAT_SUSTAINED_OUTAGE_MS` (5 minutes), in any connection state, once per outage, so a dashboard restart never wakes it; a revoked key tells the session once.
81
+ - **Nothing outlives the session.** The heartbeat interval, the backoff sleep and the digest hold are unref'd; `close()` (run when the transport closes and when stdin ends) aborts the stream and the mint and inventory requests and clears a pending call's wait timer, the one ref'd timer, which otherwise holds the process only until the call answers.
82
+ - **Credential and audience.** The server's own, from its boot-time `CLAUDE_CODE_SESSION_ID`: no connection record, fingerprint check or environment variable is involved. The tool is advertised only to a person's session holding `plans.view`, never to a dispatch. The stream's advisory frames (the idle nudge, DX-4340's pacing message, DX-4342's auto-resume message) arrive as records like any event, at once and never held for a digest. The digest (agent and machine events held up to 10 minutes) is one record, `[plan events] digest`, and a held id is never acknowledged before it is returned.
83
+
84
+ ## `restart_notice` — what a restarted session was working on (DX-3928, DX-4632)
85
+
86
+ A tool for the 50arms plugin's session-start hook, not for a model, advertised only on a person's session (never a dispatch) and **usable before sign-in**: a restarted Claude Code session gets a new session id, no plan binding and, until a person approves its first call, no dashboard key of its own, so nothing else could tell it that the plan it had been working was unattended. `plan_connect` writes the session's working directory into its connection record (`projectKey`, record schema v2); `restart_notice` finds the newest OTHER sessions' records for the same project (at most ten, each within `STALE_RECORD_MS`), resolves each record's own credential, and asks `GET /api/plan-sessions/<predecessor>/restart-notice` which plan it was on and how many operator events wait there (the connect digest's own query, read-only). No arguments. Answers one of:
87
+
88
+ - `{notice: "<text>"}`: say it (the registry's `plan_restart.waiting_events` text; this package carries no wording).
89
+ - `{notice: null}`: nothing to say (no earlier session of this project connected a plan, the plan is archived or deleted, the record is stale, or every predecessor is still live).
90
+ - `{stopped: {reason, detail, fix}}`: the lookup could not answer: `no_session_id`, `lookup_failed` (no candidate answered; the detail names each reason) or `lookup_timeout`. Never a silent null.
91
+
92
+ ONE deadline of 8 s bounds the whole lookup, every candidate included (each request also keeps its own 5 s bound), so a hung dashboard answers `lookup_timeout` and never wedges a session start. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
93
+
94
+ ## `subagents-live` — the plan pane's local live sub-agent feed (DX-4508)
95
+
96
+ `platform-mcp subagents-live <absolute path to the session's main transcript .jsonl>`. Spawned by the 50arms plugin's plan pane, which cannot tail files itself; the dashboard's sub-agent read lags the transcript by about 80s, this by about one second. It tails the session's transcript tree (the main file and `<session-id>/subagents/agent-*.jsonl` beside it) with the same `SessionLogWatcher` the relay runs, from byte 0 once and then only appended bytes, polling every second, and computes each sub-agent through `@50arms/session-telemetry`'s `createSubagentLiveView` (the same observer, ledger counter and pricing rule the dashboard uses). stdout carries only `{"subagents":[...]}` lines: the first, after the catch-up read, holds every sub-agent (an empty array when there are none); each later one holds only the sub-agents a poll changed. The one network call is `GET /api/model-versions` at start (DX-5611: the telemetry package holds no prices, so the declared `model_version_prices` price each reply, as the dashboard's ledger does), authenticated with the session's approved dashboard key on `DANXBOT_DASHBOARD_URL`, else the plugin's hosted dashboard; a start that cannot read the prices exits 1. A missing, relative or non-`.jsonl` path exits 2 with one stderr line; SIGTERM, SIGINT or a closed stdout exits 0.
97
+
98
+ ## Build + test
99
+
100
+ ```bash
101
+ cd packages/danx-dashboard-mcp
102
+ npm install
103
+ npm run build # → dist/index.js (executable)
104
+ ```
105
+
106
+ Its tests run only in danxbot's designated Linux test container (a `vitest run` anywhere else refuses before any test runs). From
107
+ the danxbot checkout root:
108
+
109
+ ```bash
110
+ make test PROJECT=packages/danx-dashboard-mcp [FILES='__tests__/<file>.test.ts']
111
+ ```
112
+
113
+ ## Publishing
114
+
115
+ Out of scope for this package's own code — the `make publish-platform-mcp` target in the danxbot root Makefile owns version bump + `npm publish` + propagation wait. Standing operator authorization per `~/.claude/skills/thehammer-publish/`.
@@ -0,0 +1,101 @@
1
+ import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
2
+ // DX-4446: mirrors TEAM_SLUG_PATTERN in src/lib/auth-rules.ts (this package cannot import the main src/); a team ref is a slug,
3
+ // and a team id's digits are a slug too. Keep the two in step.
4
+ const TEAM_REF_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
5
+ /** DX-4446: the `DANXBOT_TEAM` value, or a loud error naming the variable. */
6
+ export function assertMcpTeamRef(value) {
7
+ if (!TEAM_REF_PATTERN.test(value)) {
8
+ throw new Error(`[platform-mcp] DANXBOT_TEAM "${value}" is not a team slug or id: lowercase letters and digits, joined by single hyphens`);
9
+ }
10
+ return value;
11
+ }
12
+ function baseOf(dashboardUrl) {
13
+ return dashboardUrl.replace(/\/+$/, "");
14
+ }
15
+ async function postPublic(fetchImpl, url, body) {
16
+ let res;
17
+ try {
18
+ res = await fetchImpl(url, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
21
+ body: JSON.stringify(body),
22
+ });
23
+ }
24
+ catch (err) {
25
+ throw new Error(`[platform-mcp] network failure on POST ${url}: ${err instanceof Error ? err.message : String(err)}`);
26
+ }
27
+ assertVersionFloorSatisfied(res.headers.get(MCP_MIN_VERSION_HEADER));
28
+ const text = await res.text();
29
+ if (res.status >= 500) {
30
+ throw new Error(`[platform-mcp] server error ${res.status} on POST ${url}: ${text.slice(0, 500)}`);
31
+ }
32
+ let json = null;
33
+ if (text !== "") {
34
+ try {
35
+ json = JSON.parse(text);
36
+ }
37
+ catch {
38
+ throw new Error(`[platform-mcp] non-JSON response (status ${res.status}) from POST ${url}: ${text.slice(0, 200)}`);
39
+ }
40
+ }
41
+ return { status: res.status, headers: res.headers, json };
42
+ }
43
+ /** When a 429 carries no usable `Retry-After`, wait this long before the next try. */
44
+ const DEFAULT_RETRY_AFTER_SECONDS = 1;
45
+ function retryAfterSeconds(headers) {
46
+ const seconds = Number(headers.get("retry-after"));
47
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_RETRY_AFTER_SECONDS;
48
+ }
49
+ function isCreated(value) {
50
+ if (typeof value !== "object" || value === null)
51
+ return false;
52
+ const v = value;
53
+ return (typeof v.publicId === "string" &&
54
+ typeof v.secret === "string" &&
55
+ typeof v.confirmCode === "string" &&
56
+ typeof v.approvalUrl === "string" &&
57
+ typeof v.pollMs === "number");
58
+ }
59
+ export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
60
+ const url = `${baseOf(dashboardUrl)}/api/access-requests`;
61
+ const { status, headers, json } = await postPublic(fetchImpl, url, input);
62
+ if (status === 400 && input.team !== undefined && json?.error === "unknown_team") {
63
+ return { kind: "unknown_team", team: input.team };
64
+ }
65
+ if (status === 201) {
66
+ if (!isCreated(json)) {
67
+ throw new Error(`[platform-mcp] POST ${url} answered 201 without the access request fields: ${JSON.stringify(json)}`);
68
+ }
69
+ return { kind: "created", request: json };
70
+ }
71
+ if (status === 404)
72
+ return { kind: "dashboard_outdated" };
73
+ if (status === 429)
74
+ return { kind: "rate_limited", retryAfterSeconds: retryAfterSeconds(headers) };
75
+ return { kind: "refused", status, body: json };
76
+ }
77
+ export async function claimAccessRequest(fetchImpl, dashboardUrl, secret) {
78
+ const url = `${baseOf(dashboardUrl)}/api/access-requests/claim`;
79
+ const { status, headers, json } = await postPublic(fetchImpl, url, { secret });
80
+ if (status === 404)
81
+ return { kind: "not_found" };
82
+ if (status === 429)
83
+ return { kind: "rate_limited", retryAfterSeconds: retryAfterSeconds(headers) };
84
+ if (status !== 200)
85
+ return { kind: "refused", status, body: json };
86
+ const body = json;
87
+ switch (body?.status) {
88
+ case "pending":
89
+ case "denied":
90
+ case "expired":
91
+ case "claimed":
92
+ return { kind: body.status };
93
+ case "approved":
94
+ if (typeof body.apiKey !== "string" || body.apiKey === "") {
95
+ throw new Error(`[platform-mcp] POST ${url} answered approved without an apiKey`);
96
+ }
97
+ return { kind: "approved", apiKey: body.apiKey };
98
+ default:
99
+ throw new Error(`[platform-mcp] POST ${url} answered an unknown status: ${JSON.stringify(json)}`);
100
+ }
101
+ }
@@ -0,0 +1,69 @@
1
+ /** The two states of a session with no agent, as the dashboard answers them. */
2
+ const BLOCKED_STATES = new Set(["agent_needed", "agent_limit_reached"]);
3
+ /** The dashboard's no-agent answer in `body`, or null when `body` is anything else (a connect, an error, an agent held). */
4
+ export function agentBlockedReplyOf(body) {
5
+ if (typeof body !== "object" || body === null)
6
+ return null;
7
+ const state = body.state;
8
+ const instruction = body.instruction;
9
+ if (typeof state !== "string" || !BLOCKED_STATES.has(state) || typeof instruction !== "string")
10
+ return null;
11
+ return body;
12
+ }
13
+ /** A reply that proves the session holds a team agent: the connect envelope and the plan-less sign-in both carry `agent` with its id and name. */
14
+ export function agentHeldIn(body) {
15
+ if (typeof body !== "object" || body === null)
16
+ return false;
17
+ const agent = body.agent;
18
+ if (typeof agent !== "object" || agent === null)
19
+ return false;
20
+ const { id, name } = agent;
21
+ return typeof id === "number" && typeof name === "string";
22
+ }
23
+ export class AgentGate {
24
+ blocked = null;
25
+ /**
26
+ * Record the outcome of a sign-in. The no-agent answer closes the gate on dashboard work; only a reply that carries a held `agent` opens it. Any
27
+ * other answer (an error, a 4xx or 5xx body, a leave) says nothing about the agent, so the gate keeps its state: it never fails open.
28
+ */
29
+ record(body) {
30
+ const blocked = agentBlockedReplyOf(body);
31
+ if (blocked !== null)
32
+ this.blocked = blocked;
33
+ else if (agentHeldIn(body))
34
+ this.blocked = null;
35
+ }
36
+ /** The no-agent answer every dashboard tool returns while the session holds none, else null. */
37
+ current() {
38
+ return this.blocked;
39
+ }
40
+ }
41
+ /** The prelude of every guarded dashboard call: the no-agent answer (as the halt text) while the session holds none, else null and the call may go on. */
42
+ export function agentGateHalt(gate) {
43
+ const noAgent = gate.current();
44
+ return noAgent === null ? null : JSON.stringify(noAgent);
45
+ }
46
+ /**
47
+ * DX-5724 / DX-5765: the ONE list of the two routes a session with no agent may still reach (propose an agent, then attach its avatar), as the route templates
48
+ * `50arms_api_list` reports and `50arms_api_spec` takes. Pinned to the server's AGENT_GATE_OPEN_ROUTES by src/dashboard/agent-gate-mcp-pin.test.ts.
49
+ */
50
+ export const AGENT_PROPOSAL_ENDPOINTS = ["POST /api/team/agent-proposals", "POST /api/team/agent-proposals/:publicId/avatar"];
51
+ /** The call regex, derived from {@link AGENT_PROPOSAL_ENDPOINTS}: a proposal's public id is 32 hex characters. */
52
+ const AGENT_PROPOSAL_ROUTE = new RegExp("^(?:" + AGENT_PROPOSAL_ENDPOINTS.map((e) => e.replace(/^POST /, "").replace(":publicId", "[0-9a-f]{32}")).join("|") + ")$");
53
+ /** Whether `50arms_api` may make this call with the gate closed. */
54
+ export function isAgentGateOpenCall(method, path) {
55
+ return method === "POST" && AGENT_PROPOSAL_ROUTE.test(path);
56
+ }
57
+ /** Whether `50arms_api_spec` may answer with the gate closed: only when every named endpoint is one of the two proposal routes. */
58
+ export function isAgentGateOpenSpec(endpoints) {
59
+ return endpoints.length > 0 && endpoints.every((e) => AGENT_PROPOSAL_ENDPOINTS.includes(e));
60
+ }
61
+ /** The `50arms_api_list` answer with the gate closed: the dashboard's list narrowed to the two proposal routes; a refusal passes through. */
62
+ export function onlyAgentProposalRoutes(result) {
63
+ if (!result.ok)
64
+ return result;
65
+ const routes = result.body.routes;
66
+ if (!Array.isArray(routes))
67
+ return result;
68
+ return { ...result, body: { ...result.body, routes: routes.filter((r) => AGENT_PROPOSAL_ENDPOINTS.includes(r)) } };
69
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * DX-5512 / DX-6489 (PLAN-50 E01, R3) - the one place this package reads a board ref or a repo name: a board's ref is the positive integer that numbers it within its team (its `ref_num`, never the database id), never a name.
3
+ *
4
+ * `parseBoardRefText` is the same rule as the dashboard's `parseRefNum` (`src/ids.ts`): a trimmed bare positive integer, else null. This
5
+ * package is published on its own and cannot import server source, so the rule is restated here; `board-id.test.ts` pins its cases, which are the same vectors as `src/ids.test.ts` (change one rule, change both).
6
+ * The dashboard's `parseRefNum` (`src/ids.ts`) and `resolveBoardRef` (`src/dashboard/board-ref.ts`) decide which values an edge accepts: this package type-checks
7
+ * the value as a ref and forwards it, so it invents no label form of its own.
8
+ */
9
+ import { z } from "zod";
10
+ /** A bare positive integer's text: no sign, no leading zero, no exponent, no radix, no fraction, no surrounding space. */
11
+ const BOARD_ID_PATTERN = /^[1-9][0-9]*$/;
12
+ /** A board ref from text (an env value, a tool argument's decimal text): a bare positive safe integer, else null ("007", "0", "-1", "1.5", "1e3", overflow and "" are refused). */
13
+ export function parseBoardRefText(raw) {
14
+ // Trims because an env value ("DANXBOT_BOARD_ID=14 ") is often padded by the shell or a template; the tool argument below does not, since
15
+ // a caller passing " 14 " has made a mistake worth refusing (the dashboard's `parseIdText` trims the same way for URL params).
16
+ const text = raw.trim();
17
+ if (!BOARD_ID_PATTERN.test(text))
18
+ return null;
19
+ const id = Number(text);
20
+ return Number.isSafeInteger(id) ? id : null;
21
+ }
22
+ /** The `board` tool argument: a board ref as a number or its decimal text, always handed on as the number (an overflowing text is refused by the pipe). */
23
+ export const boardRefArgSchema = z
24
+ .union([z.number().int().positive(), z.string().regex(BOARD_ID_PATTERN).transform(Number)])
25
+ .pipe(z.number().int().positive().safe());
26
+ /** The `repo` tool argument (DX-6554, DEC-269): a repo's name, in the characters a name may hold (the `repos_name_url_safe` constraint), so it is safe in a URL path and a file name. */
27
+ export const repoNameArgSchema = z.string().regex(/^[A-Za-z0-9._-]+$/).refine((name) => name !== "." && name !== "..", "a repo name is not . or ..");
@@ -0,0 +1,25 @@
1
+ /**
2
+ * DX-4523 - the one bounded retry the tool-access sync uses, at boot (`index.ts`) and on every permission-stream connect
3
+ * (`permission-watch.ts`): a few attempts with a growing wait, insurance against a dashboard blip, never the primary mechanism.
4
+ */
5
+ export const SYNC_ATTEMPTS = 3;
6
+ export const SYNC_RETRY_BACKOFF_MS = 500;
7
+ /**
8
+ * Run `run` up to `attempts` times, waiting `backoffMs * attempt` between tries. The last failure is rethrown for the caller to
9
+ * decide how loud it is. `shouldStop` ends the loop quietly (the watch was stopped).
10
+ */
11
+ export async function retryBounded(run, opts) {
12
+ const backoffMs = opts.backoffMs ?? SYNC_RETRY_BACKOFF_MS;
13
+ for (let attempt = 1;; attempt++) {
14
+ if (opts.shouldStop?.())
15
+ return undefined;
16
+ try {
17
+ return await run();
18
+ }
19
+ catch (err) {
20
+ if (attempt >= opts.attempts)
21
+ throw err;
22
+ await opts.sleep(backoffMs * attempt);
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * DX-6735 - this session runs in a real checkout, the one thing a team with no worker and no GitHub credential has that can read its repo. So on connect the server tells the dashboard
3
+ * the checkout's docs root, and the team's boards of that repo stop refusing their agents' role instructions (`docsRootNotComputed`). It reports on every connect, because the first one can
4
+ * come before the repo or its board exists (`/50arms:start` signs in first), and the route changes nothing for a value already stored. Nothing depends on the answer: a folder that is no git
5
+ * checkout, or a repo the team never registered, reports nothing or changes nothing, and a failed report is only logged, so a connect never waits on it or fails for it.
6
+ *
7
+ * Only `owner/name` leaves this machine, never the remote URL, which can carry a credential.
8
+ */
9
+ import { execFile } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { promisify } from "node:util";
13
+ const execFileAsync = promisify(execFile);
14
+ /** The folder every detector checks (`DETECTED_DOCS_ROOT` in `src/issues/docs-root-detection.ts`). */
15
+ const DOCS_ROOT = "docs/pages";
16
+ /** `owner/name` of a GitHub remote, an SSH host alias (`git@github.com-work:owner/name.git`) included, or null for any other remote. */
17
+ export function githubSlugOf(remote) {
18
+ const match = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]*github[^/:]*)[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(remote.trim());
19
+ return match === null ? null : `${match[2]}/${match[3]}`;
20
+ }
21
+ const runGit = async (cwd, args) => (await execFileAsync("git", [...args], { cwd, windowsHide: true })).stdout.trim();
22
+ /** What `cwd`'s checkout says, or null for a folder that is not a checkout of a GitHub repo. */
23
+ export async function readCheckoutDocsRoot(cwd, git = runGit, exists = existsSync) {
24
+ try {
25
+ const repo = githubSlugOf(await git(cwd, ["remote", "get-url", "origin"]));
26
+ if (repo === null)
27
+ return null;
28
+ const top = await git(cwd, ["rev-parse", "--show-toplevel"]);
29
+ return { repo, docs_root: exists(join(top, ...DOCS_ROOT.split("/"))) ? DOCS_ROOT : "" };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function reportCheckoutDocsRoot(client, report) {
36
+ // This process reports on its own, never as the agent working: `requestOwn` marks it background (DX-5076, DX-5354).
37
+ return client.requestOwn({ method: "POST", path: "/me/docs-root", basePath: "/api/plan-sessions", body: { ...report } });
38
+ }
39
+ /** Started by each connect that holds an agent and never awaited by it, so it never throws. */
40
+ export function createCheckoutDocsRootReporter(client, cwd, read = readCheckoutDocsRoot) {
41
+ return async function report() {
42
+ try {
43
+ const found = await read(cwd);
44
+ if (found === null)
45
+ return;
46
+ const answer = await reportCheckoutDocsRoot(client(), found);
47
+ if (!answer.ok)
48
+ process.stderr.write(`[platform] reporting this checkout's docs root answered ${answer.status}\n`);
49
+ }
50
+ catch (err) {
51
+ process.stderr.write(`[platform] reporting this checkout's docs root failed: ${err instanceof Error ? err.message : String(err)}\n`);
52
+ }
53
+ };
54
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * DX-6692 (PLAN-116 "The agent queue"; DEC-359 on CP-43) - the clear control of a session on the ready list, as this package relays it.
3
+ *
4
+ * A session that joined the ready list works the card or plan it is handed; once that job has ended, 50 Arms asks it to get ready to clear its conversation (`/clear`) and rejoin the list. The
5
+ * clear is a handshake, the shape of compaction: 50 Arms asks, the session wraps up and says ready (`POST /api/plan-sessions/me/clear-ready`), and 50 Arms releases the clear only while the job
6
+ * is still ended. The session's stream carries it as a `clear` frame (`event: clear`, data `{"control": <control> | null}`, level-triggered: the current control on connect and whenever it
7
+ * changes; the dashboard's `src/issues/db/ready-loop.ts`). The relay does two things with each frame:
8
+ *
9
+ * - the ASK (`control.ask`) reaches the agent as an urgent record, once per ask (`ask.at` names it), so it arrives inside a running turn and wakes an idle session's `plan_events_wait`;
10
+ * - the release is written to ONE local file the plugin's ready loop reads: `~/.config/danxbot/clear/<sessionId>.json`, `{v: 1, sessionId, control: {releaseId} | null, askedAt}`.
11
+ * `control` is `null` while no clear is released; `releaseId` names one agreed clear (the plugin runs `/clear` once per `releaseId`, then rejoins with `PUT /api/plan-sessions/me/ready`,
12
+ * after which the dashboard sends `control: null` again). `askedAt` is the relay's own memory of the last ask it showed the agent, kept in the file so a restart of this server does not
13
+ * show it again; the plugin ignores it. `sessionId` is the session the file was written for (the plugin refuses a file that names another).
14
+ *
15
+ * The frame's shape mirrors the dashboard's `ClearControl`; this published package cannot import it. A frame this relay cannot read is a fault line to the agent, never silence.
16
+ */
17
+ import { homedir } from "node:os";
18
+ import { prefixedLine } from "./one-line.js";
19
+ import { sessionControlDir, sessionControlFilePath, sessionControlFilesIn } from "./session-control-file.js";
20
+ /** The longest ask line relayed whole (the compaction request's bound): the dashboard's ask fits well under it, with a job's capped label. */
21
+ export const CLEAR_ASK_MAX_CODE_POINTS = 1500;
22
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
23
+ const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
24
+ /** Why a `clear` frame's data cannot be read, or `null` when it can. */
25
+ export function invalidClearReason(value) {
26
+ if (!isRecord(value) || !("control" in value))
27
+ return "the frame has no control";
28
+ const c = value.control;
29
+ if (c === null)
30
+ return null;
31
+ if (!isRecord(c))
32
+ return "control is not an object or null";
33
+ if (c.v !== 1)
34
+ return `control version ${JSON.stringify(c.v)} is not 1`;
35
+ if (c.ask !== null && !(isRecord(c.ask) && isNonEmptyString(c.ask.at) && isNonEmptyString(c.ask.text)))
36
+ return "ask must be null or {at, text}";
37
+ if (c.release_id !== null && !isNonEmptyString(c.release_id))
38
+ return "release_id must be a string or null";
39
+ return null;
40
+ }
41
+ /** The control of a valid frame's data (`invalidClearReason` is `null`). */
42
+ export function clearControlOfFrame(value) {
43
+ const reason = invalidClearReason(value);
44
+ if (reason !== null)
45
+ throw new Error(`malformed clear frame: ${reason}`);
46
+ return value.control;
47
+ }
48
+ /** The line the agent reads for an ask: the dashboard's text on one line, prefixed like every stream line. */
49
+ export function formatClearAskLine(ask) {
50
+ return prefixedLine(ask.text, CLEAR_ASK_MAX_CODE_POINTS);
51
+ }
52
+ /** The file of session `sessionId` for `control` after the ask `askedAt` was shown last. */
53
+ export function clearFileOf(control, askedAt, sessionId) {
54
+ return { v: 1, sessionId, control: control === null || control.release_id === null ? null : { releaseId: control.release_id }, askedAt };
55
+ }
56
+ const CLEAR_DIR = "clear";
57
+ export function clearDir(home = homedir()) {
58
+ return sessionControlDir(CLEAR_DIR, home);
59
+ }
60
+ /** `~/.config/danxbot/clear/<sessionId>.json`, next to the compaction control file. */
61
+ export function clearFilePath(sessionId, home = homedir()) {
62
+ return sessionControlFilePath(CLEAR_DIR, sessionId, home);
63
+ }
64
+ export function parseClearFile(text, where) {
65
+ const parsed = JSON.parse(text);
66
+ if (!isRecord(parsed) || parsed.v !== 1 || typeof parsed.sessionId !== "string" || !("control" in parsed) || (parsed.askedAt !== null && typeof parsed.askedAt !== "string")) {
67
+ throw new Error(`${where} is not a clear file`);
68
+ }
69
+ const c = parsed.control;
70
+ if (c !== null && !(isRecord(c) && isNonEmptyString(c.releaseId)))
71
+ throw new Error(`${where}: control must be null or {releaseId}`);
72
+ return parsed;
73
+ }
74
+ /** The real store, under `home` (`sessionControlFilesIn`, as the compaction control's). */
75
+ export function clearFilesIn(home = homedir(), now = Date.now) {
76
+ return sessionControlFilesIn(CLEAR_DIR, parseClearFile, home, now);
77
+ }