@thehammer/danx-dashboard-mcp 0.1.202 → 0.1.203
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 +4 -0
- package/dist/index.js +13 -18
- package/dist/restart-notice.js +140 -0
- package/dist/session-connection.js +57 -4
- package/package.json +1 -1
- package/dist/terms-halt.js +0 -33
package/README.md
CHANGED
|
@@ -46,6 +46,10 @@ CLAUDE_CODE_SESSION_ID=<session> npx -y @thehammer/danx-dashboard-mcp@<version>
|
|
|
46
46
|
- **Stopping.** Last, one `{"type":"stopped","reason":"…","detail":"…","fix":"…"}` and exit, only on a terminal outcome. `fix` is the remedy shown to the session (`STOP_FIXES` in `src/bridge.ts` — never a log-only hint), so a session that hits a stop it cannot otherwise see still knows what to do about it. Terminal reasons: `no_connection_record` / `credential_unavailable` / `credential_mismatch` (the start-time checks above), `not_connected` (a `409 session_not_connected` at mint OR at stream admission, or a live `end("not_connected")` — all three mapped identically), `plan_archived` (DX-3809 — the plan was archived or deleted while the stream was live; the session is disconnected, and reconnects by hand after a restore), `unauthorized` (401/403), `mint_refused` (any other non-transient mint refusal), `mint_bad_response`, `superseded` / `replaced` (exit 0 — a newer or replacing connection already serves this session, so `fix` says no action is needed), `revoked`, or `refused` (two freshly minted tickets refused in a row with no more specific classification). A transient mint failure (network, timeout, 408, 429, 5xx) backs off and retries; a lapsed ticket lease re-mints.
|
|
47
47
|
- **Reconnect and resume.** A read-idle timeout (three missed keep-alives) turns a silently dead connection into a drop. Capped exponential backoff (1s → 30s) that resets only after a healthy connection, with `Last-Event-ID`, so a dashboard restart replays what was missed and nothing is emitted twice. `--resume-ids` carries the same guarantee across a process restart: the ids already delivered seed the duplicate guard, and the highest is the first `Last-Event-ID`. The dashboard floors that replay at the later of the session's first ticket and when it joined its current plan, so a restart loses nothing and a plan move replays nothing from before the move.
|
|
48
48
|
|
|
49
|
+
## `restart-notice` — what a restarted session was working on (DX-3928)
|
|
50
|
+
|
|
51
|
+
Run by the danxbot plugin's SessionStart hook for a session that is **not** plan-connected. A restarted Claude Code session gets a new session id and no plan binding, so nothing told 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); this subcommand finds the newest OTHER session's record for the same project within `STALE_RECORD_MS`, resolves that 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). Exit 0 with text on stdout = say it (the registry's `plan_restart.waiting_events` text — this package carries no wording); exit 0 with nothing = nothing to say (never connected from this project, plan archived or deleted, record stale); exit 1 = the lookup failed. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
|
|
52
|
+
|
|
49
53
|
## Build + test
|
|
50
54
|
|
|
51
55
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -77,8 +77,9 @@ import { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
|
|
|
77
77
|
import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
|
|
78
78
|
import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
|
|
79
79
|
import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
80
|
+
import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
|
|
80
81
|
import { resolveDeclaredCredential } from "./credential.js";
|
|
81
|
-
import { recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
82
|
+
import { projectKeyOf, recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
82
83
|
import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
|
|
83
84
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
84
85
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -86,7 +87,6 @@ import { z } from "zod";
|
|
|
86
87
|
import { DashboardHttpClient } from "./http-client.js";
|
|
87
88
|
import { planConnect, danxbotApi, danxbotApiList, danxbotApiSpec, attachFile, DANXBOT_API_METHODS, } from "./handlers.js";
|
|
88
89
|
import { bodyValueSchema, queryValueSchema } from "./types.js";
|
|
89
|
-
import { termsAcceptanceCallHalt, termsHaltFor } from "./terms-halt.js";
|
|
90
90
|
import { danxbotApiExamplesText } from "./danxbot-api-examples.js";
|
|
91
91
|
import { registerLifecycleTools, applyLifecycleVisibility, readLifecycleUrlsFromEnv, } from "./lifecycle.js";
|
|
92
92
|
function readEnvOrDie(name) {
|
|
@@ -279,9 +279,6 @@ export const server = new McpServer({
|
|
|
279
279
|
name: "danx-dashboard-mcp",
|
|
280
280
|
version: "0.1.0",
|
|
281
281
|
}, { instructions: SERVER_INSTRUCTIONS });
|
|
282
|
-
function haltResult(text) {
|
|
283
|
-
return { content: [{ type: "text", text }], isError: true };
|
|
284
|
-
}
|
|
285
282
|
function jsonResult(value) {
|
|
286
283
|
return {
|
|
287
284
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
@@ -389,17 +386,7 @@ strictTool("danxbot_api", "Call any dashboard API route your credential may reac
|
|
|
389
386
|
},
|
|
390
387
|
// DX-3997 — the result rides back verbatim, plus the dashboard's own `reminders[]` entries
|
|
391
388
|
// for this plan-connected session (the server decides; `listener-reminder.ts` only relays).
|
|
392
|
-
async (args) =>
|
|
393
|
-
// DX-4148 — an agent never accepts the Terms, and a terms refusal halts it (see terms-halt.ts).
|
|
394
|
-
const refused = termsAcceptanceCallHalt(args, config.baseUrl);
|
|
395
|
-
if (refused)
|
|
396
|
-
return haltResult(refused);
|
|
397
|
-
const result = await danxbotApi(client, args);
|
|
398
|
-
const halt = termsHaltFor(result);
|
|
399
|
-
if (halt)
|
|
400
|
-
return haltResult(halt);
|
|
401
|
-
return jsonResult(await listenerReminder.apply(result));
|
|
402
|
-
});
|
|
389
|
+
async (args) => jsonResult(await listenerReminder.apply(await danxbotApi(client, args))));
|
|
403
390
|
// ---------------- danxbot_api_list (DX-3465 A5, PLN-11 R-25) ----------------
|
|
404
391
|
strictTool("danxbot_api_list", "Lists the dashboard API routes your credential may call right now — `[{method, path, summary, permission}]`, " +
|
|
405
392
|
"one line each, in the route TEMPLATE form `danxbot_api_spec` also expects (e.g. " +
|
|
@@ -462,7 +449,7 @@ strictTool("attach_file", "Attach a LOCAL file to a card: `card_id` + `path` (an
|
|
|
462
449
|
// no existing plan for an id to name yet.
|
|
463
450
|
strictTool("plan_connect",
|
|
464
451
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
465
|
-
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth}` — `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. An action the operator DECLINES arrives as `... rejected the action \"<title>\" for \"<statement>\" — reason: \"<note>\". Do not perform it; re-plan from the reason` (a decision with `outcome: \"rejected\"` — never treat it as work done). Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
|
|
452
|
+
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth, missedEvents}` — `missedEvents` is `{events, omitted}`: the operator events on this plan that no session had been told about before you connected, oldest first, newest 50 at most (`omitted` counts older ones) — read them before anything else. `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. An action the operator DECLINES arrives as `... rejected the action \"<title>\" for \"<statement>\" — reason: \"<note>\". Do not perform it; re-plan from the reason` (a decision with `outcome: \"rejected\"` — never treat it as work done). Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
|
|
466
453
|
plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
|
|
467
454
|
title: z
|
|
468
455
|
.string()
|
|
@@ -482,6 +469,7 @@ async (args) => {
|
|
|
482
469
|
...result,
|
|
483
470
|
...recordSessionConnectionAfterConnect(result, {
|
|
484
471
|
sessionId: config.session?.id,
|
|
472
|
+
projectKey: projectKeyOf(process.cwd()),
|
|
485
473
|
dashboardUrl: connection.dashboardUrl,
|
|
486
474
|
source: connection.source,
|
|
487
475
|
fingerprint: connection.fingerprint,
|
|
@@ -548,9 +536,16 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
548
536
|
process.exit(1);
|
|
549
537
|
});
|
|
550
538
|
}
|
|
539
|
+
else if (subcommand === RESTART_NOTICE_SUBCOMMAND) {
|
|
540
|
+
// DX-3928 — same exit-code contract as event-text: 0 (text, or nothing to say) / 1 (lookup failed).
|
|
541
|
+
runRestartNoticeCommand(rest).then((code) => process.exit(code), (err) => {
|
|
542
|
+
console.error(`[danx-dashboard-mcp] restart-notice fatal: ${err.message}`);
|
|
543
|
+
process.exit(1);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
551
546
|
else if (subcommand !== undefined) {
|
|
552
547
|
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
|
|
553
|
-
`"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", and "${
|
|
548
|
+
`"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", "${EVENT_TEXT_SUBCOMMAND}", and "${RESTART_NOTICE_SUBCOMMAND}")`);
|
|
554
549
|
process.exit(2);
|
|
555
550
|
}
|
|
556
551
|
else {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `danx-dashboard-mcp restart-notice` — DX-3928. The ONE-SHOT client the
|
|
3
|
+
* claude-plugins `danxbot` plugin's SessionStart hook runs for a session that is
|
|
4
|
+
* NOT plan-connected, to tell it when the previous session of the same project
|
|
5
|
+
* was working a plan.
|
|
6
|
+
*
|
|
7
|
+
* WHY IT EXISTS. A restarted Claude Code session gets a NEW session id and no
|
|
8
|
+
* plan binding, and nothing told it (or the operator) that the plan it had been
|
|
9
|
+
* working was unattended: six operator answers sat unread for an hour on
|
|
10
|
+
* 2026-10-01. The session's own connection record cannot help (it is the new
|
|
11
|
+
* session's, and does not exist yet); the PREVIOUS session's record can, found by
|
|
12
|
+
* the project key `plan_connect` wrote into it (`findPredecessorConnection`).
|
|
13
|
+
*
|
|
14
|
+
* THE CONTRACT, which `event-hook.sh` reads by exit code:
|
|
15
|
+
* - exit 0, text on stdout: say this to the agent (the registry's
|
|
16
|
+
* `plan_restart.waiting_events` text — this module carries no wording).
|
|
17
|
+
* - exit 0, nothing on stdout: nothing to say — no earlier session of this
|
|
18
|
+
* project connected a plan, or the plan is archived/deleted, or the record is
|
|
19
|
+
* stale. The plugin stays silent, as PLN-11 R-10 requires of a project that
|
|
20
|
+
* never connected a plan.
|
|
21
|
+
* - exit 1, reason on stderr: the lookup itself failed (credential, network,
|
|
22
|
+
* non-2xx, bad shape). Never silent: the hook prints a could-not-load notice.
|
|
23
|
+
*
|
|
24
|
+
* The credential is the PREDECESSOR's own record's source, resolved through the
|
|
25
|
+
* same resolver the bridge uses, so the lookup reaches the dashboard that
|
|
26
|
+
* connected it. READ-ONLY: it advances no cursor on the server.
|
|
27
|
+
*/
|
|
28
|
+
import { SESSION_ID_HEADER, resolveBridgeOptions } from "./bridge.js";
|
|
29
|
+
import { findPredecessorConnection, projectKeyOf } from "./session-connection.js";
|
|
30
|
+
export const RESTART_NOTICE_SUBCOMMAND = "restart-notice";
|
|
31
|
+
export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
|
|
32
|
+
/** The registry row whose text is the notice (`src/issues/reminders/seed.yaml`). */
|
|
33
|
+
export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
|
|
34
|
+
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${RESTART_NOTICE_SUBCOMMAND} [--cwd <project directory>]`;
|
|
35
|
+
/** One authenticated GET with a hard timeout, answering the notice text or `null`. */
|
|
36
|
+
export async function fetchRestartNotice(target, deps) {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
39
|
+
let status;
|
|
40
|
+
let text;
|
|
41
|
+
try {
|
|
42
|
+
try {
|
|
43
|
+
const response = await deps.fetch(`${target.dashboardUrl}/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice`, {
|
|
44
|
+
method: "GET",
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${target.token}`,
|
|
47
|
+
Accept: "application/json",
|
|
48
|
+
[SESSION_ID_HEADER]: target.askingSessionId,
|
|
49
|
+
},
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
});
|
|
52
|
+
status = response.status;
|
|
53
|
+
text = await response.text();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
if (status === 401 || status === 403)
|
|
63
|
+
return { ok: false, reason: "unauthorized" };
|
|
64
|
+
if (status === 404)
|
|
65
|
+
return { ok: false, reason: "not_found" };
|
|
66
|
+
if (status < 200 || status >= 300)
|
|
67
|
+
return { ok: false, reason: "http_error" };
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = JSON.parse(text);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { ok: false, reason: "bad_response" };
|
|
74
|
+
}
|
|
75
|
+
if (typeof body !== "object" || body === null || !("restart" in body))
|
|
76
|
+
return { ok: false, reason: "bad_response" };
|
|
77
|
+
if (body.restart === null)
|
|
78
|
+
return { ok: true, text: null };
|
|
79
|
+
// A notice exists, so the registry row MUST have answered with its text: a
|
|
80
|
+
// missing one is a server/registry fault, reported rather than papered over.
|
|
81
|
+
const reminders = Array.isArray(body.reminders) ? body.reminders : [];
|
|
82
|
+
const notice = reminders.find((r) => r.key === RESTART_NOTICE_REMINDER_KEY);
|
|
83
|
+
if (typeof notice?.text !== "string" || notice.text === "")
|
|
84
|
+
return { ok: false, reason: "bad_response" };
|
|
85
|
+
return { ok: true, text: notice.text };
|
|
86
|
+
}
|
|
87
|
+
function parseCwd(argv) {
|
|
88
|
+
if (argv.length === 0)
|
|
89
|
+
return process.cwd();
|
|
90
|
+
if (argv.length === 2 && argv[0] === "--cwd" && argv[1] !== "")
|
|
91
|
+
return argv[1];
|
|
92
|
+
return "invalid";
|
|
93
|
+
}
|
|
94
|
+
/** The bin's `restart-notice` subcommand, wired to the real process. */
|
|
95
|
+
export async function runRestartNoticeCommand(argv, env = process.env,
|
|
96
|
+
/** Test seam: the home the connection records are read from. */
|
|
97
|
+
resolveFrom = {}) {
|
|
98
|
+
const cwd = parseCwd(argv);
|
|
99
|
+
if (cwd === "invalid") {
|
|
100
|
+
process.stderr.write(`bad arguments\n${USAGE}\n`);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
104
|
+
if (!sessionId) {
|
|
105
|
+
process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
let predecessor;
|
|
109
|
+
try {
|
|
110
|
+
predecessor = findPredecessorConnection(projectKeyOf(cwd), sessionId, resolveFrom);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
process.stderr.write(`unreadable_records: ${err.message}\n`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
if (predecessor === null)
|
|
117
|
+
return 0;
|
|
118
|
+
let options;
|
|
119
|
+
try {
|
|
120
|
+
options = resolveBridgeOptions({ sessionId: predecessor.sessionId, resumeIds: [] }, env, resolveFrom);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const start = err;
|
|
124
|
+
process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
const output = await fetchRestartNotice({
|
|
128
|
+
dashboardUrl: options.dashboardUrl,
|
|
129
|
+
token: options.token,
|
|
130
|
+
predecessorId: predecessor.sessionId,
|
|
131
|
+
askingSessionId: sessionId,
|
|
132
|
+
}, { fetch: (input, init) => fetch(input, init), requestTimeoutMs: RESTART_NOTICE_REQUEST_TIMEOUT_MS });
|
|
133
|
+
if (!output.ok) {
|
|
134
|
+
process.stderr.write(`${output.reason}: could not ask the dashboard what the previous session of this project was working\n`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
if (output.text !== null)
|
|
138
|
+
process.stdout.write(output.text);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
@@ -31,10 +31,17 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
33
33
|
import { homedir } from "node:os";
|
|
34
|
-
import { join } from "node:path";
|
|
34
|
+
import { join, resolve } from "node:path";
|
|
35
35
|
/** One canonical shape (CLAUDE.md Core Principle 1) — a record of any other version is refused, never adapted. */
|
|
36
|
-
|
|
36
|
+
// DX-3928 — v2 adds `projectKey`, the directory the connecting session ran in, so a
|
|
37
|
+
// restarted session (a NEW session id, no record of its own) can find which plan the
|
|
38
|
+
// previous session of the same project was on (`findPredecessorConnection`).
|
|
39
|
+
export const SESSION_CONNECTION_SCHEMA_VERSION = 2;
|
|
37
40
|
export const STALE_RECORD_MS = 7 * 24 * 60 * 60 * 1000;
|
|
41
|
+
/** DX-3928 — the one definition of "the same project": the resolved working directory. */
|
|
42
|
+
export function projectKeyOf(cwd) {
|
|
43
|
+
return resolve(cwd);
|
|
44
|
+
}
|
|
38
45
|
export function sessionConnectionDir(home = homedir()) {
|
|
39
46
|
return join(home, ".config", "danxbot", "plan-sessions");
|
|
40
47
|
}
|
|
@@ -140,6 +147,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
|
|
|
140
147
|
try {
|
|
141
148
|
write({
|
|
142
149
|
sessionId: inputs.sessionId,
|
|
150
|
+
projectKey: inputs.projectKey,
|
|
143
151
|
dashboardUrl: inputs.dashboardUrl,
|
|
144
152
|
source: inputs.source,
|
|
145
153
|
fingerprint: inputs.fingerprint,
|
|
@@ -159,9 +167,10 @@ export function invalidRecordReason(value) {
|
|
|
159
167
|
return "it is not an object";
|
|
160
168
|
const r = value;
|
|
161
169
|
if (r.schemaVersion !== SESSION_CONNECTION_SCHEMA_VERSION) {
|
|
162
|
-
return `schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION}
|
|
170
|
+
return (`schemaVersion is ${JSON.stringify(r.schemaVersion)}, not ${SESSION_CONNECTION_SCHEMA_VERSION} — ` +
|
|
171
|
+
"a session connected by an older MCP server wrote it; call plan_connect again to rewrite it");
|
|
163
172
|
}
|
|
164
|
-
for (const field of ["sessionId", "dashboardUrl", "fingerprint", "connectedAt"]) {
|
|
173
|
+
for (const field of ["sessionId", "projectKey", "dashboardUrl", "fingerprint", "connectedAt"]) {
|
|
165
174
|
if (typeof r[field] !== "string" || r[field] === "")
|
|
166
175
|
return `${field} is not a non-empty string`;
|
|
167
176
|
}
|
|
@@ -174,3 +183,47 @@ export function invalidRecordReason(value) {
|
|
|
174
183
|
return typeof source.name === "string" && source.name !== "" ? null : "source.name is not a variable name";
|
|
175
184
|
return `source.kind is ${JSON.stringify(source.kind)}, which this version does not know`;
|
|
176
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* DX-3928 — the record of the session that ran in `projectKey` before
|
|
188
|
+
* `currentSessionId`: the newest record for that project, written by another
|
|
189
|
+
* session, within `STALE_RECORD_MS`. `null` when there is none.
|
|
190
|
+
*
|
|
191
|
+
* A record that cannot be understood (another schema version, not JSON) is
|
|
192
|
+
* SKIPPED here and nowhere else: this answers "was a plan being worked in this
|
|
193
|
+
* project", and a record from an older server cannot say. Every consumer of a
|
|
194
|
+
* session's OWN record (`readSessionConnection`) still fails loud.
|
|
195
|
+
*/
|
|
196
|
+
export function findPredecessorConnection(projectKey, currentSessionId, options = {}) {
|
|
197
|
+
const dir = sessionConnectionDir(options.home ?? homedir());
|
|
198
|
+
const now = options.now ?? Date.now();
|
|
199
|
+
let names;
|
|
200
|
+
try {
|
|
201
|
+
names = readdirSync(dir);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null; // nothing written yet
|
|
205
|
+
}
|
|
206
|
+
let newest = null;
|
|
207
|
+
for (const name of names) {
|
|
208
|
+
if (!name.endsWith(".json"))
|
|
209
|
+
continue;
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(readFileSync(join(dir, name), "utf-8"));
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (invalidRecordReason(parsed) !== null)
|
|
218
|
+
continue;
|
|
219
|
+
const record = parsed;
|
|
220
|
+
if (record.projectKey !== projectKey || record.sessionId === currentSessionId)
|
|
221
|
+
continue;
|
|
222
|
+
const connectedAt = Date.parse(record.connectedAt);
|
|
223
|
+
if (!Number.isFinite(connectedAt) || now - connectedAt > STALE_RECORD_MS)
|
|
224
|
+
continue;
|
|
225
|
+
if (newest === null || connectedAt > Date.parse(newest.connectedAt))
|
|
226
|
+
newest = record;
|
|
227
|
+
}
|
|
228
|
+
return newest;
|
|
229
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.203",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/dist/terms-halt.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
const TERMS_ACCEPTANCE_PATH = "/api/account/terms-acceptance";
|
|
2
|
-
// Mirrors `termsAcceptUrl("/")` in src/lib/terms-accept-path.ts (a published package cannot import it);
|
|
3
|
-
// terms-gate.integration.test.ts pins the server's link to the same literal. Only meaningful for an
|
|
4
|
-
// operator session: a dispatch's DANXBOT_DASHBOARD_URL is internal, and agent credentials are never terms-gated.
|
|
5
|
-
const ACCEPT_SCREEN_PATH = "/terms/accept?return=%2F";
|
|
6
|
-
/** Keep in step with `termsRefusalInstruction` in src/dashboard/route-policy.ts. */
|
|
7
|
-
export function haltText(acceptUrl) {
|
|
8
|
-
return ("STOP ALL WORK NOW. The dashboard refuses your access until the person you act for accepts the " +
|
|
9
|
-
"current Terms of Use.\n\n" +
|
|
10
|
-
`Give the person this link to accept them: ${acceptUrl}\n\n` +
|
|
11
|
-
"Do not retry, do not look for another route, and never accept the Terms yourself. " +
|
|
12
|
-
"A dispatched agent ends with danxbot_complete({status: \"failed\", summary}) naming this link, " +
|
|
13
|
-
"and never blocks the card.");
|
|
14
|
-
}
|
|
15
|
-
/** The halt text for a `403 terms_not_accepted` result, or null for any other result. */
|
|
16
|
-
export function termsHaltFor(result) {
|
|
17
|
-
if (result.ok || result.status !== 403)
|
|
18
|
-
return null;
|
|
19
|
-
const body = result.body;
|
|
20
|
-
if (body?.error !== "terms_not_accepted")
|
|
21
|
-
return null;
|
|
22
|
-
if (typeof body.acceptUrl !== "string") {
|
|
23
|
-
throw new Error("[danx-dashboard-mcp] a terms_not_accepted refusal carried no acceptUrl");
|
|
24
|
-
}
|
|
25
|
-
return haltText(body.acceptUrl);
|
|
26
|
-
}
|
|
27
|
-
/** The refusal for a call that would accept the Terms (never an agent's act), or null for any other call. */
|
|
28
|
-
export function termsAcceptanceCallHalt(args, dashboardUrl) {
|
|
29
|
-
const path = args.path.split("?")[0].replace(/\/+$/, "");
|
|
30
|
-
if (args.method !== "POST" || path !== TERMS_ACCEPTANCE_PATH)
|
|
31
|
-
return null;
|
|
32
|
-
return haltText(`${dashboardUrl.replace(/\/+$/, "")}${ACCEPT_SCREEN_PATH}`);
|
|
33
|
-
}
|