@thehammer/danx-dashboard-mcp 0.1.274 → 0.1.276
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 +9 -3
- package/dist/index.js +16 -24
- package/dist/listen.js +10 -0
- package/dist/one-shot-session-request.js +5 -5
- package/dist/request-bound.js +22 -0
- package/dist/restart-notice.js +90 -110
- package/dist/session-connection.js +4 -4
- package/dist/session-options.js +2 -2
- package/dist/session-stream.js +5 -10
- package/dist/stop-fixes.js +6 -0
- package/dist/tool-access.js +10 -1
- package/package.json +1 -1
- package/dist/event-text.js +0 -136
package/README.md
CHANGED
|
@@ -78,11 +78,17 @@ The contract the plugin builds against:
|
|
|
78
78
|
- **Nothing outlives the session.** The heartbeat interval, the backoff sleep and the digest hold are unref'd; `close()` (run when the transport closes and when stdin ends) aborts the stream and the mint and inventory requests and clears a pending call's wait timer, the one ref'd timer, which otherwise holds the process only until the call answers.
|
|
79
79
|
- **Credential and audience.** The server's own, from its boot-time `CLAUDE_CODE_SESSION_ID`: no connection record, fingerprint check or environment variable is involved. The tool is advertised only to a person's session holding `plans.view`, never to a dispatch. The stream's advisory frames (the idle nudge, DX-4340's pacing message, DX-4342's auto-resume message) arrive as records like any event, at once and never held for a digest. The digest (agent and machine events held up to 10 minutes) is one record, `[plan events] digest`, and a held id is never acknowledged before it is returned.
|
|
80
80
|
|
|
81
|
-
The one-shot subcommands below (`activity`, `background-work`, `
|
|
81
|
+
The one-shot subcommands below (`activity`, `background-work`, `ready-cards`) still read the connection record this server wrote on `plan_connect` (`src/session-connection.ts`) and refuse to run unless the credential it names fingerprints identically (`credential_mismatch`, DX-2862).
|
|
82
82
|
|
|
83
|
-
## `
|
|
83
|
+
## `restart_notice` — what a restarted session was working on (DX-3928, DX-4632)
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
A tool for the danxbot 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:
|
|
86
|
+
|
|
87
|
+
- `{notice: "<text>"}`: say it (the registry's `plan_restart.waiting_events` text; this package carries no wording).
|
|
88
|
+
- `{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).
|
|
89
|
+
- `{stopped: {reason, detail, fix}}`: the lookup could not answer: `no_session_id`, `lookup_failed` (no candidate answered; the detail names each reason) or `lookup_timeout`. Never a silent null.
|
|
90
|
+
|
|
91
|
+
ONE deadline of 8 s bounds the whole lookup, every candidate included (each request also keeps its own 5 s bound), so a hung dashboard answers `lookup_timeout` and never wedges a session start. A record from an older server (schema v1) is skipped here and refused, with the fix, everywhere a session reads its OWN record.
|
|
86
92
|
|
|
87
93
|
## `ready-cards` — the plan's cards anyone could pick up now (DX-4534)
|
|
88
94
|
|
package/dist/index.js
CHANGED
|
@@ -86,11 +86,10 @@ import { PlanEventRelay, WAIT_MAX_MS, WAIT_MIN_MS } from "./plan-events-relay.js
|
|
|
86
86
|
import { READ_IDLE_TIMEOUT_MS } from "./listen.js";
|
|
87
87
|
import { REQUEST_TIMEOUT_MS, realScheduleInterval, realSleep } from "./session-stream.js";
|
|
88
88
|
import { servePlanEventsWait } from "./plan-events-tool.js";
|
|
89
|
+
import { lookupRestartNotice } from "./restart-notice.js";
|
|
89
90
|
import { stopWhenSessionEnds } from "./shutdown.js";
|
|
90
91
|
import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
|
|
91
92
|
import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
|
|
92
|
-
import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
93
|
-
import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
|
|
94
93
|
import { READY_CARDS_SUBCOMMAND, runReadyCardsCommand } from "./ready-cards.js";
|
|
95
94
|
import { SUBAGENTS_LIVE_SUBCOMMAND, runSubagentsLiveCommand } from "./subagents-live.js";
|
|
96
95
|
import { resolveBootCredential } from "./credential.js";
|
|
@@ -785,6 +784,17 @@ strictTool("plan_events_wait",
|
|
|
785
784
|
// The relay is bound to the session the server booted for (the id every other tool on this server also sends); a server
|
|
786
785
|
// with none holds no session key, so the surface never shows this tool and the relay would answer `credential_unavailable`.
|
|
787
786
|
async (args, extra) => jsonResult(await servePlanEventsWait(planEventRelay, args, extra.signal)));
|
|
787
|
+
// ---------------- restart_notice (DX-4632) ----------------
|
|
788
|
+
strictTool("restart_notice",
|
|
789
|
+
// DX-4632 - for the danxbot plugin's session-start hook, not for a model to call. General-audience text (DX-4550).
|
|
790
|
+
"Which plan the session this one replaced was working, and what waits there. For the danxbot plugin's session start: " +
|
|
791
|
+
"a model never calls it. Works before sign-in. Answers `{notice: text}` (tell the session), `{notice: null}` (nothing " +
|
|
792
|
+
"to say) or `{stopped: {reason, detail, fix}}`.", {}, async () => jsonResult(await lookupRestartNotice({
|
|
793
|
+
sessionId: config.session?.id,
|
|
794
|
+
cwd: process.cwd(),
|
|
795
|
+
env: process.env,
|
|
796
|
+
fetch: (input, init) => fetch(input, init),
|
|
797
|
+
})));
|
|
788
798
|
// ---------------- main ----------------
|
|
789
799
|
async function main() {
|
|
790
800
|
boot();
|
|
@@ -808,7 +818,7 @@ async function main() {
|
|
|
808
818
|
// symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
|
|
809
819
|
// spawns, not just a direct `node dist/index.js`.
|
|
810
820
|
//
|
|
811
|
-
// The subcommands are one-shot clients (activity, background-work,
|
|
821
|
+
// The subcommands are one-shot clients (activity, background-work, ready-cards, subagents-live)
|
|
812
822
|
// the danxbot plugin runs. None boots the MCP server; the dashboard URL, credential source and credential come from the
|
|
813
823
|
// session-connection record this server's own `plan_connect` wrote (`session-connection.ts`), never from the subcommand's own
|
|
814
824
|
// environment (DX-2862: trusting an ambient env credential is the exact bug this repo fixed). There is no event-stream
|
|
@@ -834,26 +844,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
834
844
|
process.exit(0);
|
|
835
845
|
});
|
|
836
846
|
}
|
|
837
|
-
else if (subcommand === EVENT_TEXT_SUBCOMMAND) {
|
|
838
|
-
// DX-3421 (was DX-3366's `mantra` subcommand) — unlike background-work, a
|
|
839
|
-
// rejection here is a genuine fatal (never expected: runEventTextCommand
|
|
840
|
-
// itself already classifies every network/shape failure into its own
|
|
841
|
-
// {ok:false} exit 1), so it exits 1 too rather than the "always 0, stay
|
|
842
|
-
// silent" contract background-work's hook-only subcommand uses.
|
|
843
|
-
runEventTextCommand(rest).then((code) => process.exit(code), (err) => {
|
|
844
|
-
console.error(`[danx-dashboard-mcp] event-text fatal: ${err.message}`);
|
|
845
|
-
process.exit(1);
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
else if (subcommand === RESTART_NOTICE_SUBCOMMAND) {
|
|
849
|
-
// DX-3928 — same exit-code contract as event-text: 0 (text, or nothing to say) / 1 (lookup failed).
|
|
850
|
-
runRestartNoticeCommand(rest).then((code) => process.exit(code), (err) => {
|
|
851
|
-
console.error(`[danx-dashboard-mcp] restart-notice fatal: ${err.message}`);
|
|
852
|
-
process.exit(1);
|
|
853
|
-
});
|
|
854
|
-
}
|
|
855
847
|
else if (subcommand === READY_CARDS_SUBCOMMAND) {
|
|
856
|
-
// DX-4534 — the plugin Stop hook's read;
|
|
848
|
+
// DX-4534 — the plugin Stop hook's read; exit 0 (one JSON line) / 1 (read failed).
|
|
857
849
|
runReadyCardsCommand(rest).then((code) => process.exit(code), (err) => {
|
|
858
850
|
console.error(`[danx-dashboard-mcp] ${READY_CARDS_SUBCOMMAND} fatal: ${err.message}`);
|
|
859
851
|
process.exit(1);
|
|
@@ -869,8 +861,8 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
869
861
|
}
|
|
870
862
|
else if (subcommand !== undefined) {
|
|
871
863
|
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
|
|
872
|
-
`"${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}",
|
|
873
|
-
`"${
|
|
864
|
+
`"${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", ` +
|
|
865
|
+
`"${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
|
|
874
866
|
process.exit(2);
|
|
875
867
|
}
|
|
876
868
|
else {
|
package/dist/listen.js
CHANGED
|
@@ -181,6 +181,13 @@ export function invalidEventReason(value) {
|
|
|
181
181
|
}
|
|
182
182
|
case "blocked":
|
|
183
183
|
return isCappedText(d.reason) ? null : "detail.reason is not capped text";
|
|
184
|
+
// DX-4622: a reviewer asked for changes on the card's pull request (GitHub review webhook).
|
|
185
|
+
case "pull_request_changes_requested":
|
|
186
|
+
if (typeof d.reviewer !== "string")
|
|
187
|
+
return "detail.reviewer is not a string";
|
|
188
|
+
if (typeof d.review_url !== "string")
|
|
189
|
+
return "detail.review_url is not a string";
|
|
190
|
+
return isCappedText(d.excerpt) ? null : "detail.excerpt is not capped text";
|
|
184
191
|
default:
|
|
185
192
|
return null;
|
|
186
193
|
}
|
|
@@ -256,6 +263,9 @@ function describe(event) {
|
|
|
256
263
|
return `${who} blocked the card: ${quoted(d.reason)}`;
|
|
257
264
|
case "unblocked":
|
|
258
265
|
return `${who} unblocked the card`;
|
|
266
|
+
// DX-4622: the agent must act on the review, so the line names who, where, and what to do next.
|
|
267
|
+
case "pull_request_changes_requested":
|
|
268
|
+
return `${who}: ${d.reviewer} requested changes on the pull request (${d.review_url})${d.excerpt.text === "" ? "" : `: ${quoted(d.excerpt)}`}. Address the review, push, and reply on the pull request`;
|
|
259
269
|
default:
|
|
260
270
|
// A kind newer than this listener still produces a line: an event the
|
|
261
271
|
// agent is never told about is exactly the failure this reader prevents.
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* non-Error) still comes back as a reported reason instead of a value
|
|
25
25
|
* whose `.reason`/`.message`/`.fix` fields are silently `undefined`.
|
|
26
26
|
*/
|
|
27
|
+
import { boundRequest } from "./request-bound.js";
|
|
27
28
|
import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./session-options.js";
|
|
28
29
|
/**
|
|
29
30
|
* One authenticated HTTP call to this session's dashboard, with a hard
|
|
@@ -32,8 +33,7 @@ import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./se
|
|
|
32
33
|
* might produce from the status/body it gets back on success.
|
|
33
34
|
*/
|
|
34
35
|
export async function sendSessionRequest(options, path, init, deps) {
|
|
35
|
-
const
|
|
36
|
-
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
36
|
+
const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
|
|
37
37
|
try {
|
|
38
38
|
try {
|
|
39
39
|
const response = await deps.fetch(`${options.dashboardUrl}${path}`, {
|
|
@@ -45,7 +45,7 @@ export async function sendSessionRequest(options, path, init, deps) {
|
|
|
45
45
|
[SESSION_ID_HEADER]: options.sessionId,
|
|
46
46
|
},
|
|
47
47
|
body: init.body,
|
|
48
|
-
signal:
|
|
48
|
+
signal: bound.signal,
|
|
49
49
|
});
|
|
50
50
|
const text = await response.text();
|
|
51
51
|
return { status: response.status, text };
|
|
@@ -54,11 +54,11 @@ export async function sendSessionRequest(options, path, init, deps) {
|
|
|
54
54
|
// DX-3367 fix-up — the caught value is never inspected (an aborted
|
|
55
55
|
// fetch's own rejection shape varies by runtime), so binding it to a
|
|
56
56
|
// name the code never reads was dead weight the linter never caught.
|
|
57
|
-
return { ok: false, reason:
|
|
57
|
+
return { ok: false, reason: bound.aborted() ? "timeout" : "request_failed" };
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
finally {
|
|
61
|
-
|
|
61
|
+
bound.release();
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
/** `JSON.parse`, classified rather than thrown — the ONE shared parse point
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-4632 - the ONE bound every dashboard request of this package shares: a timeout of its own, plus an OUTER signal that ends
|
|
3
|
+
* the request early (a lookup's one deadline, a closed relay). Used by `sendSessionRequest` (`one-shot-session-request.ts`: the
|
|
4
|
+
* one-shot subcommands and `restart_notice`) and by `request` in `session-stream.ts` (the relay's mint, inventory and
|
|
5
|
+
* heartbeat), so the controller, the timer, the link to the outer signal and the cleanup exist once.
|
|
6
|
+
*/
|
|
7
|
+
export function boundRequest(timeoutMs, outer) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const abort = () => controller.abort();
|
|
10
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
11
|
+
if (outer?.aborted === true)
|
|
12
|
+
abort();
|
|
13
|
+
outer?.addEventListener("abort", abort, { once: true });
|
|
14
|
+
return {
|
|
15
|
+
signal: controller.signal,
|
|
16
|
+
aborted: () => controller.signal.aborted,
|
|
17
|
+
release: () => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
outer?.removeEventListener("abort", abort);
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/restart-notice.js
CHANGED
|
@@ -1,83 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
3
|
-
*
|
|
4
|
-
* NOT plan-connected, to tell it when the previous session of the same project
|
|
5
|
-
* was working a plan.
|
|
2
|
+
* The `restart_notice` tool - DX-3928, DX-4632. How a RESTARTED Claude Code session learns which plan the session it replaced was
|
|
3
|
+
* working, so it can say so to its agent and reconnect.
|
|
6
4
|
*
|
|
7
|
-
* WHY IT EXISTS. A restarted
|
|
8
|
-
*
|
|
9
|
-
* working was unattended: six operator answers sat unread for an hour on
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
+
* dashboard key of its own: its MCP server boots signed out. Nothing told it (or the operator) that the plan it had been
|
|
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
|
|
9
|
+
* (`findPredecessorCandidates`). So this asks the dashboard about each predecessor as the predecessor, with the credential
|
|
10
|
+
* that predecessor's record names (`resolveDashboardAuth`, the resolver the one-shot subcommands use), 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
|
|
12
|
+
* permission) and only on a person's session (`tool-access.ts`).
|
|
13
13
|
*
|
|
14
|
-
* THE
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
14
|
+
* THE ANSWER (the plugin's hooks module reads it through `$.mcp.call`):
|
|
15
|
+
* - `{notice: "<text>"}` - say this to the agent (the registry's `plan_restart.waiting_events` text; this module carries no
|
|
16
|
+
* wording of its own).
|
|
17
|
+
* - `{notice: null}` - nothing to say: no earlier session of this project connected a plan, or the plan is archived or
|
|
18
|
+
* deleted, or the record is stale, or every predecessor is still live. The plugin stays silent, as PLN-11 R-10 requires of
|
|
19
|
+
* a project that never connected a plan.
|
|
20
|
+
* - `{stopped: {reason, detail, fix}}` - the lookup could not answer: no session id, NO candidate answered (credential,
|
|
21
|
+
* network, non-2xx, bad shape, for every one), or the one deadline passed. Never a silent null. (A failure that is not
|
|
22
|
+
* one of these, a bug in the credential resolver, is rethrown: fail loud, never dressed as a lookup failure.)
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* ONE DEADLINE over the whole lookup (`RESTART_NOTICE_DEADLINE_MS`): every candidate's request shares it (and each keeps its own
|
|
25
|
+
* shorter bound), so a hung dashboard cannot wedge a session start however many candidates there are. READ-ONLY: it advances
|
|
26
|
+
* no cursor on the server.
|
|
27
27
|
*/
|
|
28
|
-
import {
|
|
28
|
+
import { sendSessionRequest, tryParseJsonBody } from "./one-shot-session-request.js";
|
|
29
|
+
import { resolveDashboardAuth, SessionStartError } from "./session-options.js";
|
|
29
30
|
import { findPredecessorCandidates, projectKeyOf } from "./session-connection.js";
|
|
30
|
-
|
|
31
|
+
import { fixForStop } from "./stop-fixes.js";
|
|
31
32
|
export const RESTART_NOTICE_REQUEST_TIMEOUT_MS = 5_000;
|
|
33
|
+
/** DX-4632: the whole lookup, every candidate included (the old hook's `timeout 8s`). */
|
|
34
|
+
export const RESTART_NOTICE_DEADLINE_MS = 8_000;
|
|
32
35
|
/**
|
|
33
|
-
* How many earlier sessions of the project one
|
|
36
|
+
* How many earlier sessions of the project one lookup asks about, newest first. Many
|
|
34
37
|
* sessions share one checkout and the dashboard answers "nothing" for a LIVE sibling
|
|
35
38
|
* (it is not a predecessor), so the newest candidate is often not the one.
|
|
36
39
|
*/
|
|
37
40
|
export const RESTART_NOTICE_MAX_CANDIDATES = 10;
|
|
38
41
|
/** The registry row whose text is the notice (`src/issues/reminders/seed.yaml`). */
|
|
39
42
|
export const RESTART_NOTICE_REMINDER_KEY = "plan_restart.waiting_events";
|
|
40
|
-
|
|
41
|
-
/** One authenticated GET with a hard timeout, answering the notice text or `null`. */
|
|
43
|
+
/** One authenticated GET (the shared one-shot request, so its bound and its failure names are the same), answering the notice text or `null`. */
|
|
42
44
|
export async function fetchRestartNotice(target, deps) {
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const response = await deps.fetch(`${target.dashboardUrl}/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice`, {
|
|
50
|
-
method: "GET",
|
|
51
|
-
headers: {
|
|
52
|
-
Authorization: `Bearer ${target.token}`,
|
|
53
|
-
Accept: "application/json",
|
|
54
|
-
[SESSION_ID_HEADER]: target.askingSessionId,
|
|
55
|
-
},
|
|
56
|
-
signal: controller.signal,
|
|
57
|
-
});
|
|
58
|
-
status = response.status;
|
|
59
|
-
text = await response.text();
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
finally {
|
|
66
|
-
clearTimeout(timer);
|
|
67
|
-
}
|
|
45
|
+
// The bearer is the predecessor's; the session id header names the ASKING session.
|
|
46
|
+
const auth = { dashboardUrl: target.dashboardUrl, token: target.token, sessionId: target.askingSessionId };
|
|
47
|
+
const sent = await sendSessionRequest(auth, `/api/plan-sessions/${encodeURIComponent(target.predecessorId)}/restart-notice`, { method: "GET" }, deps);
|
|
48
|
+
if ("ok" in sent)
|
|
49
|
+
return { ok: false, reason: sent.reason };
|
|
50
|
+
const { status, text } = sent;
|
|
68
51
|
if (status === 401 || status === 403)
|
|
69
52
|
return { ok: false, reason: "unauthorized" };
|
|
70
53
|
if (status === 404)
|
|
71
54
|
return { ok: false, reason: "not_found" };
|
|
72
55
|
if (status < 200 || status >= 300)
|
|
73
56
|
return { ok: false, reason: "http_error" };
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
body = JSON.parse(text);
|
|
77
|
-
}
|
|
78
|
-
catch {
|
|
79
|
-
return { ok: false, reason: "bad_response" };
|
|
80
|
-
}
|
|
57
|
+
const parsed = tryParseJsonBody(text);
|
|
58
|
+
const body = parsed.ok ? parsed.body : null;
|
|
81
59
|
if (typeof body !== "object" || body === null || !("restart" in body))
|
|
82
60
|
return { ok: false, reason: "bad_response" };
|
|
83
61
|
if (body.restart === null)
|
|
@@ -90,61 +68,63 @@ export async function fetchRestartNotice(target, deps) {
|
|
|
90
68
|
return { ok: false, reason: "bad_response" };
|
|
91
69
|
return { ok: true, text: notice.text };
|
|
92
70
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
return
|
|
98
|
-
return "invalid";
|
|
99
|
-
}
|
|
100
|
-
/** The bin's `restart-notice` subcommand, wired to the real process. */
|
|
101
|
-
export async function runRestartNoticeCommand(argv, env = process.env,
|
|
102
|
-
/** Test seam: the home the connection records are read from. */
|
|
103
|
-
resolveFrom = {}) {
|
|
104
|
-
const cwd = parseCwd(argv);
|
|
105
|
-
if (cwd === "invalid") {
|
|
106
|
-
process.stderr.write(`bad arguments\n${USAGE}\n`);
|
|
107
|
-
return 1;
|
|
108
|
-
}
|
|
109
|
-
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
110
|
-
if (!sessionId) {
|
|
111
|
-
process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
|
|
112
|
-
return 1;
|
|
71
|
+
const stop = (reason, detail) => ({ stopped: { reason, detail, fix: fixForStop(reason) } });
|
|
72
|
+
/** The tool's whole body: see the module docblock for the contract. Throws only on a bug, never on a lookup failure. */
|
|
73
|
+
export async function lookupRestartNotice(lookup) {
|
|
74
|
+
if (lookup.sessionId === undefined || lookup.sessionId === "") {
|
|
75
|
+
return stop("no_session_id", "this server has no CLAUDE_CODE_SESSION_ID, so it cannot tell which session it replaced");
|
|
113
76
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
77
|
+
const resolveFrom = { home: lookup.home, now: lookup.now };
|
|
78
|
+
const candidates = findPredecessorCandidates(projectKeyOf(lookup.cwd), lookup.sessionId, resolveFrom).slice(0, RESTART_NOTICE_MAX_CANDIDATES);
|
|
79
|
+
// DX-4632: ONE deadline for every candidate read. It is not unref'd: the lookup is awaited, and a pending request holds the
|
|
80
|
+
// process anyway.
|
|
81
|
+
const deadline = new AbortController();
|
|
82
|
+
const deadlineMs = lookup.deadlineMs ?? RESTART_NOTICE_DEADLINE_MS;
|
|
83
|
+
const timer = setTimeout(() => deadline.abort(), deadlineMs);
|
|
84
|
+
try {
|
|
85
|
+
// One candidate failing (its credential moved, its dashboard is down) must not hide a LATER candidate that
|
|
86
|
+
// would answer: keep going, and report failure only when no candidate answered at all.
|
|
87
|
+
const failures = [];
|
|
88
|
+
let answered = 0;
|
|
89
|
+
for (const predecessor of candidates) {
|
|
90
|
+
let options;
|
|
91
|
+
try {
|
|
92
|
+
options = resolveDashboardAuth(predecessor.sessionId, lookup.env, resolveFrom);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (!(err instanceof SessionStartError))
|
|
96
|
+
throw err; // a bug in the resolver is not a lookup failure: fail loud
|
|
97
|
+
failures.push(`${err.reason}: ${err.message}. Fix: ${err.fix}.`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const output = await fetchRestartNotice({
|
|
101
|
+
dashboardUrl: options.dashboardUrl,
|
|
102
|
+
token: options.token,
|
|
103
|
+
predecessorId: predecessor.sessionId,
|
|
104
|
+
askingSessionId: lookup.sessionId,
|
|
105
|
+
}, { fetch: lookup.fetch, requestTimeoutMs: lookup.requestTimeoutMs ?? RESTART_NOTICE_REQUEST_TIMEOUT_MS, signal: deadline.signal });
|
|
106
|
+
// A notice the dashboard fully answered is kept even when the deadline fired right after it; every other outcome of a
|
|
107
|
+
// deadline that passed is the stop below (this is the lookup's one deadline check).
|
|
108
|
+
if (output.ok && output.text !== null)
|
|
109
|
+
return { notice: output.text };
|
|
110
|
+
if (deadline.signal.aborted)
|
|
111
|
+
break;
|
|
112
|
+
if (!output.ok) {
|
|
113
|
+
failures.push(`${output.reason}: could not ask the dashboard what session ${predecessor.sessionId} was working`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
answered += 1;
|
|
128
117
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
predecessorId: predecessor.sessionId,
|
|
133
|
-
askingSessionId: sessionId,
|
|
134
|
-
}, { fetch: (input, init) => fetch(input, init), requestTimeoutMs: RESTART_NOTICE_REQUEST_TIMEOUT_MS });
|
|
135
|
-
if (!output.ok) {
|
|
136
|
-
failures.push(`${output.reason}: could not ask the dashboard what session ${predecessor.sessionId} was working`);
|
|
137
|
-
continue;
|
|
118
|
+
// DX-4632: a deadline that passed is a stop even when earlier candidates answered "nothing": an unasked candidate may be the one.
|
|
119
|
+
if (deadline.signal.aborted) {
|
|
120
|
+
return stop("lookup_timeout", `the lookup did not finish within ${deadlineMs} ms (${failures.length} of ${candidates.length} candidates had failed)`);
|
|
138
121
|
}
|
|
139
|
-
answered
|
|
140
|
-
|
|
141
|
-
process.stdout.write(output.text);
|
|
142
|
-
return 0;
|
|
122
|
+
if (answered === 0 && failures.length > 0) {
|
|
123
|
+
return stop("lookup_failed", `no candidate answered (${failures.length} of ${candidates.length} failed): ${failures.join(" | ")}`);
|
|
143
124
|
}
|
|
125
|
+
return { notice: null };
|
|
144
126
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return 1;
|
|
127
|
+
finally {
|
|
128
|
+
clearTimeout(timer);
|
|
148
129
|
}
|
|
149
|
-
return 0;
|
|
150
130
|
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* DX-2862 — the record that hands ONE Claude Code session's dashboard
|
|
3
3
|
* connection from its MCP server to the plugin's hook subcommands.
|
|
4
4
|
*
|
|
5
|
-
* THE PROBLEM THIS SOLVES. A hook's subcommand (`activity`, `background-work`,
|
|
6
|
-
* `ready-cards
|
|
5
|
+
* THE PROBLEM THIS SOLVES. A hook's subcommand (`activity`, `background-work`,
|
|
6
|
+
* `ready-cards`) runs in the SESSION's environment, not in the MCP server's, which is the only
|
|
7
7
|
* place the server's `.mcp.json` env block exists. So it cannot see what the server was configured with, and before this it
|
|
8
8
|
* simply used its own ambient env credential. When that was a different credential from the server's, its report was
|
|
9
9
|
* dropped silently (the card's incident). The plan event relay (`plan_events_wait`) needs none of this: it runs inside the
|
|
@@ -157,7 +157,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
|
|
|
157
157
|
if (inputs.sessionId === undefined || inputs.sessionId === "") {
|
|
158
158
|
return {
|
|
159
159
|
connection_record_error: "connected, but this server has no CLAUDE_CODE_SESSION_ID, so no connection record can be written — " +
|
|
160
|
-
"the plugin's activity, background-work
|
|
160
|
+
"the plugin's activity, background-work and ready-cards reports will not reach the dashboard",
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
163
|
try {
|
|
@@ -173,7 +173,7 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
|
|
|
173
173
|
catch (err) {
|
|
174
174
|
return {
|
|
175
175
|
connection_record_error: `connected, but recording this session's connection failed (${err.message}) — ` +
|
|
176
|
-
"the plugin's activity, background-work
|
|
176
|
+
"the plugin's activity, background-work and ready-cards reports will not reach the dashboard until it succeeds",
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
}
|
package/dist/session-options.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DX-2862 / DX-4233 - how a one-shot client of the dashboard (`activity`, `background-work`, `
|
|
3
|
-
* `
|
|
2
|
+
* DX-2862 / DX-4233 - how a one-shot client of the dashboard (`activity`, `background-work`, `ready-cards`, and the
|
|
3
|
+
* predecessor lookup of `restart_notice`) learns WHICH dashboard to call and AS WHOM: the connection record this session's own MCP server wrote when
|
|
4
4
|
* it connected a plan (`session-connection.ts`), resolved through the same credential resolver the server used, and refused
|
|
5
5
|
* unless it fingerprints identically. (The in-process relay behind `plan_events_wait` needs none of this: it lives inside the
|
|
6
6
|
* server that holds the credential.)
|
package/dist/session-stream.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* what a stop means for the session) belongs to the relay.
|
|
8
8
|
*/
|
|
9
9
|
import { errorCodeOf } from "./json-body.js";
|
|
10
|
+
import { boundRequest } from "./request-bound.js";
|
|
10
11
|
import { keyRevocationOf, keyRevokedHalt } from "./key-revoked-halt.js";
|
|
11
12
|
import { oneLine } from "./one-line.js";
|
|
12
13
|
import { SESSION_ID_HEADER } from "./session-options.js";
|
|
@@ -64,12 +65,7 @@ const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
|
|
|
64
65
|
* failures are worth retrying.
|
|
65
66
|
*/
|
|
66
67
|
async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
67
|
-
const
|
|
68
|
-
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
69
|
-
const abort = () => controller.abort();
|
|
70
|
-
if (deps.signal?.aborted === true)
|
|
71
|
-
abort();
|
|
72
|
-
deps.signal?.addEventListener("abort", abort, { once: true });
|
|
68
|
+
const bound = boundRequest(deps.requestTimeoutMs, deps.signal);
|
|
73
69
|
try {
|
|
74
70
|
let status;
|
|
75
71
|
let text;
|
|
@@ -85,7 +81,7 @@ async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
|
85
81
|
method: init.method,
|
|
86
82
|
headers,
|
|
87
83
|
body: init.body,
|
|
88
|
-
signal:
|
|
84
|
+
signal: bound.signal,
|
|
89
85
|
});
|
|
90
86
|
status = response.status;
|
|
91
87
|
text = await response.text();
|
|
@@ -93,7 +89,7 @@ async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
|
93
89
|
catch (err) {
|
|
94
90
|
return {
|
|
95
91
|
kind: "transient",
|
|
96
|
-
detail:
|
|
92
|
+
detail: bound.aborted()
|
|
97
93
|
? `${what} timed out after ${deps.requestTimeoutMs} ms`
|
|
98
94
|
: `${what} failed: ${err.message}`,
|
|
99
95
|
};
|
|
@@ -111,8 +107,7 @@ async function request(options, deps, what, url, init = { method: "GET" }) {
|
|
|
111
107
|
return { kind: "answer", status, text, body };
|
|
112
108
|
}
|
|
113
109
|
finally {
|
|
114
|
-
|
|
115
|
-
deps.signal?.removeEventListener("abort", abort);
|
|
110
|
+
bound.release();
|
|
116
111
|
}
|
|
117
112
|
}
|
|
118
113
|
/** What is wrong with a 2xx mint body, or `null` when it is a ticket. Never echoes the body. */
|
package/dist/stop-fixes.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* DX-4233 - the texts name the session's listener, never a process: with the relay inside the session's own MCP server there
|
|
13
13
|
* is no separate process to restart.
|
|
14
14
|
*/
|
|
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";
|
|
15
17
|
export const STOP_FIXES = {
|
|
16
18
|
no_connection_record: "call plan_connect again in this session — its danx-dashboard MCP server records the connection a hook command needs " +
|
|
17
19
|
"(a server older than the one that introduced the record writes none)",
|
|
@@ -32,6 +34,10 @@ export const STOP_FIXES = {
|
|
|
32
34
|
refused: "call plan_connect again to mint a new listener ticket for this session",
|
|
33
35
|
// DX-3028 - this session id belongs to a worker dispatch; there is no listener ticket a worker session can ever use.
|
|
34
36
|
session_is_worker: "this session id belongs to a worker dispatch; start a new session",
|
|
37
|
+
// DX-4632: `restart_notice` only tells a restarted session which plan its predecessor was on; none of its stops blocks anything.
|
|
38
|
+
no_session_id: `${RESTART_NOTICE_STOP_LEAD}: this server was started without a session id, so it cannot tell which session it replaced; ${RESTART_NOTICE_STOP_TAIL}`,
|
|
39
|
+
lookup_timeout: `${RESTART_NOTICE_STOP_LEAD}: the dashboard did not answer in time; ${RESTART_NOTICE_STOP_TAIL}`,
|
|
40
|
+
lookup_failed: `${RESTART_NOTICE_STOP_LEAD}: no earlier session's record could be checked (the detail says why); ${RESTART_NOTICE_STOP_TAIL}`,
|
|
35
41
|
relay_failed: "the event relay inside this session's danx-dashboard MCP server failed (the detail says why); the next call tries again, and if it keeps failing restart this Claude Code session",
|
|
36
42
|
};
|
|
37
43
|
export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the listener";
|
package/dist/tool-access.js
CHANGED
|
@@ -27,6 +27,8 @@ export const CALLBACK_ENV = {
|
|
|
27
27
|
};
|
|
28
28
|
const BOOTSTRAP = { permission: null, callback: null };
|
|
29
29
|
const needs = (permission) => ({ permission, callback: null });
|
|
30
|
+
/** DX-4632: usable with no key (a session that has not signed in yet) and only on a person's session. */
|
|
31
|
+
const OPERATOR_SESSION_BOOTSTRAP = { permission: null, callback: null, operatorSessionOnly: true };
|
|
30
32
|
const operatorSessionNeeds = (permission) => ({ permission, callback: null, operatorSessionOnly: true });
|
|
31
33
|
const needsAndPostsTo = (permission, callback) => ({ permission, callback });
|
|
32
34
|
/**
|
|
@@ -44,6 +46,10 @@ export const TOOL_ACCESS = {
|
|
|
44
46
|
// DX-4233 - the plugin's relay waits on the session's plan events here: it opens the listener ticket
|
|
45
47
|
// (`POST /api/plan-sessions/me/stream-ticket`) and heartbeats (`.../me/heartbeat`), both `plans.view`.
|
|
46
48
|
plan_events_wait: operatorSessionNeeds("plans.view"),
|
|
49
|
+
// DX-4632 - the plugin's session-start hook asks which plan the session this one replaced was working. A restarted session boots
|
|
50
|
+
// signed out, so it holds no permission: like `plan_connect` it is a bootstrap tool, and (like `plan_events_wait`) a person's
|
|
51
|
+
// session's only. The lookup asks the dashboard as the predecessor, with the credential the predecessor's record names.
|
|
52
|
+
restart_notice: OPERATOR_SESSION_BOOTSTRAP,
|
|
47
53
|
// The one dispatch callback that stays a tool (DX-4525): it reads local files. Its dashboard route's own policy names the
|
|
48
54
|
// permission (`route-policies/index.ts`); every other callback is a `danxbot_api` call to a route under `callback-routes.ts`.
|
|
49
55
|
danxbot_slack_reply: needsAndPostsTo("slack.threads.post", "slackReply"),
|
|
@@ -62,7 +68,10 @@ export function isUsable(access, grant) {
|
|
|
62
68
|
export function toolsFor(grant) {
|
|
63
69
|
return Object.keys(TOOL_ACCESS).filter((name) => isUsable(TOOL_ACCESS[name], grant)).sort();
|
|
64
70
|
}
|
|
65
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The bootstrap set a DISPATCH sees: usable with no key, no callback, and not operator-session-only. A signed-out PERSON's
|
|
73
|
+
* session is shown this plus the operator-only bootstrap tools (`restart_notice`): see `bootstrapSurfaceTools()`.
|
|
74
|
+
*/
|
|
66
75
|
export function bootstrapTools() {
|
|
67
76
|
return toolsFor({ permissions: new Set(), callbacks: new Set(), operatorSession: false });
|
|
68
77
|
}
|
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.276",
|
|
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, request_permission, attach_file, and danxbot_slack_reply) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/dist/event-text.js
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `danx-dashboard-mcp event-text <event>` — the ONE-SHOT client of
|
|
3
|
-
* `GET /api/reminders/event/<event>` (DX-3421).
|
|
4
|
-
*
|
|
5
|
-
* WHO RUNS IT. The claude-plugins `danxbot` plugin's single event hook
|
|
6
|
-
* (`event-hook.sh`, SessionStart matcher "startup|resume|compact" plus
|
|
7
|
-
* SubagentStart matcher ".*") — replaces the old `mantra` subcommand's ONE
|
|
8
|
-
* consumer, `mantra.sh` (deleted, DX-3421, along with
|
|
9
|
-
* `plan-workflow-autoload.sh` and `compaction-skill-reload.sh`; all three
|
|
10
|
-
* collapse into the one hook + this one subcommand). `<event>` is one of
|
|
11
|
-
* `session_start` | `session_resume` | `after_compaction` |
|
|
12
|
-
* `sub_agent_start` — the SAME `npx -y @thehammer/danx-dashboard-mcp@<pin>`
|
|
13
|
-
* path the plugin's other one-shot subcommands (`activity`, `background-work`, `ready-cards`, `restart-notice`) already use — never a
|
|
14
|
-
* separate install, never a deep import of this package's `dist/` internals.
|
|
15
|
-
*
|
|
16
|
-
* THE CREDENTIAL — the SAME resolver the other one-shot subcommands use
|
|
17
|
-
* (`resolveDashboardAuth`, `session-options.ts`): the dashboard URL, credential source
|
|
18
|
-
* and credential all come from the connection record this session's OWN
|
|
19
|
-
* danx-dashboard MCP server wrote on its last successful `plan_connect`
|
|
20
|
-
* (`session-connection.ts`), never from this process's own ambient env. This
|
|
21
|
-
* is only ever called once a session is already connected (`event-hook.sh`
|
|
22
|
-
* checks that first via `plan-connection.mjs` and stays completely silent
|
|
23
|
-
* otherwise — DX-3421 comment 7748/7752 verdict: no output at all before a
|
|
24
|
-
* plan is connected), so a connection record always exists by the time this
|
|
25
|
-
* runs.
|
|
26
|
-
*
|
|
27
|
-
* THE CONTRACT — `event-hook.sh` needs to tell success from failure so it
|
|
28
|
-
* can print the operator-facing "could not load" notice (DX-3421 AC: no
|
|
29
|
-
* committed offline fallback exists any more — a fetch failure is ALWAYS
|
|
30
|
-
* reported to the agent, never silently swallowed and never a `mantra.md`
|
|
31
|
-
* reread). So:
|
|
32
|
-
*
|
|
33
|
-
* - success: the event's EFFECTIVE text on stdout, nothing else, exit 0.
|
|
34
|
-
* - failure (missing env, no connection record, network error, timeout,
|
|
35
|
-
* non-2xx, 404, bad shape): NOTHING on stdout, one short reason on
|
|
36
|
-
* stderr, exit 1. `event-hook.sh` is the one that decides what to print
|
|
37
|
-
* for the agent — this module only reports whether the fetch worked.
|
|
38
|
-
*
|
|
39
|
-
* A HARD TIMEOUT — a SessionStart hook must never hang on a wedged
|
|
40
|
-
* dashboard.
|
|
41
|
-
*/
|
|
42
|
-
import { SESSION_ID_HEADER, resolveDashboardAuth } from "./session-options.js";
|
|
43
|
-
export const EVENT_TEXT_SUBCOMMAND = "event-text";
|
|
44
|
-
export const EVENT_TEXT_REQUEST_TIMEOUT_MS = 5_000;
|
|
45
|
-
const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${EVENT_TEXT_SUBCOMMAND} <event>`;
|
|
46
|
-
function eventPath(event) {
|
|
47
|
-
return `/api/reminders/event/${encodeURIComponent(event)}`;
|
|
48
|
-
}
|
|
49
|
-
/** One authenticated GET, with a hard timeout — never throws, never bubbles a non-2xx. */
|
|
50
|
-
export async function fetchEventText(event, options, deps) {
|
|
51
|
-
const controller = new AbortController();
|
|
52
|
-
const timer = setTimeout(() => controller.abort(), deps.requestTimeoutMs);
|
|
53
|
-
let status;
|
|
54
|
-
let text;
|
|
55
|
-
try {
|
|
56
|
-
try {
|
|
57
|
-
const response = await deps.fetch(`${options.dashboardUrl}${eventPath(event)}`, {
|
|
58
|
-
method: "GET",
|
|
59
|
-
headers: {
|
|
60
|
-
Authorization: `Bearer ${options.token}`,
|
|
61
|
-
Accept: "application/json",
|
|
62
|
-
[SESSION_ID_HEADER]: options.sessionId,
|
|
63
|
-
},
|
|
64
|
-
signal: controller.signal,
|
|
65
|
-
});
|
|
66
|
-
status = response.status;
|
|
67
|
-
text = await response.text();
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return { ok: false, reason: controller.signal.aborted ? "timeout" : "request_failed" };
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
finally {
|
|
74
|
-
clearTimeout(timer);
|
|
75
|
-
}
|
|
76
|
-
if (status === 401 || status === 403)
|
|
77
|
-
return { ok: false, reason: "unauthorized" };
|
|
78
|
-
if (status === 404)
|
|
79
|
-
return { ok: false, reason: "not_found" };
|
|
80
|
-
if (status < 200 || status >= 300)
|
|
81
|
-
return { ok: false, reason: "http_error" };
|
|
82
|
-
let body;
|
|
83
|
-
try {
|
|
84
|
-
body = text === "" ? null : JSON.parse(text);
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
return { ok: false, reason: "bad_response" };
|
|
88
|
-
}
|
|
89
|
-
const effectiveText = typeof body === "object" &&
|
|
90
|
-
body !== null &&
|
|
91
|
-
typeof body.effective_text === "string"
|
|
92
|
-
? body.effective_text
|
|
93
|
-
: null;
|
|
94
|
-
if (effectiveText === null)
|
|
95
|
-
return { ok: false, reason: "bad_response" };
|
|
96
|
-
return { ok: true, text: effectiveText };
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* The bin's `event-text` subcommand, wired to the real process. Success
|
|
100
|
-
* prints the effective text on stdout and returns 0; every failure prints
|
|
101
|
-
* nothing on stdout, one reason on stderr, and returns 1 — `event-hook.sh`
|
|
102
|
-
* reads the exit code, never parses stdout to guess.
|
|
103
|
-
*/
|
|
104
|
-
export async function runEventTextCommand(argv, env = process.env,
|
|
105
|
-
/** Test seam: the home the session's connection record is read from. */
|
|
106
|
-
resolveFrom = {}) {
|
|
107
|
-
const [event, ...rest] = argv;
|
|
108
|
-
if (rest.length !== 0 || typeof event !== "string" || event === "") {
|
|
109
|
-
process.stderr.write(`missing or extra arguments\n${USAGE}\n`);
|
|
110
|
-
return 1;
|
|
111
|
-
}
|
|
112
|
-
const sessionId = env.CLAUDE_CODE_SESSION_ID;
|
|
113
|
-
if (!sessionId) {
|
|
114
|
-
process.stderr.write(`no_session_id: CLAUDE_CODE_SESSION_ID is not set\n${USAGE}\n`);
|
|
115
|
-
return 1;
|
|
116
|
-
}
|
|
117
|
-
let options;
|
|
118
|
-
try {
|
|
119
|
-
options = resolveDashboardAuth(sessionId, env, resolveFrom);
|
|
120
|
-
}
|
|
121
|
-
catch (err) {
|
|
122
|
-
const start = err;
|
|
123
|
-
process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
|
|
124
|
-
return 1;
|
|
125
|
-
}
|
|
126
|
-
const output = await fetchEventText(event, options, {
|
|
127
|
-
fetch: (input, init) => fetch(input, init),
|
|
128
|
-
requestTimeoutMs: EVENT_TEXT_REQUEST_TIMEOUT_MS,
|
|
129
|
-
});
|
|
130
|
-
if (!output.ok) {
|
|
131
|
-
process.stderr.write(`${output.reason}: could not fetch the effective "${event}" text from the dashboard\n`);
|
|
132
|
-
return 1;
|
|
133
|
-
}
|
|
134
|
-
process.stdout.write(output.text);
|
|
135
|
-
return 0;
|
|
136
|
-
}
|