@thehammer/danx-dashboard-mcp 0.1.271 → 0.1.274
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 +25 -18
- package/dist/activity.js +1 -1
- package/dist/background-work.js +4 -4
- package/dist/credential.js +7 -8
- package/dist/digest-hold.js +65 -0
- package/dist/event-text.js +5 -5
- package/dist/handlers.js +4 -4
- package/dist/heartbeat-reporter.js +121 -0
- package/dist/index.js +95 -39
- package/dist/json-body.js +3 -3
- package/dist/listen.js +19 -13
- package/dist/one-shot-session-request.js +7 -7
- package/dist/plan-events-relay.js +425 -0
- package/dist/plan-events-tool.js +9 -0
- package/dist/restart-notice.js +3 -3
- package/dist/session-access.js +3 -3
- package/dist/session-connection.js +16 -17
- package/dist/session-key.js +2 -2
- package/dist/session-options.js +58 -0
- package/dist/session-stats.js +10 -19
- package/dist/session-stream.js +358 -0
- package/dist/session-title.js +5 -5
- package/dist/shutdown.js +12 -0
- package/dist/stop-fixes.js +40 -0
- package/dist/subagents-live.js +2 -2
- package/dist/tool-access.js +7 -1
- package/dist/tool-surface.js +5 -7
- package/package.json +3 -2
- package/dist/bridge.js +0 -1125
package/dist/index.js
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
*
|
|
55
55
|
* Env at boot (validated fail-loud — a missing required var → process.exit(1)):
|
|
56
56
|
* DANXBOT_DASHBOARD_URL dashboard base (e.g. http://danxbot-dashboard:5555)
|
|
57
|
-
* the CREDENTIAL (DX-4390), resolved by the ONE resolver this package's event
|
|
57
|
+
* the CREDENTIAL (DX-4390), resolved by the ONE resolver this package's event relay
|
|
58
58
|
* also uses (`credential.ts`, DX-2862), and never a reason to exit:
|
|
59
59
|
* DANX_DASHBOARD_CREDENTIAL the bearer verbatim (a dispatch, substituted into
|
|
60
60
|
* the materialized `.mcp.json` from its overlay)
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
* halts, and `plan_connect` requests access (`session-access.ts`)
|
|
64
64
|
* DANXBOT_TEAM OPTIONAL (DX-4446), non-secret: the team slug (or id) whose
|
|
65
65
|
* approvers see this session's access request in their danxbot
|
|
66
|
-
* site. Set it in
|
|
66
|
+
* site. Set it in your shell or a repo's committed `.claude/settings.json` `env` block.
|
|
67
67
|
* Unset: the request is answered by link only. Set but not a
|
|
68
68
|
* valid ref, or unknown to the dashboard: fails loud, never a
|
|
69
69
|
* silent fall back to the link flow.
|
|
@@ -82,7 +82,11 @@
|
|
|
82
82
|
* 5xx and network failures throw — never silently swallowed.
|
|
83
83
|
*/
|
|
84
84
|
import { isEntrypointModule } from "./entrypoint.js";
|
|
85
|
-
import {
|
|
85
|
+
import { PlanEventRelay, WAIT_MAX_MS, WAIT_MIN_MS } from "./plan-events-relay.js";
|
|
86
|
+
import { READ_IDLE_TIMEOUT_MS } from "./listen.js";
|
|
87
|
+
import { REQUEST_TIMEOUT_MS, realScheduleInterval, realSleep } from "./session-stream.js";
|
|
88
|
+
import { servePlanEventsWait } from "./plan-events-tool.js";
|
|
89
|
+
import { stopWhenSessionEnds } from "./shutdown.js";
|
|
86
90
|
import { BACKGROUND_WORK_SUBCOMMAND, runBackgroundWorkCommand } from "./background-work.js";
|
|
87
91
|
import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
|
|
88
92
|
import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
@@ -157,9 +161,10 @@ let client;
|
|
|
157
161
|
let listenerReminder;
|
|
158
162
|
/**
|
|
159
163
|
* DX-4390 — this server's credential: a dispatch's env bearer, this session's approved key, or
|
|
160
|
-
* none (signed out).
|
|
161
|
-
*
|
|
162
|
-
* credential itself)
|
|
164
|
+
* none (signed out). The plan event relay in this server signs in with it directly (`access.currentToken()`). The one-shot
|
|
165
|
+
* subcommands the plugin runs from hooks cannot see it, so `access.connection()` (the URL, where the credential is, its
|
|
166
|
+
* fingerprint; never the credential itself) is written to the session's connection record on a successful `plan_connect`
|
|
167
|
+
* for them.
|
|
163
168
|
*/
|
|
164
169
|
let access;
|
|
165
170
|
// DX-4434 — late-bound with `access` and `client`; see `permission-request.ts`.
|
|
@@ -167,6 +172,8 @@ let permissionRequests;
|
|
|
167
172
|
// DX-4523 - late-bound with `access` and `client`; see `tool-surface.ts` and `permission-watch.ts`.
|
|
168
173
|
let toolAccessSync;
|
|
169
174
|
let permissionWatch;
|
|
175
|
+
// DX-4233 - late-bound with `access`; see `plan-events-relay.ts`. The plugin's relay loop calls `plan_events_wait` on it.
|
|
176
|
+
let planEventRelay;
|
|
170
177
|
/**
|
|
171
178
|
* Compose the dispatch's qualified board id (`<repo>:<slug>`) from the two env
|
|
172
179
|
* halves the worker injects and build the HTTP client. DANXBOT_BOARD_NAME
|
|
@@ -180,7 +187,7 @@ function boot() {
|
|
|
180
187
|
const repo = readEnvOptional("DANX_REPO_NAME");
|
|
181
188
|
const boardName = readEnvOptional("DANXBOT_BOARD_NAME");
|
|
182
189
|
// DX-4555: a session started from the danxbot plugin's own server names no board; each call then
|
|
183
|
-
// carries a `board` argument. A board slug with no repo half is a half-written
|
|
190
|
+
// carries a `board` argument. A board slug with no repo half is a half-written DANXBOT_BOARD_NAME: fail loud.
|
|
184
191
|
if (boardName !== undefined && repo === undefined) {
|
|
185
192
|
console.error("[danx-dashboard-mcp] DANXBOT_BOARD_NAME is set but DANX_REPO_NAME is missing or empty");
|
|
186
193
|
process.exit(1);
|
|
@@ -226,13 +233,32 @@ function boot() {
|
|
|
226
233
|
// before `server.connect()`) as a session with no key: the bootstrap set plus the callback tools this dispatch's worker
|
|
227
234
|
// gave a URL for. `syncToolsAfterSignIn` widens it once the key's permissions are read. The DX-1606 tool-defs
|
|
228
235
|
// generator imports this module WITHOUT calling `boot()`, so it still sees every tool enabled.
|
|
229
|
-
const surface = new ToolSurface(registeredTools, presentCallbacksFromEnv(),
|
|
236
|
+
const surface = new ToolSurface(registeredTools, presentCallbacksFromEnv(), {
|
|
237
|
+
// A dispatch's credential is the env bearer its worker injected; a person's session holds none or its own session key.
|
|
238
|
+
operatorSession: access.connection()?.source.kind !== "env",
|
|
239
|
+
onChanged: () => server.sendToolListChanged(),
|
|
240
|
+
});
|
|
230
241
|
surface.apply(new Set());
|
|
231
242
|
toolAccessSync = new ToolAccessSync({
|
|
232
243
|
surface,
|
|
233
244
|
readPermissions: readHeldPermissions,
|
|
234
245
|
log: (line) => console.error(line),
|
|
235
246
|
});
|
|
247
|
+
// DX-4233 - the relay signs in as THIS server does, with the key it holds now, as this session.
|
|
248
|
+
planEventRelay = new PlanEventRelay({
|
|
249
|
+
auth: () => {
|
|
250
|
+
const connection = access.connection();
|
|
251
|
+
return connection === null || session === undefined
|
|
252
|
+
? null
|
|
253
|
+
: { dashboardUrl: connection.dashboardUrl, token: access.currentToken(), sessionId: session.id };
|
|
254
|
+
},
|
|
255
|
+
fetch: (input, init) => fetch(input, init),
|
|
256
|
+
sleep: realSleep,
|
|
257
|
+
now: Date.now,
|
|
258
|
+
readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
|
|
259
|
+
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
|
260
|
+
scheduleInterval: realScheduleInterval,
|
|
261
|
+
});
|
|
236
262
|
permissionWatch = new PermissionWatch({
|
|
237
263
|
dashboardUrl,
|
|
238
264
|
token: () => access.currentToken(),
|
|
@@ -304,7 +330,7 @@ async function syncToolsAfterSignIn() {
|
|
|
304
330
|
const sleepHoldingProcess = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
305
331
|
/**
|
|
306
332
|
* DX-2862 / DX-4390 — the credential this server boots with, through the ONE resolver the
|
|
307
|
-
*
|
|
333
|
+
* one-shot subcommands also use (`session-options.ts`), so a session's tools and its hook reports can never sign in as
|
|
308
334
|
* two different identities. None is not an error: the server boots signed out. A stored key that
|
|
309
335
|
* is there but unusable is reported loudly and deleted - it is a cache of a revocable key, and the
|
|
310
336
|
* session asks for a new one.
|
|
@@ -347,10 +373,10 @@ function resolveInitialCredential(dashboardUrl, sessionId) {
|
|
|
347
373
|
* calls `plan_connect` — so the title travels as that tool's own `title`
|
|
348
374
|
* argument (see `handlers.ts#planConnect`) at connect time, not as a header
|
|
349
375
|
* stamped here. DX-4461: the session's TRANSCRIPT carries every rename as a
|
|
350
|
-
* `custom-title` record, so the event
|
|
376
|
+
* `custom-title` record, so the event relay in this server reads it from
|
|
351
377
|
* there and reports it on its heartbeat (`session-title.ts`) and a later rename needs no
|
|
352
378
|
* agent call. A session that never connects with a title keeps the
|
|
353
|
-
* dashboard's own placeholder (`session <id prefix>`) until
|
|
379
|
+
* dashboard's own placeholder (`session <id prefix>`) until the relay reports
|
|
354
380
|
* one — an honest placeholder beats a guess.
|
|
355
381
|
*/
|
|
356
382
|
function readSessionConfig() {
|
|
@@ -656,7 +682,9 @@ strictTool("plan_connect",
|
|
|
656
682
|
"`nextStep`). A session is on at most one plan: connecting elsewhere moves it, and `movedFrom` names the " +
|
|
657
683
|
"plan it left. Afterwards a `danxbot_api` call to a `/api/plans/mine/...` route acts on this plan and " +
|
|
658
684
|
"takes no plan id. A reply's `naming` block (status `needed`) gives your `{Name}: PLAN-NNN {short " +
|
|
659
|
-
"title}`; rename this thread to it (`set_session_title`, or ask for `/rename`), then connect again with that `title`."
|
|
685
|
+
"title}`; rename this thread to it (`set_session_title`, or ask for `/rename`), then connect again with that `title`. " +
|
|
686
|
+
// DX-4608: a connect never fails for its title.
|
|
687
|
+
"The connect itself succeeds whatever `title` you send.", {
|
|
660
688
|
plan_id: z
|
|
661
689
|
.number()
|
|
662
690
|
.int()
|
|
@@ -667,13 +695,14 @@ strictTool("plan_connect",
|
|
|
667
695
|
.string()
|
|
668
696
|
.min(1)
|
|
669
697
|
.optional()
|
|
670
|
-
.describe(
|
|
698
|
+
.describe(
|
|
699
|
+
// DX-4608: stored as sent; a wrong one does not refuse the connect, the reply's `naming` says what to rename it to.
|
|
700
|
+
'The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
|
|
671
701
|
disconnect: z.boolean().optional().describe("true = leave `plan_id` (your current plan); no `title`."),
|
|
672
702
|
},
|
|
673
|
-
// DX-2862 — the connect is also the moment
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
// whose credential the stream must be minted with.
|
|
703
|
+
// DX-2862 — the connect is also the moment the session's connection record is written for the one-shot subcommands: of the
|
|
704
|
+
// dashboard MCP servers a session may have configured, the one that connected the plan is the one whose credential they
|
|
705
|
+
// must resolve (the relay needs none of this: it lives in each server, with that server's own credential).
|
|
677
706
|
async (args) => {
|
|
678
707
|
// DX-4390 — a leave names the plan it leaves, and needs a key like any other call.
|
|
679
708
|
if (args.disconnect === true && args.plan_id === undefined) {
|
|
@@ -703,9 +732,9 @@ async (args) => {
|
|
|
703
732
|
// DX-4523 - the key just worked: show the tools its permissions allow. After the refusals above, so a revoked or lapsed
|
|
704
733
|
// key is found by the connect itself and answered as before.
|
|
705
734
|
await syncToolsAfterSignIn();
|
|
706
|
-
// DX-3997 — a (re)connect
|
|
735
|
+
// DX-3997 — a (re)connect starts or re-keys the plugin's relay loop; the next `danxbot_api` must re-read health.
|
|
707
736
|
listenerReminder.invalidate();
|
|
708
|
-
// DX-2985 — a leave drops the local connection record (the
|
|
737
|
+
// DX-2985 — a leave drops the local connection record (the relay's stream ends `not_connected`; a later connect writes a fresh one).
|
|
709
738
|
if (args.disconnect === true) {
|
|
710
739
|
removeSessionConnectionAfterLeave(result, config.session?.id);
|
|
711
740
|
return jsonResult(result);
|
|
@@ -724,14 +753,50 @@ async (args) => {
|
|
|
724
753
|
}),
|
|
725
754
|
});
|
|
726
755
|
});
|
|
756
|
+
// ---------------- plan_events_wait (DX-4233) ----------------
|
|
757
|
+
strictTool("plan_events_wait",
|
|
758
|
+
// DX-4233 - for the danxbot plugin's relay loop, not for a model to call. It answers `events` (each `{cursor, text}`; empty after
|
|
759
|
+
// `timeout_ms`), the cursor to send being the last delivered event's, or `stopped` with the remedy to tell the session. General-audience text
|
|
760
|
+
// (DX-4550): nothing here names our own setup.
|
|
761
|
+
"Waits for events on the plan this session is connected to. For the danxbot plugin's relay, which calls it in a loop " +
|
|
762
|
+
"and hands every event to the session: a model never calls it. `plan_id` is the plan the session is on (a different " +
|
|
763
|
+
"plan than the last call's starts clean). `cursor` is the `cursor` of the last event delivered, as answered (null " +
|
|
764
|
+
"when none). Answers `{events: [{cursor, text}]}`, with `events` empty once `timeout_ms` passes, or `{stopped: " +
|
|
765
|
+
"{reason, detail, fix}}` when the stream ended: tell the session the `fix`. An event stays until a later call's " +
|
|
766
|
+
"`cursor` passes it. " +
|
|
767
|
+
"`transcript_path` is the session's transcript file, for its usage and title.", {
|
|
768
|
+
plan_id: z.number().int().positive().describe("Id of the plan this session is connected to."),
|
|
769
|
+
cursor: z
|
|
770
|
+
.string()
|
|
771
|
+
.nullable()
|
|
772
|
+
.describe("The `cursor` of the last event delivered to the session, exactly as answered, or null when there is none."),
|
|
773
|
+
timeout_ms: z
|
|
774
|
+
.number()
|
|
775
|
+
.int()
|
|
776
|
+
.min(WAIT_MIN_MS)
|
|
777
|
+
.max(WAIT_MAX_MS)
|
|
778
|
+
.describe(`How long to wait for an event, ${WAIT_MIN_MS} to ${WAIT_MAX_MS} ms.`),
|
|
779
|
+
transcript_path: z
|
|
780
|
+
.string()
|
|
781
|
+
.min(1)
|
|
782
|
+
.optional()
|
|
783
|
+
.describe("Absolute path of this session's transcript (.jsonl file)."),
|
|
784
|
+
},
|
|
785
|
+
// The relay is bound to the session the server booted for (the id every other tool on this server also sends); a server
|
|
786
|
+
// with none holds no session key, so the surface never shows this tool and the relay would answer `credential_unavailable`.
|
|
787
|
+
async (args, extra) => jsonResult(await servePlanEventsWait(planEventRelay, args, extra.signal)));
|
|
727
788
|
// ---------------- main ----------------
|
|
728
789
|
async function main() {
|
|
729
790
|
boot();
|
|
730
791
|
await syncToolsAfterSignIn();
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
792
|
+
stopWhenSessionEnds({
|
|
793
|
+
server: server.server,
|
|
794
|
+
stdin: process.stdin,
|
|
795
|
+
stop: () => {
|
|
796
|
+
permissionWatch.stop();
|
|
797
|
+
planEventRelay.close();
|
|
798
|
+
},
|
|
799
|
+
});
|
|
735
800
|
const transport = new StdioServerTransport();
|
|
736
801
|
await server.connect(transport);
|
|
737
802
|
console.error(`danx-dashboard-mcp running on stdio (dashboard=${config.baseUrl}, board=${config.board ?? "none, each call names its own"})`);
|
|
@@ -743,26 +808,17 @@ async function main() {
|
|
|
743
808
|
// symlink-aware (DX-1647) so it holds under the symlinked `npx` bin the worker
|
|
744
809
|
// spawns, not just a direct `node dist/index.js`.
|
|
745
810
|
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
// `
|
|
749
|
-
//
|
|
750
|
-
// session
|
|
751
|
-
// (`session-connection.ts`), never from the bridge's own environment
|
|
752
|
-
// (DX-2862: a bridge trusting its own ambient env credential is the
|
|
753
|
-
// exact bug this repo fixed).
|
|
811
|
+
// The subcommands are one-shot clients (activity, background-work, event-text, ready-cards, restart-notice, subagents-live)
|
|
812
|
+
// the danxbot plugin runs. None boots the MCP server; the dashboard URL, credential source and credential come from the
|
|
813
|
+
// session-connection record this server's own `plan_connect` wrote (`session-connection.ts`), never from the subcommand's own
|
|
814
|
+
// environment (DX-2862: trusting an ambient env credential is the exact bug this repo fixed). There is no event-stream
|
|
815
|
+
// subcommand: a session's plan events are served in this process by `plan_events_wait` (DX-4233).
|
|
754
816
|
//
|
|
755
817
|
// Any OTHER argument is refused rather than ignored, so a typo cannot
|
|
756
818
|
// silently start an MCP server on stdio where a subcommand was meant to run.
|
|
757
819
|
if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
758
820
|
const [subcommand, ...rest] = process.argv.slice(2);
|
|
759
|
-
if (subcommand ===
|
|
760
|
-
runBridgeCommand(rest).then((code) => process.exit(code), (err) => {
|
|
761
|
-
console.error(`[danx-dashboard-mcp] bridge fatal: ${err.message}`);
|
|
762
|
-
process.exit(1);
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
else if (subcommand === BACKGROUND_WORK_SUBCOMMAND) {
|
|
821
|
+
if (subcommand === BACKGROUND_WORK_SUBCOMMAND) {
|
|
766
822
|
runBackgroundWorkCommand(rest).then((code) => process.exit(code), (err) => {
|
|
767
823
|
console.error(`[danx-dashboard-mcp] background-work fatal: ${err.message}`);
|
|
768
824
|
process.stdout.write(`${JSON.stringify({ ok: false, reason: "fatal" })}\n`);
|
|
@@ -813,7 +869,7 @@ if (isEntrypointModule(import.meta.url, process.argv[1])) {
|
|
|
813
869
|
}
|
|
814
870
|
else if (subcommand !== undefined) {
|
|
815
871
|
console.error(`[danx-dashboard-mcp] unknown subcommand "${subcommand}" (the only ones are ` +
|
|
816
|
-
`"${
|
|
872
|
+
`"${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", "${EVENT_TEXT_SUBCOMMAND}", ` +
|
|
817
873
|
`"${RESTART_NOTICE_SUBCOMMAND}", "${READY_CARDS_SUBCOMMAND}", and "${SUBAGENTS_LIVE_SUBCOMMAND}")`);
|
|
818
874
|
process.exit(2);
|
|
819
875
|
}
|
package/dist/json-body.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DX-2920 — the ONE place that reads a JSON body's top-level `.error` string.
|
|
3
3
|
*
|
|
4
|
-
* WHY THIS EXISTS. `
|
|
4
|
+
* WHY THIS EXISTS. the stream client (`session-stream.ts`, classifying a mint refusal) and `listen.ts`
|
|
5
5
|
* (classifying a stream admission refusal) both need to answer "what error
|
|
6
6
|
* code did this JSON body carry", and before this card each answered it a
|
|
7
|
-
* different way:
|
|
7
|
+
* different way: the mint code parsed the body directly, `listen.ts` never
|
|
8
8
|
* looked past a flattened, human-readable detail string at all — a caller
|
|
9
9
|
* that wanted to react to a SPECIFIC refusal (the admission-time 409 the
|
|
10
10
|
* mint route also uses) had to regex-scrape that flattened string back apart,
|
|
11
11
|
* a second, fragile source of truth for the same fact. This module is generic
|
|
12
12
|
* — it knows nothing about "session_not_connected" or "stream_ticket_invalid",
|
|
13
13
|
* only that a JSON object may carry a top-level string field named `error` —
|
|
14
|
-
* so it can live in BOTH the domain-aware `
|
|
14
|
+
* so it can live in BOTH the domain-aware `session-stream.ts` and the deliberately
|
|
15
15
|
* generic `listen.ts` (whose own docblock states it carries no domain
|
|
16
16
|
* knowledge of the dashboard's specific error shapes) without either one
|
|
17
17
|
* importing the other.
|
package/dist/listen.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* The session event STREAM READER — holds ONE listener ticket's connection to the
|
|
3
3
|
* dashboard's session event stream and emits one record per event.
|
|
4
4
|
*
|
|
5
|
-
* WHO USES IT. Only
|
|
6
|
-
* ticket, runs this reader
|
|
7
|
-
* The ticket therefore never leaves the
|
|
5
|
+
* WHO USES IT. Only the in-process event relay (`plan-events-relay.ts`), which mints the
|
|
6
|
+
* ticket, runs this reader inside the MCP server, and re-mints when a ticket's lease runs out.
|
|
7
|
+
* The ticket therefore never leaves the server's memory: no command line, no
|
|
8
8
|
* environment variable.
|
|
9
9
|
*
|
|
10
10
|
* THE OUTPUT CONTRACT (`ListenOutput`), because every event record reaches an agent:
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* `409 session_not_connected`, the same shape the mint route uses) by
|
|
27
27
|
* status + error code rather than re-deriving it from the flattened
|
|
28
28
|
* `detail` string. A wire `reason` value this build does not recognize (a
|
|
29
|
-
* newer dashboard's reason an older
|
|
29
|
+
* newer dashboard's reason an older client hasn't shipped yet)
|
|
30
30
|
* normalizes to `"unknown"` rather than passing the raw string through,
|
|
31
31
|
* so the closed set stays closed — `fixForStop`'s generic
|
|
32
32
|
* `DEFAULT_STOP_FIX` handles it exactly as it already handled a verbatim
|
|
@@ -62,7 +62,7 @@ export const DELIVERED_ID_MEMORY = 1_000;
|
|
|
62
62
|
/**
|
|
63
63
|
* DX-2829 — the ONE place that caps a resume-id list to the last
|
|
64
64
|
* `DELIVERED_ID_MEMORY` entries. Both `runListener` (seeding its own
|
|
65
|
-
* duplicate-detection `Set` below) and `
|
|
65
|
+
* duplicate-detection `Set` below) and the relay (`plan-events-relay.ts`) (seeding its
|
|
66
66
|
* own delivered-id array) used to each carry their own `slice(-DELIVERED_ID_MEMORY)`
|
|
67
67
|
* — the same cap, on the same kind of input, computed twice.
|
|
68
68
|
*/
|
|
@@ -457,14 +457,14 @@ export async function runListener(options, deps) {
|
|
|
457
457
|
/**
|
|
458
458
|
* The shared path of an ADVISORY frame (DX-2885 `nudge`, DX-4340 `pacing`): it carries no usable id (re-derived or written fresh by the
|
|
459
459
|
* dashboard's keep-alive tick, never replayed - see `plan-session-stream.ts`'s module doc), so it rides the SAME `{type:"event", id:null, text}`
|
|
460
|
-
* shape an ordinary activity line uses. That is what lets
|
|
461
|
-
*
|
|
460
|
+
* shape an ordinary activity line uses. That is what lets the relay (`plan-events-relay.ts`) forward it with no change: it forwards any
|
|
461
|
+
* `{type:"event", id, text}` record, keyed only on `type`.
|
|
462
462
|
*
|
|
463
463
|
* `origin: null` is DX-2730 D4's exemption: the frame is delivered at once, never held for a digest flush (a nudge exists to interrupt a quiet
|
|
464
464
|
* session; a pacing change is an instruction to act now). A frame that is not JSON or not the right shape is reported from an explicit check
|
|
465
465
|
* naming the fault, and the next frame still emits.
|
|
466
466
|
*/
|
|
467
|
-
const emitAdvisory = (message, what, invalidReason, format) => {
|
|
467
|
+
const emitAdvisory = (message, what, invalidReason, format, scope) => {
|
|
468
468
|
let parsed;
|
|
469
469
|
let invalid;
|
|
470
470
|
try {
|
|
@@ -475,7 +475,7 @@ export async function runListener(options, deps) {
|
|
|
475
475
|
invalid = `not JSON: ${err.message}`;
|
|
476
476
|
}
|
|
477
477
|
const text = invalid === null ? format(parsed) : `${LINE_PREFIX} could not read ${what} (${invalid}): ${oneLine(message.data, 300)}`;
|
|
478
|
-
deps.write({ type: "event", id: null, text, origin: null });
|
|
478
|
+
deps.write({ type: "event", id: null, text, origin: null, ...(scope === undefined ? {} : { scope }) });
|
|
479
479
|
return null;
|
|
480
480
|
};
|
|
481
481
|
const handle = (message) => {
|
|
@@ -492,7 +492,7 @@ export async function runListener(options, deps) {
|
|
|
492
492
|
const reasonRaw = isRecord(parsed) ? parsed.reason : undefined;
|
|
493
493
|
const reason = typeof reasonRaw === "string" ? reasonRaw : "unknown";
|
|
494
494
|
// DX-2920 (round 4) — a wire reason this build does not recognize (a
|
|
495
|
-
// newer dashboard's reason an older
|
|
495
|
+
// newer dashboard's reason an older client hasn't shipped yet)
|
|
496
496
|
// normalizes to "unknown" rather than passing the raw string through:
|
|
497
497
|
// `Outcome`'s "ended" reason is a closed set (`EndedReason`).
|
|
498
498
|
return { kind: "ended", reason: isKnownWireEndReason(reason) ? reason : "unknown" };
|
|
@@ -501,10 +501,10 @@ export async function runListener(options, deps) {
|
|
|
501
501
|
return emitAdvisory(message, "nudge", invalidNudgeReason, (p) => formatNudgeLine(p));
|
|
502
502
|
// DX-4340 - the pacing tick's one message for this session's Claude account (level or budget changed): advisory like a nudge.
|
|
503
503
|
if (message.event === "pacing")
|
|
504
|
-
return emitAdvisory(message, "pacing message", invalidPacingReason, (p) => formatPacingLine(p));
|
|
504
|
+
return emitAdvisory(message, "pacing message", invalidPacingReason, (p) => formatPacingLine(p), "session");
|
|
505
505
|
// DX-4342 - the auto-resume's message once the limit that paused or stopped this session has reset: advisory like a pacing message.
|
|
506
506
|
if (message.event === "resume")
|
|
507
|
-
return emitAdvisory(message, "resume message", invalidResumeReason, (p) => formatResumeLine(p));
|
|
507
|
+
return emitAdvisory(message, "resume message", invalidResumeReason, (p) => formatResumeLine(p), "session");
|
|
508
508
|
if (message.event !== "activity")
|
|
509
509
|
return null;
|
|
510
510
|
// `Number(null)` is 0, so a frame with no id must never be read as event 0: that id
|
|
@@ -550,6 +550,7 @@ export async function runListener(options, deps) {
|
|
|
550
550
|
// attempt (including before the fetch even starts) targets exactly this
|
|
551
551
|
// controller, never a stale one from a previous attempt.
|
|
552
552
|
deps.onConnectionAttempt?.(() => controller.abort());
|
|
553
|
+
deps.signal?.addEventListener("abort", () => controller.abort(), { once: true });
|
|
553
554
|
let idle;
|
|
554
555
|
const armIdle = () => {
|
|
555
556
|
clearTimeout(idle);
|
|
@@ -570,7 +571,7 @@ export async function runListener(options, deps) {
|
|
|
570
571
|
if (response.status >= 400 && response.status < 500 && !RETRYABLE_CLIENT_STATUSES.has(response.status)) {
|
|
571
572
|
const text = await response.text();
|
|
572
573
|
// DX-2920 — parsed ONCE, here, into a structured outcome a consumer can
|
|
573
|
-
// classify by status + error code (
|
|
574
|
+
// classify by status + error code (the relay does, matching a specific
|
|
574
575
|
// admission refusal such as the dashboard's own `409 session_not_connected`)
|
|
575
576
|
// rather than re-deriving it from the flattened `detail` string below.
|
|
576
577
|
let body = null;
|
|
@@ -624,7 +625,12 @@ export async function runListener(options, deps) {
|
|
|
624
625
|
}
|
|
625
626
|
};
|
|
626
627
|
for (;;) {
|
|
628
|
+
if (deps.signal?.aborted)
|
|
629
|
+
return 0;
|
|
627
630
|
const outcome = await connectOnce();
|
|
631
|
+
// DX-4233 - cancelled while connected: the outcome of an attempt the owner abandoned is not the owner's to hear.
|
|
632
|
+
if (deps.signal?.aborted)
|
|
633
|
+
return 0;
|
|
628
634
|
if (outcome.kind === "ended") {
|
|
629
635
|
if (outcome.reason === "superseded" || outcome.reason === "replaced") {
|
|
630
636
|
return stop(outcome.reason, "another listener for this session took over", 0);
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
* alone rather than colliding with "the JSON didn't parse"
|
|
19
19
|
* (`bad_response`).
|
|
20
20
|
* - `resolveSessionOptions` — session-id + credential resolution via
|
|
21
|
-
* `
|
|
22
|
-
* `instanceof` rather than an unchecked `as
|
|
21
|
+
* `resolveDashboardAuth`, with `SessionStartError` narrowed by
|
|
22
|
+
* `instanceof` rather than an unchecked `as SessionStartError` cast, so
|
|
23
23
|
* an error of some OTHER shape (a bug in the resolver, a thrown
|
|
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 { SESSION_ID_HEADER,
|
|
27
|
+
import { SESSION_ID_HEADER, resolveDashboardAuth, SessionStartError } from "./session-options.js";
|
|
28
28
|
/**
|
|
29
29
|
* One authenticated HTTP call to this session's dashboard, with a hard
|
|
30
30
|
* timeout. Never throws — a network failure or an abort is reported as
|
|
@@ -84,15 +84,15 @@ export async function resolveSessionOptions(env, resolveFrom, usage) {
|
|
|
84
84
|
return { ok: false, output: { ok: false, reason: "no_session_id" }, detail: usage };
|
|
85
85
|
}
|
|
86
86
|
try {
|
|
87
|
-
const options =
|
|
87
|
+
const options = resolveDashboardAuth(sessionId, env, resolveFrom);
|
|
88
88
|
return { ok: true, options };
|
|
89
89
|
}
|
|
90
90
|
catch (err) {
|
|
91
|
-
// DX-3367 fix-up — `instanceof`, never an unchecked `as
|
|
92
|
-
// cast: `
|
|
91
|
+
// DX-3367 fix-up — `instanceof`, never an unchecked `as SessionStartError`
|
|
92
|
+
// cast: `resolveDashboardAuth` throws exactly `SessionStartError` today,
|
|
93
93
|
// but a cast reports a fabricated reason/message/fix (all `undefined`)
|
|
94
94
|
// for any OTHER thrown shape instead of a real one, silently.
|
|
95
|
-
if (err instanceof
|
|
95
|
+
if (err instanceof SessionStartError) {
|
|
96
96
|
return {
|
|
97
97
|
ok: false,
|
|
98
98
|
output: { ok: false, reason: err.reason },
|