@thehammer/danx-dashboard-mcp 0.1.273 → 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/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 bridge
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)
@@ -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 { BRIDGE_SUBCOMMAND, runBridgeCommand } from "./bridge.js";
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). It also tells the session's plan event bridge how to reach the dashboard AS
161
- * THIS SERVER (`access.connection()`: the URL, where the credential is, its fingerprint; never the
162
- * credential itself), written to the session's connection record on a successful `plan_connect`.
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
@@ -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(), () => server.sendToolListChanged());
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
- * `bridge` subcommand also uses, so a session's tools and its event stream can never sign in as
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 bridge (not this server) reads it from
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 its bridge reports
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('The `naming` block\'s exact `{Name}: PLAN-NNN {short title}`, after renaming this thread. Omit on the first connect.'),
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 this session's event bridge learns
674
- // WHICH dashboard credential owns its stream: of the dashboard MCP servers a
675
- // session may have configured, the one that connected the plan is the one
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 restarts the bridge; the next `danxbot_api` must re-read health.
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 bridge exits on `not_connected`; a later connect writes a fresh one).
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
- // DX-4523 - the open permission stream would keep the process alive after the client goes away. The SDK's stdio transport
732
- // does not watch stdin for its end (only a transport `close()` reaches `onclose`), so the end of stdin stops the watch too.
733
- server.server.onclose = () => permissionWatch.stop();
734
- process.stdin.once("end", () => permissionWatch.stop());
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
- // `bridge` is the session event stream client the danxbot plugin's plan
747
- // event bridge runs. It never boots the MCP server; its only input is
748
- // `CLAUDE_CODE_SESSION_ID` (read from its environment) plus `--resume-ids` —
749
- // the dashboard URL, credential source, and credential itself come from the
750
- // session-connection record this server's own `plan_connect` wrote
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 === BRIDGE_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
- `"${BRIDGE_SUBCOMMAND}", "${BACKGROUND_WORK_SUBCOMMAND}", "${ACTIVITY_SUBCOMMAND}", "${EVENT_TEXT_SUBCOMMAND}", ` +
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. `bridge.ts` (classifying a mint refusal) and `listen.ts`
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: `bridge.ts` parsed the body directly, `listen.ts` never
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 `bridge.ts` and the deliberately
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 `bridge.ts` (`danx-dashboard-mcp bridge`), which mints the
6
- * ticket, runs this reader in-process, and re-mints when a ticket's lease runs out.
7
- * The ticket therefore never leaves the bridge process: no command line, no
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 bridge hasn't shipped yet)
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 `bridge.ts`'s `runBridge` (seeding its
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 `bridge.ts` and the plugin's relay (`danxbot/scripts/plan-event-bridge.mjs`, outside
461
- * this repo) forward it with NO changes: both forward any `{type:"event", id, text}` line keyed only on `type`.
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 bridge hasn't shipped yet)
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 (`bridge.ts` does, matching a specific
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
- * `resolveBridgeOptions`, with `BridgeStartError` narrowed by
22
- * `instanceof` rather than an unchecked `as BridgeStartError` cast, so
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, resolveBridgeOptions, BridgeStartError } from "./bridge.js";
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 = resolveBridgeOptions({ sessionId, resumeIds: [] }, env, resolveFrom);
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 BridgeStartError`
92
- // cast: `resolveBridgeOptions` throws exactly `BridgeStartError` today,
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 BridgeStartError) {
95
+ if (err instanceof SessionStartError) {
96
96
  return {
97
97
  ok: false,
98
98
  output: { ok: false, reason: err.reason },