@50arms/platform-mcp 0.1.442 → 0.1.444

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @50arms/platform-mcp
2
2
 
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.
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 (`session_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
4
 
5
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
6
 
@@ -9,19 +9,20 @@ Stdio MCP server fronting the 50 Arms dashboard HTTP API. Since DX-3465 it expos
9
9
  | Var | Purpose |
10
10
  |---|---|
11
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 |
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 `session_connect` returns one "not signed in" halt, and `session_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 `session_connect` answer `unknown_team` (fix `DANXBOT_TEAM`), never a silent link fallback |
14
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
15
 
16
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
17
 
18
18
  ## Signing in (DX-4390): request and approve
19
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
- - **A launch token (DX-6801, DEC-522).** `plan_connect` and `card_connect` take an optional `launch_token`: the one-use token the session that started this one passed in its prompt. A signed-out session given one redeems it once per process through the public `POST /api/access-requests/claim` with `{secret: <token>, labelId: <this session's id>}` (`labelId` is sent only on a redeem); `approved` stores the key like an approval and answers signed in, with no access request. Any other answer (`claimed`, `expired`, 404, 409 `launch_grant_refused` with its `reason`, 429) spends the token and falls through to the usual person's request, the answer carrying a `note` that names why the token was not used. A signed-in session ignores the token.
20
+ An operator session has no stored credential. Signed out, `session_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 `session_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
+ - **A machine secret and an auto-approved request (DX-6764, PLAN-128).** Every `session` access request also sends `machineSecret`: one random secret per machine and per dashboard, made the first time it is needed in `~/.config/danxbot/machine-secrets/<sha12(dashboard url)>.json` (mode 0600, written durably, then hard-linked into place so two sessions starting at once on a fresh machine end up with the same one; an unusable file is a loud error, never replaced). The dashboard keeps only its hash, so a person can let later sessions from this machine sign in by themselves; it is never sent to another dashboard, and the `machine` daemon's request and a dispatched agent's credential are unchanged. When the create answer says `autoApproved: true` no person decides the request: the first claim is not delayed, `session_connect` waits up to about 45 seconds for the key and answers signed in with no `approvalUrl` or `confirmCode`; a claim slower than that answers `{state: "signing_in"}`, still with no link. `autoApproved` is required in the create answer: a dashboard that does not send it fails the request loudly. A request left pending behaves as above.
22
+ - **A launch token (DX-6801, DEC-522).** `session_connect` takes an optional `launch_token`: the one-use token the session that started this one passed in its prompt. A signed-out session given one redeems it once per process through the public `POST /api/access-requests/claim` with `{secret: <token>, labelId: <this session's id>}` (`labelId` is sent only on a redeem); `approved` stores the key like an approval and answers signed in, with no access request. Any other answer (`claimed`, `expired`, 404, 409 `launch_grant_refused` with its `reason`, 429) spends the token and falls through to the usual person's request, the answer carrying a `note` that names why the token was not used. A signed-in session ignores the token.
22
23
 
23
24
  - **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.
24
- - **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.
25
+ - **A 401 on the stored key** deletes the file and returns the sign-in halt (in `session_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, `session_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.
25
26
  - A dispatch's `DANX_DASHBOARD_CREDENTIAL` skips all of this. There is no token-file credential source.
26
27
 
27
28
  ### Asking for one more permission (DX-4434)
@@ -34,11 +35,11 @@ The hint: every dashboard result that is `403 {error: "forbidden", permission}`
34
35
 
35
36
  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.
36
37
 
37
- - **Bootstrap set**, for every session and the whole of a signed-out one: `plan_connect`, `request_permission` and the `50arms_api` trio.
38
+ - **Bootstrap set**, for every session and the whole of a signed-out one: `session_connect`, `request_permission` and the `50arms_api` trio.
38
39
  - **Permission tools** (`attach_file` needs `cards.edit` for both targets): shown once the key holds the permission the tool's dashboard route needs.
39
40
  - **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.
40
- - **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.
41
- - 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
+ - **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 `session_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.
42
+ - A stream the dashboard refuses (any 4xx) is logged and ends the watch; that session's grants then show their tools at its next `session_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.
42
43
 
43
44
  ## Tool surface (DX-3465 — one generic tool, not one per operation)
44
45
 
@@ -58,7 +59,7 @@ All exposed as `mcp__platform__<name>` once wired through the workspace `mcp.tem
58
59
 
59
60
  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.
60
61
 
61
- 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.
62
+ 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), `session_connect` (DX-6917: binds the session to ONE plan (`target: "PLAN-n"`) or ONE card on no plan (`target: "<card id>"`, DX-5553), or lets it go with `disconnect: true`; `POST|DELETE /api/plan-sessions/me/connect`, which resolves the target; the plugin's relay loop starts on it and carries the plan's, or the 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.
62
63
 
63
64
  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}`).
64
65
 
@@ -84,7 +85,7 @@ The contract the plugin builds against:
84
85
 
85
86
  ## `restart_notice` — what a restarted session was working on (DX-3928, DX-4632)
86
87
 
87
- 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:
88
+ 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. `session_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:
88
89
 
89
90
  - `{notice: "<text>"}`: say it (the registry's `plan_restart.waiting_events` text; this package carries no wording).
90
91
  - `{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).
@@ -54,7 +54,8 @@ function isCreated(value) {
54
54
  typeof v.secret === "string" &&
55
55
  typeof v.confirmCode === "string" &&
56
56
  typeof v.approvalUrl === "string" &&
57
- typeof v.pollMs === "number");
57
+ typeof v.pollMs === "number" &&
58
+ typeof v.autoApproved === "boolean");
58
59
  }
59
60
  export async function createAccessRequest(fetchImpl, dashboardUrl, input) {
60
61
  const url = `${baseOf(dashboardUrl)}/api/access-requests`;
@@ -14,8 +14,8 @@
14
14
  * substitutes it into the materialized `.mcp.json` from the dispatch overlay. Authoritative
15
15
  * when set, and it never signs out.
16
16
  * - a SESSION KEY (`session-key.ts`) - the key a user approved for this one session through
17
- * `plan_connect`. An operator session has no other credential: with neither of these it boots
18
- * signed out, and `plan_connect` asks for access.
17
+ * `session_connect`. An operator session has no other credential: with neither of these it boots
18
+ * signed out, and `session_connect` asks for access.
19
19
  *
20
20
  * There is no token-file source (DX-4390): a long-lived bearer placed on disk by hand is exactly
21
21
  * what request-and-approve replaces.
@@ -60,7 +60,7 @@ export function readCredential(source, env) {
60
60
  return readSessionKeyFile(source.path);
61
61
  }
62
62
  catch (err) {
63
- throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`, "call plan_connect to request access again; the key file is written when the request is approved");
63
+ throw new CredentialError(`cannot read ${describeCredentialSource(source)}: ${err.message}`, "call session_connect to request access again; the key file is written when the request is approved");
64
64
  }
65
65
  }
66
66
  /**
package/dist/handlers.js CHANGED
@@ -23,25 +23,24 @@
23
23
  */
24
24
  import { posix, win32 } from "node:path";
25
25
  import { readFile, stat } from "node:fs/promises";
26
- // ---------------- plan_connect (DX-2683) ----------------
26
+ // ---------------- session_connect (DX-2683, DX-5553, DX-6917) ----------------
27
27
  // DX-3465 (PLN-11 R-25) — every other per-operation handler that used to
28
- // live in this file (issue_*, plan_* except plan_connect, brief_*,
28
+ // live in this file (issue_*, plan_*, brief_*,
29
29
  // repo_knowledge_*, failure_category_*, dispatch_transcript_search,
30
30
  // quality_gate_instruction) is deleted: `danxbotApi`/`danxbotApiSpec` below
31
- // are the generic replacement. `plan_connect` stays a dedicated tool because
31
+ // are the generic replacement. `session_connect` stays a dedicated tool because
32
32
  // it is session-bound (see `index.ts`'s own comment on this).
33
33
  const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
34
34
  /**
35
- * Connect THIS session to a plan — the same write the operator's Connect
36
- * action performs, reaching the same server-side code path. `me` in the URL
37
- * is resolved from the forwarded session id, so this can only ever bind the
38
- * caller's own session.
35
+ * Connect THIS session to a plan or to ONE stand-alone card (a card on no plan, DX-5553), or let it go: `POST|DELETE /api/plan-sessions/me/connect`. `me` in the URL
36
+ * is resolved from the forwarded session id, so this can only ever bind the caller's own session. The same writes the operator's Connect action performs
37
+ * for a plan, reaching the same server-side code path.
39
38
  *
40
- * A session already on another plan is MOVED, and the response says which
41
- * plan it left (`movedFrom`).
39
+ * A session already on another plan or card is MOVED, and the response says which
40
+ * it left (`movedFrom` / `leftPlan` / `leftCard`). The answer carries what the session missed (`missedEvents`) and the exact thread title to take (`naming`).
42
41
  *
43
- * IT DOES NOTHING ABOUT HEARING THE PLAN'S EVENTS, deliberately. Delivery belongs
44
- * to the plan event relay inside this MCP server (`plan_events_wait`, `plan-events-relay.ts`),
42
+ * IT DOES NOTHING ABOUT HEARING THE EVENTS, deliberately. Delivery belongs
43
+ * to the event relay inside this MCP server (`plan_events_wait`, `plan-events-relay.ts`),
45
44
  * which holds the session's ONE listener ticket and is started by the 50arms plugin's loop once this tool
46
45
  * succeeds. The dashboard keeps one ticket per session, so a ticket minted here
47
46
  * would end the relay's stream.
@@ -53,25 +52,20 @@ const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
53
52
  * The event relay keeps it current afterwards, from the transcript (DX-4461,
54
53
  * `session-title.ts`).
55
54
  */
56
- export async function planConnect(client, args) {
55
+ export async function sessionConnect(client, args) {
57
56
  if (args.disconnect === true) {
58
57
  if (args.title !== undefined) {
59
- return { ok: false, status: 400, body: { error: "plan_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
58
+ return { ok: false, status: 400, body: { error: "session_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
60
59
  }
61
- // DX-2985 — `DELETE /api/plan-sessions/me/plan?plan_id=`: `me` is the forwarded session id, so only the caller's own session leaves.
62
- return client.request({
63
- method: "DELETE",
64
- path: "/me/plan",
65
- basePath: PLAN_SESSIONS_BASE_PATH,
66
- query: { plan_id: args.plan_id },
67
- });
60
+ // DX-2985 — `DELETE /api/plan-sessions/me/connect?target=`: `me` is the forwarded session id, so only the caller's own session leaves.
61
+ return client.request({ method: "DELETE", path: "/me/connect", basePath: PLAN_SESSIONS_BASE_PATH, query: { target: args.target } });
68
62
  }
69
63
  return client.request({
70
64
  method: "POST",
71
- path: "/me/plan",
65
+ path: "/me/connect",
72
66
  basePath: PLAN_SESSIONS_BASE_PATH,
73
67
  body: {
74
- plan_id: args.plan_id,
68
+ target: args.target,
75
69
  ...(args.title === undefined ? {} : { title: args.title }),
76
70
  ...(args.assignment_id === undefined ? {} : { assignment_id: args.assignment_id }),
77
71
  },
@@ -85,25 +79,6 @@ export async function planConnect(client, args) {
85
79
  export async function claimSessionAgent(client) {
86
80
  return client.request({ method: "POST", path: "/me/agent", basePath: PLAN_SESSIONS_BASE_PATH });
87
81
  }
88
- /**
89
- * DX-5553 - hold ONE stand-alone card (a card on no plan) instead of a plan, or let it go: `POST|DELETE /api/plan-sessions/me/card`. `me` is the forwarded session id, so only
90
- * the caller's own session is ever bound. Like `plan_connect`, the answer carries what the session missed (`missedEvents`) and the exact thread title to take (`naming`); the
91
- * session then hears that card's and its descendants' events through the same relay as a plan's.
92
- */
93
- export async function cardConnect(client, args) {
94
- if (args.disconnect === true) {
95
- if (args.title !== undefined) {
96
- return { ok: false, status: 400, body: { error: "card_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
97
- }
98
- return client.request({ method: "DELETE", path: "/me/card", basePath: PLAN_SESSIONS_BASE_PATH, query: { card_id: args.card_id } });
99
- }
100
- return client.request({
101
- method: "POST",
102
- path: "/me/card",
103
- basePath: PLAN_SESSIONS_BASE_PATH,
104
- body: { card_id: args.card_id, ...(args.title === undefined ? {} : { title: args.title }), ...(args.assignment_id === undefined ? {} : { assignment_id: args.assignment_id }) },
105
- });
106
- }
107
82
  // DX-3465 (code-architecture gate finding, at 275f82c0d) — the dedicated
108
83
  // `resource_fields` handler (DX-3426) was deleted from here: it was a thin
109
84
  // wrapper around `GET /api/resources/:resource/fields`, which `50arms_api`
@@ -60,7 +60,7 @@ export class DashboardHttpClient {
60
60
  //
61
61
  // NO TITLE HEADER (DX-2816, revised). This server has no way to read
62
62
  // Claude's own session title — see `readSessionConfig` in `index.ts`.
63
- // The title travels instead as `plan_connect`'s own `title` argument,
63
+ // The title travels instead as `session_connect`'s own `title` argument,
64
64
  // straight in that call's body, because the AGENT (not this process) is
65
65
  // the one thing able to read it.
66
66
  if (this.config.session) {
@@ -158,7 +158,7 @@ export class DashboardHttpClient {
158
158
  // DX-4592: a board-less server (the 50arms plugin's, no DANXBOT_BOARD_ID) stamps nothing when the call names no
159
159
  // board. Whether the route NEEDS one is the dashboard's to say: a route that does (quality-gate and settings routes, the dispatch
160
160
  // verbs) answers 400 naming `board`, while a card-id, plan or plan-session route takes its board from the card
161
- // or the plan. Refusing here (DX-4555) wrongly stopped every plan route, `plan_connect` first.
161
+ // or the plan. Refusing here (DX-4555) wrongly stopped every plan route, `session_connect` first.
162
162
  if (board !== undefined)
163
163
  params.set("board", String(board));
164
164
  }
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * - 50arms_api the generic surface for every non-agent-specific dashboard
15
15
  * operation (DX-3465 / PLN-11 R-25) — list/get/create/update/delete
16
16
  * against the uniform resource routes that used to each have their
17
- * own per-operation tool (issue_*, plan_* (except plan_connect),
17
+ * own per-operation tool (issue_*, plan_*,
18
18
  * brief_*, repo_knowledge_*, failure_category_*,
19
19
  * dispatch_transcript_search, quality_gate_instruction — all deleted
20
20
  * here). See `SERVER_INSTRUCTIONS` below for the calling convention.
@@ -28,9 +28,10 @@
28
28
  * with the approval URL and confirm code, and the grant is picked up in
29
29
  * the background (`permission-request.ts`). A dashboard call refused
30
30
  * `403 forbidden` carries a hint to call it (`forbidden-hint.ts`).
31
- * - plan_connect POST /api/plan-sessions/me/plan — SESSION-BOUND (kept as a
32
- * dedicated tool; every plan READ or WRITE operation goes
33
- * through 50arms_api now, e.g. GET /api/plans/mine or
31
+ * - session_connect POST|DELETE /api/plan-sessions/me/connect — SESSION-BOUND (a
32
+ * dedicated tool; one tool for a plan and a card, named by its
33
+ * `target`; every plan READ or WRITE operation goes
34
+ * through 50arms_api, e.g. GET /api/plans/mine or
34
35
  * POST /api/plans/mine/records)
35
36
  *
36
37
  * DX-2683, updated DX-3465 — PLAN WRITES ARE SESSION-BOUND, and
@@ -40,7 +41,7 @@
40
41
  * a plan write is an ordinary `50arms_api` call to a `/api/plans/mine/...`
41
42
  * route. Reads may name any plan by id (`/api/plans/:id/...`); a WRITE
42
43
  * route instead takes NO plan id at all and acts on the plan this session
43
- * is connected to (`plan_connect`), resolved server-side from the session
44
+ * is connected to (`session_connect`), resolved server-side from the session
44
45
  * id this package forwards on every request. A session therefore cannot
45
46
  * modify a plan it is not connected to, whatever id it believes. A write
46
47
  * from an unconnected session is refused loudly (`session_not_connected`),
@@ -58,8 +59,8 @@
58
59
  * DANX_DASHBOARD_CREDENTIAL the bearer verbatim (a dispatch, substituted into
59
60
  * the materialized `.mcp.json` from its overlay)
60
61
  * otherwise this session's approved key (`session-key.ts`), else none: the
61
- * server boots SIGNED OUT, every tool but `plan_connect`
62
- * halts, and `plan_connect` requests access (`session-access.ts`)
62
+ * server boots SIGNED OUT, every tool but `session_connect`
63
+ * halts, and `session_connect` requests access (`session-access.ts`)
63
64
  * DANXBOT_TEAM OPTIONAL (DX-4446), non-secret: the team slug (or id) whose
64
65
  * approvers see this session's access request in their danxbot
65
66
  * site. Set it in your shell or a repo's committed `.claude/settings.json` `env` block.
@@ -103,7 +104,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
103
104
  import { z } from "zod";
104
105
  import { boardRefArgSchema, parseBoardRefText, repoNameArgSchema } from "./board-id.js";
105
106
  import { DashboardHttpClient } from "./http-client.js";
106
- import { cardConnect, claimSessionAgent, planConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
107
+ import { claimSessionAgent, sessionConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
107
108
  import { miniAppPublish } from "./mini-app-publish.js";
108
109
  import { miniAppRestore } from "./mini-app-restore.js";
109
110
  import { pluginPublish } from "./plugin-publish.js";
@@ -180,7 +181,7 @@ let listenerReminder;
180
181
  * DX-4390 — this server's credential: a dispatch's env bearer, this session's approved key, or
181
182
  * none (signed out). The plan event relay in this server signs in with it directly (`access.currentToken()`).
182
183
  * `restart_notice` runs against other sessions' credentials and cannot see it, so `access.connection()` (the URL, where the credential is, its
183
- * fingerprint; never the credential itself) is written to the session's connection record on a successful `plan_connect`
184
+ * fingerprint; never the credential itself) is written to the session's connection record on a successful `session_connect`
184
185
  * for it.
185
186
  */
186
187
  let access;
@@ -308,7 +309,7 @@ async function readHeldPermissions() {
308
309
  /**
309
310
  * How hard a failed permission read is for this session. A dispatch cannot work without its tools, so it retries a few times (a
310
311
  * dashboard blip at spawn must not cost it its MCP server) and then fails the boot; an operator session logs it and keeps the
311
- * bootstrap set until its next `plan_connect` or the permission stream's next connect.
312
+ * bootstrap set until its next `session_connect` or the permission stream's next connect.
312
313
  */
313
314
  function syncPolicy() {
314
315
  const isDispatch = access.connection()?.source.kind === "env";
@@ -327,7 +328,7 @@ async function syncToolsWithPolicy() {
327
328
  }
328
329
  /**
329
330
  * DX-4523 - show the tools the key's permissions allow, and watch for a grant. Called at boot and again whenever a
330
- * `plan_connect` finds the session signed in, which is the moment a first approval or a re-approved key takes effect.
331
+ * `session_connect` finds the session signed in, which is the moment a first approval or a re-approved key takes effect.
331
332
  */
332
333
  async function syncToolsAfterSignIn() {
333
334
  if (!access.isSignedIn())
@@ -336,7 +337,7 @@ async function syncToolsAfterSignIn() {
336
337
  permissionWatch.start();
337
338
  }
338
339
  /**
339
- * DX-6157 - a person created the agent this session proposed, and the dashboard signed the session in as it (and connected it to its plan or card) without a `plan_connect` from the session. The
340
+ * DX-6157 - a person created the agent this session proposed, and the dashboard signed the session in as it (and connected it to its plan or card) without a `session_connect` from the session. The
340
341
  * session therefore never saw a connect answer, so this does here what one that binds does: it opens the agent gate (`50arms_api` stops answering `agent_needed`), shows the tools the key's
341
342
  * permissions allow, restarts the listener reminder's health read and, for a session now on a plan or card, writes the local connection record `restart_notice` and the listener reminder read.
342
343
  * Called by the relay's listener as it writes the notice; a failure is a stderr line, never silence (the notice itself still reaches the session).
@@ -346,7 +347,7 @@ function adoptCreatedAgent(created) {
346
347
  listenerReminder.invalidate();
347
348
  const connection = access.connection();
348
349
  if (created.plan !== null || created.card !== null) {
349
- // DX-6157: the frame arrived over this key's own stream, so no credential here is a broken invariant: said loudly, as plan_connect throws on it.
350
+ // DX-6157: the frame arrived over this key's own stream, so no credential here is a broken invariant: said loudly, as session_connect throws on it.
350
351
  if (connection === null) {
351
352
  console.error("agent created: no credential after the dashboard signed this session in; the local connection record was not written");
352
353
  }
@@ -405,8 +406,8 @@ function resolveInitialCredential(dashboardUrl, sessionId) {
405
406
  * MCP-server-reachable signal carries Claude's own session title — this
406
407
  * SERVER genuinely cannot read it. The AGENT can, though (a Desktop-side
407
408
  * `get_session` tool this server has no access to), and the agent is what
408
- * calls `plan_connect` — so the title travels as that tool's own `title`
409
- * argument (see `handlers.ts#planConnect`) at connect time, not as a header
409
+ * calls `session_connect` — so the title travels as that tool's own `title`
410
+ * argument (see `handlers.ts#sessionConnect`) at connect time, not as a header
410
411
  * stamped here. DX-4461: the session's TRANSCRIPT carries every rename as a
411
412
  * `custom-title` record, so the event relay in this server reads it from
412
413
  * there and reports it on its heartbeat (`session-title.ts`) and a later rename needs no
@@ -420,7 +421,7 @@ function readSessionConfig() {
420
421
  return undefined;
421
422
  return { id };
422
423
  }
423
- // DX-5549: whether this session holds a team agent; closed by a plan_connect answer of `agent_needed` / `agent_limit_reached`, opened only by one carrying an agent.
424
+ // DX-5549: whether this session holds a team agent; closed by a session_connect answer of `agent_needed` / `agent_limit_reached`, opened only by one carrying an agent.
424
425
  const agentGate = new AgentGate();
425
426
  /**
426
427
  * DX-3465 (PLN-11 R-25) — the conventions shared by every `50arms_api` call,
@@ -473,7 +474,7 @@ function connectedDashboard() {
473
474
  return dashboardIdentity(config.baseUrl);
474
475
  }
475
476
  /**
476
- * DX-4390 — `plan_connect`'s answer while access is not in place: the request (or why there is none),
477
+ * DX-4390 — `session_connect`'s answer while access is not in place: the request (or why there is none),
477
478
  * an error when it names a failure. `note` says why a new request was needed.
478
479
  */
479
480
  function accessReply(ready, note) {
@@ -862,52 +863,50 @@ strictTool("request_permission", "Ask a person to grant this key ONE MORE permis
862
863
  }
863
864
  return jsonResult(asked.answer);
864
865
  });
865
- // ---------------- plans (DX-2683) ----------------
866
+ // ---------------- session connect (DX-2683, DX-5553, DX-6917) ----------------
866
867
  // THE BINDING ASYMMETRY IS IN THE SCHEMAS, NOT IN PROSE. `GET /api/plans/:planId`
867
868
  // takes a plan id in its path; the plan records, plan cards and
868
869
  // architecture-section routes under `/api/plans/mine/...` take NO plan id in
869
870
  // any form, so a connected session cannot even express "write to that other
870
- // plan". `plan_connect` takes a plan id because binding a session to a plan
871
- // is the one operation that is ABOUT a plan id — and it can only ever bind
871
+ // plan". `session_connect` takes a target (`PLAN-n`, or a card's id) because binding a session
872
+ // is the one operation that is ABOUT a plan or card id, and it can only ever bind
872
873
  // the caller's own session. `POST /api/plans` also takes no plan id, but for
873
874
  // a different reason: it MAKES a plan rather than acting on one, so there is
874
875
  // no existing plan for an id to name yet.
875
- strictTool("plan_connect",
876
+ strictTool("session_connect",
877
+ // DX-6917 - the one connect tool: what used to be `plan_connect` (a plan) and `card_connect` (DX-5553, a card on no plan). The dashboard resolves `target`; this package never parses it.
876
878
  // DX-4493 — the `naming` block carries the thread title the session must take before it connects.
877
879
  // DX-4318 — states only what to do with the reply. What it used to add arrives where it is used: a rejected
878
880
  // action's rule in the event line itself (`listen.ts`), the mantra in the reply's own `reminders[]` text.
879
881
  // DX-4390 — signed out, this is also how the session asks for access.
880
- "Connect THIS session to a plan; one call is enough to start. Signed out, it requests access instead: " +
882
+ // DX-4608 / DX-5549: a title in the format naming another agent than the held one is refused.
883
+ "Connect THIS session to ONE plan or card, named by `target`: `PLAN-n`, or a card's id for a card on no plan. One call is enough to start; omit `target` only to sign in (it still takes your team agent). Signed out, it requests access: " +
881
884
  "the reply is `state: approval_required` or `approval_pending` with an `approvalUrl` and `confirmCode` " +
882
- "(show the code, open the URL, then call again; it waits until this session ends; a call re-asks an expired one). " +
883
- "Signed in, the reply carries `owedComments` (person comments no agent has answered yet: read first, answer each, ack then reply) and `missedEvents` (operator " +
884
- "events no session had been told about: read next), " +
885
- "`browserInstruction` (do it now), `briefing` (goals, rules, caveats, architecture " +
886
- "titles and a first page of open cards) and `listenerHealth` (when its `state` is not `healthy`, do its " +
887
- "`nextStep`), and `docsLandJob` (on a docs land assignment: the conflict job's text, or `unavailable` with why). A session is on at most one plan: connecting elsewhere moves it (`movedFrom` names the " +
888
- "plan it left). A plan holds one live session: another on it refuses the connect (`plan_has_live_session`, " +
889
- "naming that session); do not retry: tell the person and connect elsewhere. A session whose plan another took while it was quiet is refused its next `/api/plans/mine/...` call: connect elsewhere or ask the person. Afterwards `50arms_api` calls to `/api/plans/mine/...` act on this plan, no plan id. " +
890
- "Connecting signs you in as a team agent (`agent`, `agentInstruction`). " +
891
- "No agent to sign in as: `agent_needed` (none exists for you; propose one) or `agent_limit_reached` (the cap on agent sessions is full; the person ends another or raises it), nothing connects: follow its `instruction`, " +
892
- "no other dashboard work until `plan_connect` holds an agent. A `naming` block (status `needed`) gives the exact " +
893
- "`title`, `{Name}: PLAN-NNN {short title}`; rename this thread to it (`set_session_title`, or ask for `/rename`), then connect again with that `title`. " +
894
- // DX-4608 / DX-5549: a title in the format naming another agent than the held one is refused.
885
+ "(show the code, open the URL, then call again; a call re-asks an expired one). " +
886
+ "Signed in, the reply carries `owedComments` (person comments no agent has answered yet: read first, answer each, ack then reply), `missedEvents` (operator " +
887
+ "events no session had been told about: read next), `naming` and `listenerHealth` (when its `state` is not `healthy`, do its `nextStep`); " +
888
+ "for a plan also `browserInstruction` (do it now), `briefing` (goals, rules, caveats, architecture, open cards) and `docsLandJob` (a docs land assignment's job). " +
889
+ "A session holds a plan or a card, never both: connecting elsewhere moves it (`movedFrom` names the plan it left). " +
890
+ "A plan holds one live session: another on it refuses the connect (`plan_has_live_session`); do not retry. " +
891
+ "A card is refused `card_on_plan` when it or a card above it is on a plan (connect to that plan) and `card_held` when another live session holds it; holding one assigns it to you, like a manual pickup. " +
892
+ "Afterwards `50arms_api` calls to `/api/plans/mine/...` act on your plan, no plan id. Connecting signs you in as a team agent; with none to sign in as: `agent_needed` or `agent_limit_reached` (the cap is full: ask the person), nothing connects: follow its `instruction`, " +
893
+ "no other dashboard work until `session_connect` holds an agent. A `naming` block (status `needed`) gives the exact " +
894
+ "`title`, `{Name}: PLAN-NNN {short title}` or `{Name}: {CARD-ID} {card title}`; rename this thread to it (`set_session_title`, or ask for `/rename`), then connect again with that `title`. " +
895
895
  "A `title` in that format naming another agent than yours is refused `session_title_not_agent_name` with the title to use.", {
896
- plan_id: z
897
- .number()
898
- .int()
899
- .positive()
896
+ target: z
897
+ .string()
898
+ .min(1)
900
899
  .optional()
901
- .describe("The plan's number within your team (the `n` of `PLAN-n`; a plan's `ref` in `50arms_api` `GET /api/plans`). Omit only to sign in (it still takes your team agent)."),
900
+ .describe("What to hold: `PLAN-n` (a plan's `ref` in `50arms_api` `GET /api/plans`) or a card's id as the board shows it, for a card on no plan. Omit only to sign in (it still takes your team agent)."),
902
901
  title: z
903
902
  .string()
904
903
  .min(1)
905
904
  .optional()
906
905
  .describe(
907
906
  // DX-4608: stored as sent; a wrong one does not refuse the connect, the reply's `naming` says what to rename it to.
908
- 'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
907
+ 'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}` or `{Name}: {CARD-ID} {card title}`, after renaming this thread. Omit on the first connect.'),
909
908
  assignment_id: z.number().int().positive().optional().describe("The assignment this connect answers, when a person's assignment message names one. Refused `assignment_ended` once it is over: then do nothing more about it."),
910
- disconnect: z.boolean().optional().describe("true = leave `plan_id` (your current plan); no `title`."),
909
+ disconnect: z.boolean().optional().describe("true = leave `target` (the plan or card you hold); no `title`."),
911
910
  launch_token: z
912
911
  .string()
913
912
  .min(1)
@@ -921,21 +920,21 @@ strictTool("plan_connect",
921
920
  "Set only by the plugin's Sign in button when a person presses it; an agent never sets it. On a session whose key a person revoked it lifts the stop and starts a new access request a person must approve; any call without it still answers the stop."),
922
921
  },
923
922
  // DX-2862 — the connect is also the moment the session's connection record is written for `restart_notice` and the listener reminder: of the
924
- // dashboard MCP servers a session may have configured, the one that connected the plan is the one whose credential they
923
+ // dashboard MCP servers a session may have configured, the one that connected the plan or card is the one whose credential they
925
924
  // must resolve (the relay needs none of this: it lives in each server, with that server's own credential).
926
925
  async (args) => {
927
926
  // DX-6894 — a person's Sign in press leaves the revoked stop; every call without it still answers that stop.
928
927
  if (args.person_sign_in === true)
929
928
  access.personSignIn();
930
- // DX-4390 — a leave names the plan it leaves, and needs a key like any other call.
931
- if (args.disconnect === true && args.plan_id === undefined) {
932
- return haltResult("plan_connect: `disconnect: true` needs `plan_id`, the plan this session is currently on");
929
+ // DX-4390 — a leave names what it leaves, and needs a key like any other call.
930
+ if (args.disconnect === true && args.target === undefined) {
931
+ return haltResult("session_connect: `disconnect: true` needs `target`, the plan or card this session currently holds");
933
932
  }
934
933
  // DX-4390 — no key yet: ask for one (or wait for the approval already asked for).
935
934
  const ready = await access.ensureAccess(args.title, args.launch_token);
936
935
  if (!ready.signedIn)
937
936
  return accessReply(ready);
938
- if (args.plan_id === undefined) {
937
+ if (args.target === undefined) {
939
938
  // DX-4523 - signed in (possibly just now): show the tools this key's permissions allow.
940
939
  await syncToolsAfterSignIn();
941
940
  // DX-5549: signing in takes a team agent. The dashboard's answer (the agent and the instruction to use its name, or why there is none) is the reply.
@@ -950,11 +949,11 @@ async (args) => {
950
949
  void reportCheckoutDocsRoot();
951
950
  return jsonResult({
952
951
  ...claimed.body,
953
- ...(agentGate.current() === null ? { instruction: "This session is signed in. Call `plan_connect` with `plan_id` to connect it to a plan." } : {}),
952
+ ...(agentGate.current() === null ? { instruction: "This session is signed in. Call `session_connect` with `target` to connect it to a plan or a card." } : {}),
954
953
  });
955
954
  }
956
955
  // DX-6089: the role text lands in a local file; the reply names it (`role-instruction-file.ts`).
957
- const result = spillRoleInstruction(await planConnect(client, { ...args, plan_id: args.plan_id }));
956
+ const result = spillRoleInstruction(await sessionConnect(client, { ...args, target: args.target }));
958
957
  // DX-4418 — a person revoked this key: the stop halt, and nothing asks again.
959
958
  const revokedHalt = access.refusalHalt(result);
960
959
  if (revokedHalt !== null)
@@ -963,12 +962,12 @@ async (args) => {
963
962
  if (result.status === 401 && access.dropKey(result)) {
964
963
  return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
965
964
  }
966
- return finishConnect("plan_connect", result, args.disconnect === true);
965
+ return finishConnect("session_connect", result, args.disconnect === true);
967
966
  });
968
967
  // DX-6735: started by each connect that holds an agent, and never awaited by it.
969
968
  const reportCheckoutDocsRoot = createCheckoutDocsRootReporter(() => client, process.cwd());
970
969
  /**
971
- * What `plan_connect` and `card_connect` do with a connect answer once the key is known good (DX-6157 - one path, so a wiring test through either tool covers it), and `50arms_api` with a manual pickup that connected (DX-6556).
970
+ * What `session_connect` does with a connect answer once the key is known good (DX-6157 - one path for a plan and a card), and `50arms_api` with a manual pickup that connected (DX-6556).
972
971
  * DX-5549: the dashboard connects only a session that holds a team agent. With none it answers why (`agent_needed` / `agent_limit_reached`) and binds nothing: that answer is handed to the agent
973
972
  * unchanged and every other dashboard tool stays closed until a later connect finds an agent. The tools are shown and the listener reminder reset BEFORE that block is handed back (DX-4523, DX-3997),
974
973
  * so a session whose key was just approved can run `plan_events_wait` and hear the person's Create. A leave (DX-2985) drops the local connection record; a connect writes it.
@@ -1001,43 +1000,6 @@ async function finishConnect(tool, result, disconnect) {
1001
1000
  }),
1002
1001
  });
1003
1002
  }
1004
- // ---------------- card_connect (DX-5553) ----------------
1005
- strictTool("card_connect",
1006
- // DX-5553 - the stand-alone counterpart of `plan_connect`: one card on no plan instead of a plan.
1007
- "Hold ONE card that is on no plan instead of a plan: for a one-off, a quick fix or an epic that is not a plan. You then hear that card's and its descendants' events, " +
1008
- "and no other card's. The reply carries `owedComments` (every person comment on it no agent has answered yet, oldest first: read them first and answer each, ack then reply), `missedEvents` (operator events no session had been told about on it: read them next), `naming` and `agent`/`agentInstruction`. " +
1009
- "Refused `card_on_plan` when the card or any card above it is on a plan (connect to that plan with `plan_connect`), `card_held` when another live session holds it. " +
1010
- "A session holding no team agent is first signed in as one, as `plan_connect` does: with none free the answer is `agent_needed` (propose one) or `agent_limit_reached`, and nothing is held. " +
1011
- "Holding it assigns the card to you, like a manual pickup; a card " +
1012
- "with children is held as a whole and its children are picked up normally. A session holds a plan or a card, never both: connecting to either lets go of the other. " +
1013
- "A `naming` block (status `needed`) gives the exact `title`, `{Name}: {CARD-ID} {card title}`; rename this thread to it (`set_session_title`, or ask for `/rename`), then connect " +
1014
- "again with that `title`. A card later added to a plan moves you off it: you are told on your next plan call.", {
1015
- card_id: z.string().min(1).describe("The id of the card to hold, as the board shows it."),
1016
- title: z
1017
- .string()
1018
- .min(1)
1019
- .optional()
1020
- .describe("The `naming` block's exact `{Name}: {CARD-ID} {card title}`, after renaming this thread. Omit on the first connect."),
1021
- assignment_id: z.number().int().positive().optional().describe("The assignment this connect answers, when a person's assignment message names one. Refused `assignment_ended` once it is over: then do nothing more about it."),
1022
- disconnect: z.boolean().optional().describe("true = let go of `card_id` (the card you hold); no `title`."),
1023
- launch_token: z
1024
- .string()
1025
- .min(1)
1026
- .optional()
1027
- .describe("The one-use token the session that started this one passed in its prompt. With it a signed-out session signs in with no person approving it; a token that cannot be used says why and the session asks a person as usual. Omit it otherwise."),
1028
- }, async (args) => {
1029
- const ready = await access.ensureAccess(args.title, args.launch_token);
1030
- if (!ready.signedIn)
1031
- return accessReply(ready);
1032
- const result = spillRoleInstruction(await cardConnect(client, args));
1033
- const revokedHalt = access.refusalHalt(result);
1034
- if (revokedHalt !== null)
1035
- return haltResult(revokedHalt);
1036
- if (result.status === 401 && access.dropKey(result)) {
1037
- return accessReply(await access.ensureAccess(args.title), "the dashboard no longer accepted this session's key");
1038
- }
1039
- return finishConnect("card_connect", result, args.disconnect === true);
1040
- });
1041
1003
  // ---------------- plan_events_wait (DX-4233) ----------------
1042
1004
  strictTool("plan_events_wait",
1043
1005
  // DX-4882 - `plan_id` is null for a signed-in session on no plan: its stream still carries its account's pacing and resume messages.
@@ -26,5 +26,5 @@ export function keyRevokedHalt(revocation, dispatched) {
26
26
  return (`STOP ALL WORK NOW. ${revocation.revokedBy} revoked your access to the 50 Arms dashboard at ${revocation.revokedAt}.\n\n` +
27
27
  "Commit your work in progress now (commit what you have; do not run agent-finalize.sh and do not merge). " +
28
28
  `${ending}\n\n` +
29
- "Do not call plan_connect or any other 50 Arms dashboard tool again, do not request access again and do not look for another route.");
29
+ "Do not call session_connect or any other 50 Arms dashboard tool again, do not request access again and do not look for another route.");
30
30
  }
package/dist/listen.js CHANGED
@@ -613,7 +613,7 @@ export function formatAgentCreatedLine(event) {
613
613
  const held = event.plan !== null ? "this agent and plan" : event.card !== null ? "this agent and card" : "this agent";
614
614
  const ownCalls = thread !== "" || event.rolePointer !== null ? "; the steps below keep their own calls" : "";
615
615
  const parts = [
616
- `${LINE_PREFIX} The person created your team agent ${oneLine(event.agent.name, 60)}: this session is now signed in as it${where}. Taking ${held} needs no \`plan_connect\`${ownCalls}.`,
616
+ `${LINE_PREFIX} The person created your team agent ${oneLine(event.agent.name, 60)}: this session is now signed in as it${where}. Taking ${held} needs no \`session_connect\`${ownCalls}.`,
617
617
  oneLine(event.agentInstruction, 800),
618
618
  thread === "" ? "" : oneLine(thread, 800),
619
619
  event.owedComments === null ? "" : oneLine(event.owedComments),
@@ -0,0 +1,93 @@
1
+ /**
2
+ * DX-6764 (PLAN-128) - the secret that proves which machine a session's access request comes from.
3
+ *
4
+ * One random secret per machine and per dashboard, kept in `~/.config/danxbot/machine-secrets/<sha12(dashboard url)>.json`
5
+ * (mode 0600). A `session` access request sends it so a person can let later sessions from this machine sign in by themselves
6
+ * (the dashboard keeps only its hash). It is made the first time it is needed and never changes after, and it is only ever sent to
7
+ * the dashboard its file is named for: a different dashboard URL is a different file and a different secret.
8
+ *
9
+ * TWO SESSIONS ON A FRESH MACHINE. Both find no file and both make a candidate; a plain write-then-rename would let the second
10
+ * replace the first, so the two sessions would send different secrets. The candidate is written (durably) to its own temp name, then
11
+ * HARD-LINKED to the target: a link never replaces an existing file, so exactly one candidate wins, and every session reads the
12
+ * target back and uses what is there. A file that exists but is unusable is a loud error, never replaced: replacing it would
13
+ * silently orphan any grant a person made for the secret it held.
14
+ */
15
+ import { createHash, randomBytes } from "node:crypto";
16
+ import { linkSync, readFileSync, unlinkSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { writeFileDurable } from "./durable-write.js";
20
+ /** One canonical shape (CLAUDE.md Core Principle 1): a file of any other version is refused. */
21
+ export const MACHINE_SECRET_SCHEMA_VERSION = 1;
22
+ /** The dashboard refuses a shorter secret (`MACHINE_SECRET_MIN` in `src/dashboard/access-request-routes.ts`); 32 random bytes as hex is twice that. */
23
+ const SECRET_BYTES = 32;
24
+ const SECRET_MIN_LENGTH = 32;
25
+ const dashboardHash = (dashboardUrl) => createHash("sha256").update(dashboardUrl.replace(/\/+$/, "")).digest("hex").slice(0, 12);
26
+ export function machineSecretPath(dashboardUrl, home = homedir()) {
27
+ return join(home, ".config", "danxbot", "machine-secrets", `${dashboardHash(dashboardUrl)}.json`);
28
+ }
29
+ function unusable(path, why) {
30
+ return new Error(`[platform-mcp] the machine secret file ${path} is unusable: ${why}; fix or delete it, then call session_connect again`);
31
+ }
32
+ /** The secret in `path`, or null when the file is absent. A file that is there but of another shape throws. */
33
+ function readSecret(path, dashboardUrl) {
34
+ let raw;
35
+ try {
36
+ raw = readFileSync(path, "utf-8");
37
+ }
38
+ catch (err) {
39
+ if (err.code === "ENOENT")
40
+ return null;
41
+ throw unusable(path, err.message);
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ }
47
+ catch (err) {
48
+ throw unusable(path, `not JSON (${err.message})`);
49
+ }
50
+ if (typeof parsed !== "object" || parsed === null)
51
+ throw unusable(path, "it is not an object");
52
+ const record = parsed;
53
+ if (record.schemaVersion !== MACHINE_SECRET_SCHEMA_VERSION) {
54
+ throw unusable(path, `schemaVersion is ${JSON.stringify(record.schemaVersion)}, not ${MACHINE_SECRET_SCHEMA_VERSION}`);
55
+ }
56
+ if (typeof record.secret !== "string" || record.secret.length < SECRET_MIN_LENGTH) {
57
+ throw unusable(path, `secret is not a string of at least ${SECRET_MIN_LENGTH} characters`);
58
+ }
59
+ if (typeof record.dashboardUrl !== "string" || record.dashboardUrl.replace(/\/+$/, "") !== dashboardUrl.replace(/\/+$/, "")) {
60
+ throw unusable(path, `it names the dashboard ${JSON.stringify(record.dashboardUrl)}, not ${dashboardUrl}`);
61
+ }
62
+ return record.secret;
63
+ }
64
+ /** This machine's secret for `dashboardUrl`, made (once, race-safe) if it does not exist yet. */
65
+ export function machineSecretFor(dashboardUrl, options = {}) {
66
+ const path = machineSecretPath(dashboardUrl, options.home);
67
+ const existing = readSecret(path, dashboardUrl);
68
+ if (existing !== null)
69
+ return existing;
70
+ const record = {
71
+ schemaVersion: MACHINE_SECRET_SCHEMA_VERSION,
72
+ dashboardUrl: dashboardUrl.replace(/\/+$/, ""),
73
+ secret: randomBytes(SECRET_BYTES).toString("hex"),
74
+ createdAt: new Date(options.now ?? Date.now()).toISOString(),
75
+ };
76
+ const candidate = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.candidate`;
77
+ writeFileDurable(candidate, JSON.stringify(record), { mode: 0o600 });
78
+ try {
79
+ linkSync(candidate, path);
80
+ }
81
+ catch (err) {
82
+ // Another session made the file between the read above and this link: its secret is the machine's.
83
+ if (err.code !== "EEXIST")
84
+ throw err;
85
+ }
86
+ finally {
87
+ unlinkSync(candidate);
88
+ }
89
+ const stored = readSecret(path, dashboardUrl);
90
+ if (stored === null)
91
+ throw unusable(path, "it vanished right after it was made");
92
+ return stored;
93
+ }
@@ -250,7 +250,7 @@ function resolveToolkitVersion(stamped, readManifest) {
250
250
  return stamped ?? readManifest();
251
251
  }
252
252
  var readOwnManifest = () => JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8")).version;
253
- var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.442", readOwnManifest);
253
+ var TOOLKIT_VERSION = resolveToolkitVersion(false ? void 0 : "0.1.444", readOwnManifest);
254
254
  function envelope(now) {
255
255
  return { contract_version: CONTRACT_VERSION, toolkit_version: TOOLKIT_VERSION, profile: PROFILE, measured_at: now.toISOString() };
256
256
  }
@@ -90,7 +90,7 @@ export class PermissionWatch {
90
90
  });
91
91
  if (response.status >= 400 && response.status < 500 && !RETRYABLE_REFUSALS.has(response.status)) {
92
92
  this.deps.log?.(`[platform-mcp] the dashboard refused the permission stream (HTTP ${response.status}): a permission granted ` +
93
- `mid-session shows its tools at the next plan_connect instead`);
93
+ `mid-session shows its tools at the next session_connect instead`);
94
94
  await response.body?.cancel();
95
95
  return { kind: "refused" };
96
96
  }
@@ -5,10 +5,10 @@
5
5
  * WHY IT EXISTS. A restarted session gets a NEW session id, no plan binding and, until a person approves its first call, no
6
6
  * dashboard key of its own: its MCP server boots signed out. Nothing told it (or the operator) that the plan it had been
7
7
  * working was unattended: six operator answers sat unread for an hour on 2026-10-01. The session's own connection record cannot
8
- * help (it does not exist yet); the PREVIOUS session's record can, found by the project key `plan_connect` wrote into it
8
+ * help (it does not exist yet); the PREVIOUS session's record can, found by the project key `session_connect` wrote into it
9
9
  * (`findPredecessorCandidates`). So this asks the dashboard about each predecessor as the predecessor, with the credential
10
10
  * that predecessor's record names (`resolveDashboardAuth`, the resolver `session-options.ts` holds), which a signed-out
11
- * session needs no key of its own to do. The tool is therefore usable signed out: it is declared like `plan_connect` (no
11
+ * session needs no key of its own to do. The tool is therefore usable signed out: it is declared like `session_connect` (no
12
12
  * permission) and only on a person's session (`tool-access.ts`).
13
13
  *
14
14
  * THE ANSWER (the plugin's hooks module reads it through `$.mcp.call`):
@@ -6,7 +6,7 @@ import { writeFileDurable } from "./durable-write.js";
6
6
  * DX-4400 — the most characters one `50arms_api`, `50arms_api_spec` or `50arms_api_list` result may carry
7
7
  * back to the agent. Every character is context the agent pays for on every later call, and Claude Code itself
8
8
  * saves a larger result to a file with only a short preview, after the cost is paid. A deliberate, narrow
9
- * exception to PLAN-11 rule R-25 (verbatim forwarding): `plan_connect` and the lifecycle tools never pass here.
9
+ * exception to PLAN-11 rule R-25 (verbatim forwarding): `session_connect` and the lifecycle tools never pass here.
10
10
  */
11
11
  export const MCP_MAX_RESULT_CHARS = 100_000;
12
12
  const FILE_INSTRUCTION = "The reply was over the inline limit, so its body is this file, whole: read it with Read (in parts with offset and limit when it is long), or grep it.";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * DX-6089 - a session's role text reaches it as a local file, never inline. The dashboard answers `roleInstruction: {profile, text}` on
3
- * `plan_connect`, `card_connect` and `GET /api/plan-sessions/me/role`; the text runs to tens of KB, and a tool reply that large is
3
+ * `session_connect` and `GET /api/plan-sessions/me/role`; the text runs to tens of KB, and a tool reply that large is
4
4
  * saved to a file by the client with only a short preview, which also hides every field after it (the briefing, the agent instruction, the
5
5
  * reminders). So this server writes the text to `<tmp>/danx-role/<profile>-<content hash>.md` and answers `{profile, path, bytes, instruction}`
6
6
  * in its place, leaving every other field inline, as `docs_read` does for a docs page (`docs-tools.ts`). The name carries the content hash, so a
@@ -6,17 +6,17 @@
6
6
  * - `env` a dispatch declared `DANX_DASHBOARD_CREDENTIAL`; this never signs out.
7
7
  * - `session-key` a user approved this session's access request; the key is stored per session
8
8
  * (`session-key.ts`) and a 401 on it deletes the file (`dropKey`).
9
- * - signed out no credential. Every tool but `plan_connect` halts; `plan_connect` asks.
9
+ * - signed out no credential. Every tool but `session_connect` halts; `session_connect` asks.
10
10
  *
11
11
  * DX-4418: a fourth state, REVOKED, ends all three. The dashboard answers `401 key_revoked` to a key a person revoked
12
- * (as opposed to one that lapsed); this object then remembers it, every dashboard tool (and `plan_connect`) returns the one stop
12
+ * (as opposed to one that lapsed); this object then remembers it, every dashboard tool (and `session_connect`) returns the one stop
13
13
  * halt (`key-revoked-halt.ts`), and no new access request is made. A session's key file is deleted; a dispatch's credential is
14
14
  * left to its dispatch, but the halt is the same. DX-6894: the one way out is `personSignIn`, the call a PERSON's Sign in press
15
- * makes (`plan_connect` with `person_sign_in: true`); nothing an agent calls lifts the stop.
15
+ * makes (`session_connect` with `person_sign_in: true`); nothing an agent calls lifts the stop.
16
16
  *
17
- * ASKING NEVER BLOCKS FOR MINUTES. The first signed-out `plan_connect` posts the request and
17
+ * ASKING NEVER BLOCKS FOR MINUTES. The first signed-out `session_connect` posts the request and
18
18
  * answers at once with the approval URL and confirm code. From then on THIS object polls claim in
19
- * the background, so the key is stored the moment the user approves, and the next `plan_connect`
19
+ * the background, so the key is stored the moment the user approves, and the next `session_connect`
20
20
  * waits at most `awaitMs` (about 45s) for it before answering `approval_pending`. The secret the
21
21
  * poll needs lives only in this process's memory; a restarted session asks again.
22
22
  *
@@ -29,9 +29,10 @@ import { hostname } from "node:os";
29
29
  import { claimAccessRequest, createAccessRequest } from "./access-request.js";
30
30
  import { credentialFingerprint } from "./credential.js";
31
31
  import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
32
+ import { machineSecretFor } from "./machine-secret.js";
32
33
  import { deleteSessionKey, writeSessionKey } from "./session-key.js";
33
34
  import { McpOutdatedError } from "./version-floor.js";
34
- /** Longest a `plan_connect` call waits for an approval already asked for. */
35
+ /** Longest a `session_connect` call waits for an approval already asked for. */
35
36
  export const AWAIT_APPROVAL_MS = 45_000;
36
37
  /** A live server rewrites its key file this often, so an in-use key never ages out of the 7-day prune. */
37
38
  const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
@@ -39,19 +40,22 @@ const KEEP_KEY_FILE_EVERY_MS = 60 * 60 * 1000;
39
40
  const REFUSAL_LOG_LIMIT = 300;
40
41
  /** The request's title when the agent gave none: the session id's first characters. */
41
42
  const TITLE_FALLBACK_LENGTH = 8;
42
- export const SIGN_IN_HALT = "Not signed in to the 50 Arms dashboard. Call `plan_connect` (with `plan_id`) to request access; the user approves it in their browser, then this tool works.";
43
+ export const SIGN_IN_HALT = "Not signed in to the 50 Arms dashboard. Call `session_connect` (with `target`) to request access; the user approves it in their browser, then this tool works.";
43
44
  /** DX-4418: the session's key lapsed (a day unused) or the dashboard does not know it; a PERSON's revoke is `keyRevokedHalt` instead. */
44
45
  export const KEY_LAPSED_HALT = "The dashboard no longer accepts this session's key (it lapsed after a day unused), so this session is signed out. " +
45
46
  SIGN_IN_HALT;
46
47
  const APPROVAL_REQUIRED_INSTRUCTION = "Show the user the confirm code and open the approval URL in their browser; they check the code " +
47
- "matches and approve. Then call `plan_connect` again: it waits up to about 45 seconds for the approval. " +
48
+ "matches and approve. Then call `session_connect` again: it waits up to about 45 seconds for the approval. " +
48
49
  "The request stays open until this session ends, however long the user takes.";
49
50
  const APPROVAL_PENDING_INSTRUCTION = "Still waiting for the user to approve; the request stays open until this session ends. Remind them of the " +
50
- "confirm code and the URL, then call `plan_connect` again.";
51
- const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `plan_connect` makes a new request.";
51
+ "confirm code and the URL, then call `session_connect` again.";
52
+ /** DX-6764: a machine grant approved the request; nothing for the user to open or confirm, the next call picks the sign-in up. */
53
+ const SIGNING_IN_INSTRUCTION = "This machine's auto-approval signs the session in; no approval link is involved. Call `session_connect` again in a moment.";
54
+ const AUTO_APPROVED_EXPIRED_ERROR = "the dashboard approved this request by itself, but it expired before this process claimed the key; call session_connect again";
55
+ const DENIED_INSTRUCTION = "The user denied the request. Do not retry unless they ask; a new `session_connect` makes a new request.";
52
56
  const DASHBOARD_OUTDATED_ERROR = "this dashboard does not support request-and-approve yet (POST /api/access-requests answered 404): " +
53
57
  "it must be updated before this session can sign in";
54
- const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call plan_connect again";
58
+ const RATE_LIMITED_ERROR = "too many access requests from this address; wait, then call session_connect again";
55
59
  const unknownTeamError = (team) => `the dashboard has no team "${team}" (DANXBOT_TEAM); fix DANXBOT_TEAM in this server's env (your shell, or a repo's committed .claude/settings.json env block) and restart the session`;
56
60
  const NO_SESSION_ID_ERROR = "this server has no CLAUDE_CODE_SESSION_ID, so it cannot request a session key";
57
61
  const CLAIMED_LOST_ERROR = "the dashboard says this request was already claimed, but this process never received the key; " +
@@ -101,7 +105,7 @@ export class SessionAccess {
101
105
  // DX-4418: set once the dashboard says a person revoked the key. Only `personSignIn` clears it (DX-6894); a restarted server is a new process.
102
106
  revoked = null;
103
107
  pending = null;
104
- // DX-4390: two concurrent signed-out plan_connect calls must not each post a request.
108
+ // DX-4390: two concurrent signed-out session_connect calls must not each post a request.
105
109
  ensureQueue = Promise.resolve();
106
110
  lastKeyFileWrite = 0;
107
111
  // DX-6801: a launch token is redeemed at most once per process, whatever the answer.
@@ -231,7 +235,7 @@ export class SessionAccess {
231
235
  }
232
236
  /**
233
237
  * Make sure this session holds a key. Signed in: `{signedIn: true}`. Otherwise the answer
234
- * `plan_connect` returns: the request just made, the one still pending (after waiting up to
238
+ * `session_connect` returns: the request just made, the one still pending (after waiting up to
235
239
  * `awaitMs` for it), or why there is none. Calls run one at a time.
236
240
  *
237
241
  * DX-6801 / DEC-522: a signed-out session given the `launchToken` the session that started it passed in its prompt redeems it
@@ -271,21 +275,10 @@ export class SessionAccess {
271
275
  }
272
276
  }
273
277
  if (this.pending !== null) {
274
- const pending = this.pending;
275
- const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
276
- if (outcome === null)
277
- return notReady(this.requestAnswer("approval_pending", pending.request), false);
278
- this.pending = null;
279
- switch (outcome.kind) {
280
- case "approved":
281
- return { signedIn: true };
282
- case "denied":
283
- return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
284
- case "failed":
285
- return notReady({ state: "request_failed", error: outcome.error }, true);
286
- case "expired":
287
- note = EXPIRED_NOTE;
288
- }
278
+ const settled = await this.awaitPending(this.pending);
279
+ if (settled !== "expired")
280
+ return settled;
281
+ note = EXPIRED_NOTE;
289
282
  }
290
283
  const created = await this.createRequest(sessionId, title, note);
291
284
  // DX-6801: a refused or failed request carries the launch note too, so the reason is never lost.
@@ -294,6 +287,27 @@ export class SessionAccess {
294
287
  }
295
288
  return created;
296
289
  }
290
+ /**
291
+ * Wait up to `awaitMs` for the open request, then answer how it stands: signed in, still pending (the link to open, or for an
292
+ * auto-approved request the plain "signing in" state, which has no link), denied or failed. `expired` is for the caller: a person's
293
+ * request that lapsed is asked again, an auto-approved one is an error.
294
+ */
295
+ async awaitPending(pending) {
296
+ const outcome = await Promise.race([pending.settled, this.sleep(this.awaitMs).then(() => null)]);
297
+ if (outcome === null)
298
+ return notReady(this.requestAnswer("approval_pending", pending.request), false);
299
+ this.pending = null;
300
+ switch (outcome.kind) {
301
+ case "approved":
302
+ return { signedIn: true };
303
+ case "denied":
304
+ return notReady({ state: "denied", instruction: DENIED_INSTRUCTION }, false);
305
+ case "failed":
306
+ return notReady({ state: "request_failed", error: outcome.error }, true);
307
+ case "expired":
308
+ return "expired";
309
+ }
310
+ }
297
311
  async createRequest(sessionId, title, note) {
298
312
  const repo = this.deps.readRepo === undefined ? null : await this.deps.readRepo();
299
313
  const created = await createAccessRequest(this.fetchImpl, this.deps.dashboardUrl, {
@@ -304,6 +318,7 @@ export class SessionAccess {
304
318
  clientVersion: this.deps.clientVersion,
305
319
  ...(repo === null ? {} : { repo }),
306
320
  ...(this.deps.team === undefined ? {} : { team: this.deps.team }),
321
+ machineSecret: machineSecretFor(this.deps.dashboardUrl, { home: this.deps.home, now: this.now() }),
307
322
  });
308
323
  switch (created.kind) {
309
324
  case "dashboard_outdated":
@@ -316,10 +331,18 @@ export class SessionAccess {
316
331
  return notReady({ state: "request_refused", status: created.status, body: created.body }, true);
317
332
  case "created":
318
333
  this.pending = { request: created.request, settled: this.pollUntilSettled(created.request) };
334
+ // DX-6764: a machine grant approved it: no person decides, so the key is a claim away. Wait for it as a second call would,
335
+ // and never hand the agent a link.
336
+ if (created.request.autoApproved) {
337
+ const settled = await this.awaitPending(this.pending);
338
+ return settled === "expired" ? notReady({ state: "request_failed", error: AUTO_APPROVED_EXPIRED_ERROR }, true) : settled;
339
+ }
319
340
  return notReady({ ...this.requestAnswer("approval_required", created.request), ...(note ? { note } : {}) }, false);
320
341
  }
321
342
  }
322
343
  requestAnswer(state, request) {
344
+ if (request.autoApproved)
345
+ return { state: "signing_in", instruction: SIGNING_IN_INSTRUCTION };
323
346
  return {
324
347
  state,
325
348
  approvalUrl: request.approvalUrl,
@@ -329,7 +352,8 @@ export class SessionAccess {
329
352
  }
330
353
  // DX-4530: no give-up time - the polls are what keep the request open, until the dashboard answers how it ended.
331
354
  async pollUntilSettled(request) {
332
- let waitMs = request.pollMs;
355
+ // DX-6764: an auto-approved request is decided already, so its first claim is not delayed.
356
+ let waitMs = request.autoApproved ? 0 : request.pollMs;
333
357
  for (;;) {
334
358
  await this.sleep(waitMs);
335
359
  waitMs = request.pollMs;
@@ -8,7 +8,7 @@
8
8
  * dropped silently (the card's incident). The plan event relay (`plan_events_wait`) needs none of this: it runs inside the
9
9
  * server, with the server's own credential. The listener reminder reads this record too.
10
10
  *
11
- * WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `plan_connect` the server
11
+ * WHAT IS WRITTEN, AND WHAT IS NOT. On a successful `session_connect` the server
12
12
  * writes the dashboard URL, the credential SOURCE (a session key file path, or the name of
13
13
  * an env var — never a credential) and the credential's FINGERPRINT. A reader
14
14
  * (`restart_notice`) reads that record, resolves the SAME source through the same resolver
@@ -16,7 +16,7 @@
16
16
  * identically. A rotated file or a session environment that disagrees with the
17
17
  * server's therefore fails loud instead of reporting as somebody else.
18
18
  *
19
- * WHY `plan_connect` AND NOTHING ELSE WRITES IT. A session can have several
19
+ * WHY `session_connect` AND NOTHING ELSE WRITES IT. A session can have several
20
20
  * dashboard MCP servers configured (gpt-manager has a deployed one and a local
21
21
  * one). The server that CONNECTED the session to a plan is, by construction,
22
22
  * the one whose credential owns that session's stream — so the connect is the
@@ -143,7 +143,7 @@ export function readSessionConnection(sessionId, options = {}) {
143
143
  return parsed;
144
144
  }
145
145
  /**
146
- * Record the connection a successful `plan_connect` just made, and say so in
146
+ * Record the connection a successful `session_connect` just made, and say so in
147
147
  * that tool's own answer when it could not be recorded.
148
148
  *
149
149
  * FAIL LOUD, IN THE ONE PLACE THE AGENT IS LOOKING. A connect whose record is
@@ -179,14 +179,14 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
179
179
  }
180
180
  }
181
181
  /**
182
- * DX-6556 — a manual pickup, which goes through the generic `50arms_api`, can connect the session as `card_connect` does; the dashboard marks that answer, and it is then
182
+ * DX-6556 — a manual pickup, which goes through the generic `50arms_api`, can connect the session as a card connect does; the dashboard marks that answer, and it is then
183
183
  * handled as a connect, so no record or tool sync is missed.
184
184
  */
185
185
  export function isPickupConnect(result) {
186
186
  return result.ok && typeof result.body === "object" && result.body !== null && result.body.connectedByPickup === true;
187
187
  }
188
188
  /**
189
- * DX-2985 — drop this session's local connection record after a successful leave (`plan_connect {disconnect: true}`), so
189
+ * DX-2985 — drop this session's local connection record after a successful leave (`session_connect {disconnect: true}`), so
190
190
  * nothing on this machine still treats the session as plan-connected (the listener-reminder reads this record). A refused
191
191
  * leave keeps it: the session is still on its plan. Never throws for an absent record.
192
192
  */
@@ -202,7 +202,7 @@ export function invalidRecordReason(value) {
202
202
  const r = value;
203
203
  if (r.schemaVersion !== SESSION_CONNECTION_SCHEMA_VERSION) {
204
204
  return (`schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION} — ` +
205
- "a session connected by an older MCP server wrote it; call plan_connect again to rewrite it");
205
+ "a session connected by an older MCP server wrote it; call session_connect again to rewrite it");
206
206
  }
207
207
  for (const field of ["sessionId", "projectKey", "dashboardUrl", "fingerprint", "connectedAt"]) {
208
208
  if (typeof r[field] !== "string" || r[field] === "")
@@ -2,7 +2,7 @@
2
2
  * DX-4461 - a session's live Claude title, read from its own transcript with no help from the agent.
3
3
  *
4
4
  * WHY THE TRANSCRIPT. The dashboard stores a plan session's title, and until now it arrived only as the `title`
5
- * argument of `plan_connect`, so a session renamed afterwards showed a stale name until the agent happened to connect
5
+ * argument of `session_connect`, so a session renamed afterwards showed a stale name until the agent happened to connect
6
6
  * again. DX-2816 found no env var, hook input or MCP-reachable signal carrying the title, but Claude Code writes every
7
7
  * title change into the session's transcript as a `{"type":"custom-title","customTitle":"..."}` line the moment the
8
8
  * rename happens, even while the session is idle (observed on session 952f0739: 76 records of the old title, then the
@@ -13,26 +13,26 @@
13
13
  * is no separate process to restart.
14
14
  */
15
15
  const RESTART_NOTICE_STOP_LEAD = "continue without it";
16
- const RESTART_NOTICE_STOP_TAIL = "call plan_connect to connect this session to a plan";
16
+ const RESTART_NOTICE_STOP_TAIL = "call session_connect to connect this session to a plan or a card";
17
17
  export const STOP_FIXES = {
18
- no_connection_record: "call plan_connect again in this session — its platform MCP server records the connection that restart_notice and the listener reminder read " +
18
+ no_connection_record: "call session_connect again in this session — its platform MCP server records the connection that restart_notice and the listener reminder read " +
19
19
  "(a server older than the one that introduced the record writes none)",
20
- credential_unavailable: "call plan_connect again to request access: this session's key file is missing or unreadable",
21
- credential_mismatch: "restart this Claude Code session so its MCP server resolves its credential afresh, then call plan_connect again",
22
- not_connected: "call plan_connect to connect this session to a plan",
23
- plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call plan_connect again",
24
- unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call plan_connect again to request access",
20
+ credential_unavailable: "call session_connect again to request access: this session's key file is missing or unreadable",
21
+ credential_mismatch: "restart this Claude Code session so its MCP server resolves its credential afresh, then call session_connect again",
22
+ not_connected: "call session_connect to connect this session to a plan or a card",
23
+ plan_archived: "the plan was archived or deleted, so this session was disconnected; ask the operator to restore it, then call session_connect again",
24
+ unauthorized: "the dashboard no longer accepts this session's key (it lapsed after a day unused): call session_connect again to request access",
25
25
  key_revoked: "stop all work now, commit your work in progress and end this session: the person who owns this session's access revoked it; " +
26
- "do not call plan_connect or request access again",
27
- mint_refused: "check that this session is signed in to the dashboard its MCP server points at, then call plan_connect again",
26
+ "do not call session_connect or request access again",
27
+ mint_refused: "check that this session is signed in to the dashboard its MCP server points at, then call session_connect again",
28
28
  mint_bad_response: "check that DANXBOT_DASHBOARD_URL for this session's MCP server points at a 50 Arms dashboard",
29
29
  // A newer ticket exists (superseded) or another connection is already using this one (replaced): THIS listener is ending
30
30
  // because the session is already served elsewhere, not because anything is broken.
31
31
  superseded: "no action needed — a newer ticket was minted for this session, and a fresh listener is already using it",
32
- replaced: "no action needed — another connection is already using this session's ticket; call plan_connect again only if that was unexpected",
33
- revoked: "call plan_connect again to mint a new listener ticket for this session",
34
- refused: "call plan_connect again to mint a new listener ticket for this session",
35
- // DX-6272: calling plan_connect again would reconnect with the same old relay and be refused again.
32
+ replaced: "no action needed — another connection is already using this session's ticket; call session_connect again only if that was unexpected",
33
+ revoked: "call session_connect again to mint a new listener ticket for this session",
34
+ refused: "call session_connect again to mint a new listener ticket for this session",
35
+ // DX-6272: calling session_connect again would reconnect with the same old relay and be refused again.
36
36
  mcp_outdated: "restart this Claude Code session, or reconnect its platform server in /mcp, to install the current version of the MCP server; the dashboard's event stream needs a newer one than this session runs",
37
37
  // DX-3028 - this session id belongs to a worker dispatch; there is no listener ticket a worker session can ever use.
38
38
  session_is_worker: "this session id belongs to a worker dispatch; start a new session",
@@ -44,7 +44,7 @@ export const STOP_FIXES = {
44
44
  lookup_failed: `${RESTART_NOTICE_STOP_LEAD}: no earlier session's record could be checked (the detail says why); ${RESTART_NOTICE_STOP_TAIL}`,
45
45
  relay_failed: "the event relay inside this session's platform MCP server failed (the detail says why); the next call tries again, and if it keeps failing restart this Claude Code session",
46
46
  };
47
- export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the listener";
47
+ export const DEFAULT_STOP_FIX = "call session_connect again in this session to restart the listener";
48
48
  export function fixForStop(reason) {
49
49
  return STOP_FIXES[reason] ?? DEFAULT_STOP_FIX;
50
50
  }
@@ -9,7 +9,7 @@
9
9
  * Three kinds of tool, by what makes them usable:
10
10
  *
11
11
  * - bootstrap `{permission: null, callback: null}` - what a session needs BEFORE it holds a key, or to ask for more:
12
- * `plan_connect` (sign in, bind to a plan), `request_permission`, and the `50arms_api` trio.
12
+ * `session_connect` (sign in, bind to a plan or a card), `request_permission`, and the `50arms_api` trio.
13
13
  * - permission `{permission}` - the dashboard route the tool calls needs this permission, so a key without it can
14
14
  * never succeed. Shown once the key holds it, which includes a grant a person approves mid-session.
15
15
  * - callback `{callback}` - the tool posts to a per-dispatch URL the worker injected (`lifecycle.ts`), so it is usable
@@ -35,9 +35,7 @@ const needsAndPostsTo = (permission, callback) => ({ permission, callback });
35
35
  * here, and `tool-access.test.ts` fails when an entry names no registered tool.
36
36
  */
37
37
  export const TOOL_ACCESS = {
38
- plan_connect: BOOTSTRAP,
39
- // DX-5553 - `POST|DELETE /api/plan-sessions/me/card` (`cards.edit`: it assigns the card to the session's agent): the session holds a card on no plan.
40
- card_connect: needs("cards.edit"),
38
+ session_connect: BOOTSTRAP,
41
39
  request_permission: BOOTSTRAP,
42
40
  "50arms_api": BOOTSTRAP,
43
41
  "50arms_api_list": BOOTSTRAP,
@@ -70,7 +68,7 @@ export const TOOL_ACCESS = {
70
68
  // `src/tokens/tool-defs.ts`) granted to the `orchestrator` profile alone, not this table.
71
69
  plan_events_wait: needs("plans.view"),
72
70
  // DX-4632 - the plugin's session-start hook asks which plan the session this one replaced was working. A restarted session boots
73
- // signed out, so it holds no permission: like `plan_connect` it is a bootstrap tool, and a person's session's only (a dispatch
71
+ // signed out, so it holds no permission: like `session_connect` it is a bootstrap tool, and a person's session's only (a dispatch
74
72
  // never loads the plugin that calls it). The lookup asks the dashboard as the predecessor, with the credential the predecessor's
75
73
  // record names.
76
74
  restart_notice: OPERATOR_SESSION_BOOTSTRAP,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@50arms/platform-mcp",
3
- "version": "0.1.442",
4
- "description": "Stdio MCP server fronting the 50 Arms dashboard API for agents (operator sessions and dispatched workers alike): the generic 50arms_api/50arms_api_list/50arms_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, request_permission, attach_file, and 50arms_slack_reply) (DX-3465, PLN-11 R-25).",
3
+ "version": "0.1.444",
4
+ "description": "Stdio MCP server fronting the 50 Arms dashboard API for agents (operator sessions and dispatched workers alike): the generic 50arms_api/50arms_api_list/50arms_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (session_connect, request_permission, attach_file, and 50arms_slack_reply) (DX-3465, PLN-11 R-25).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",