@thehammer/danx-dashboard-mcp 0.1.93 → 0.1.95

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/bridge.js CHANGED
@@ -89,11 +89,12 @@
89
89
  * is the only argument and holds no secret; the credential is whatever this
90
90
  * session's own MCP server declared, resolved here from that declaration.
91
91
  */
92
+ import { randomUUID } from "node:crypto";
92
93
  import { homedir } from "node:os";
93
94
  import { errorCodeOf } from "./json-body.js";
94
95
  import { oneLine } from "./one-line.js";
95
96
  import { credentialFingerprint, describeCredentialSource, readCredential, } from "./credential.js";
96
- import { readSessionConnection } from "./session-connection.js";
97
+ import { readSessionConnection, sessionConnectionPath } from "./session-connection.js";
97
98
  import { capResumeIds, DELIVERED_ID_MEMORY, HEALTHY_CONNECTION_MS, READ_IDLE_TIMEOUT_MS, runListener, } from "./listen.js";
98
99
  /**
99
100
  * DX-2730 D4 — WHICH EVENTS WAKE THE SESSION, NOW THAT EVERY ONE OF THEM CAN.
@@ -169,12 +170,20 @@ export const STREAM_TICKET_PATH = "/api/plan-sessions/me/stream-ticket";
169
170
  // streaming, and is best-effort (one attempt, no retry/backoff).
170
171
  export const PLAN_MINE_PATH = "/api/plans/mine";
171
172
  export const SESSION_ID_HEADER = "x-danx-session-id";
173
+ // DX-3028 (AC1) — `POST /api/plan-sessions/me/heartbeat` (DX-2952's server
174
+ // route, `plan-sessions-routes.ts#handleHeartbeat`). Every 15s this process
175
+ // reports its truthful streaming state; see `sendHeartbeat` below.
176
+ export const HEARTBEAT_PATH = "/api/plan-sessions/me/heartbeat";
172
177
  /** How long one dashboard request may take before it counts as a transient failure. */
173
178
  export const REQUEST_TIMEOUT_MS = 10_000;
174
179
  export const MINT_INITIAL_BACKOFF_MS = 1_000;
175
180
  export const MINT_MAX_BACKOFF_MS = 60_000;
176
181
  /** A freshly minted ticket refused this many times in a row is a terminal outcome, not a loop. */
177
182
  export const MAX_REFUSED_TICKETS_IN_A_ROW = 2;
183
+ /** DX-3028 (AC1) — the dashboard's own heartbeat cadence; see the card's AC1. */
184
+ export const HEARTBEAT_INTERVAL_MS = 15_000;
185
+ /** DX-3028 (AC2) — the server-fixable degraded reasons' own periodic retry cadence. */
186
+ export const DEGRADED_RETRY_INTERVAL_MS = 5 * 60_000;
178
187
  const TRANSIENT_CLIENT_STATUSES = new Set([408, 429]);
179
188
  const USAGE = `usage: CLAUDE_CODE_SESSION_ID=<session-id> danx-dashboard-mcp ${BRIDGE_SUBCOMMAND} ` +
180
189
  `[--resume-ids <event-id>[,<event-id>...]]`;
@@ -219,6 +228,9 @@ export const STOP_FIXES = {
219
228
  replaced: "no action needed — another connection is already using this session's ticket; call plan_connect again only if that was unexpected",
220
229
  revoked: "call plan_connect again to mint a new listener ticket for this session",
221
230
  refused: "call plan_connect again to mint a new listener ticket for this session",
231
+ // DX-3028 — this session id belongs to a worker dispatch, not an operator
232
+ // plan session; there is no listener ticket a worker session can ever use.
233
+ session_is_worker: "this session id belongs to a worker dispatch; start a new session",
222
234
  };
223
235
  export const DEFAULT_STOP_FIX = "call plan_connect again in this session to restart the bridge";
224
236
  export function fixForStop(reason) {
@@ -250,11 +262,17 @@ export class BridgeStartError extends Error {
250
262
  // reason: start.reason, ...})` needs it to be.
251
263
  reason;
252
264
  fix;
253
- constructor(reason, detail, fix) {
265
+ /** DX-3028 — see `BridgeOptions.instanceId`. Always populated: minted before any of the three start-time checks run. */
266
+ instanceId;
267
+ /** DX-3028 — see `BridgeOptions.watchedPaths`. Empty for `no_connection_record` (there is nothing to watch yet). */
268
+ paths;
269
+ constructor(reason, detail, fix, instanceId, paths) {
254
270
  super(detail);
255
271
  this.name = "BridgeStartError";
256
272
  this.reason = reason;
257
273
  this.fix = fix;
274
+ this.instanceId = instanceId;
275
+ this.paths = paths;
258
276
  }
259
277
  }
260
278
  /**
@@ -262,18 +280,48 @@ export class BridgeStartError extends Error {
262
280
  * that this process holds the SAME credential that server does. Never falls back
263
281
  * to an ambient `DANXBOT_DISPATCH_TOKEN`: that fallback is precisely DX-2862.
264
282
  */
283
+ const INSTANCE_ID_ENV = "DANX_BRIDGE_INSTANCE_ID";
284
+ /**
285
+ * DX-3028 — this process's own instance id: read from `DANX_BRIDGE_INSTANCE_ID`
286
+ * (minted by the plugin's `start()` before it spawns the run process, and
287
+ * carried down to this subcommand's env unchanged), or freshly minted for a
288
+ * manual/hand run that has no plugin supplying one. Every stop record and
289
+ * every heartbeat names this same id for the life of the process.
290
+ */
291
+ function resolveInstanceId(env) {
292
+ const declared = env[INSTANCE_ID_ENV];
293
+ return typeof declared === "string" && declared.trim() !== "" ? declared.trim() : randomUUID();
294
+ }
295
+ /**
296
+ * DX-3028 — the on-disk paths this bridge's authentication depends on, for
297
+ * `BridgeOptions.watchedPaths` (see its docblock). A `file`-sourced
298
+ * credential adds its own path; an `env`-sourced one has no file
299
+ * (`credential.ts` ~47-48, ~90-95).
300
+ */
301
+ function watchedPathsFor(record, home) {
302
+ const paths = [sessionConnectionPath(record.sessionId, home)];
303
+ if (record.source.kind === "file")
304
+ paths.push(record.source.path);
305
+ return paths;
306
+ }
265
307
  export function resolveBridgeOptions(args, env, options = {}) {
266
308
  const home = options.home ?? homedir();
309
+ const instanceId = resolveInstanceId(env);
267
310
  let record;
268
311
  try {
269
312
  record = readSessionConnection(args.sessionId, { home });
270
313
  }
271
314
  catch (err) {
272
- throw new BridgeStartError("no_connection_record", err.message, fixForStop("no_connection_record"));
315
+ throw new BridgeStartError("no_connection_record", err.message, fixForStop("no_connection_record"), instanceId, []);
273
316
  }
274
317
  if (record === null) {
275
- throw new BridgeStartError("no_connection_record", `session ${args.sessionId} has no dashboard connection record — no danx-dashboard MCP server has connected it to a plan`, fixForStop("no_connection_record"));
318
+ throw new BridgeStartError("no_connection_record", `session ${args.sessionId} has no dashboard connection record — no danx-dashboard MCP server has connected it to a plan`, fixForStop("no_connection_record"), instanceId, []);
276
319
  }
320
+ // DX-3028 — computed as soon as a connection record exists, even if the
321
+ // credential itself later fails to resolve: `credential_unavailable`'s own
322
+ // stop record still needs `paths` (the connection record path, at least)
323
+ // so the plugin has something to watch for a retry.
324
+ const watchedPaths = watchedPathsFor(record, home);
277
325
  let token;
278
326
  try {
279
327
  token = readCredential(record.source, env);
@@ -282,18 +330,20 @@ export function resolveBridgeOptions(args, env, options = {}) {
282
330
  const credentialError = err;
283
331
  throw new BridgeStartError("credential_unavailable", `the credential this session's danx-dashboard MCP server declared cannot be resolved here: ${credentialError.message}`,
284
332
  // CredentialError.fix is a required string, never absent — no fallback needed.
285
- credentialError.fix);
333
+ credentialError.fix, instanceId, watchedPaths);
286
334
  }
287
335
  const fingerprint = credentialFingerprint(token);
288
336
  if (fingerprint !== record.fingerprint) {
289
337
  throw new BridgeStartError("credential_mismatch", `${describeCredentialSource(record.source)} now yields credential ${fingerprint}, but this session's ` +
290
- `danx-dashboard MCP server is using ${record.fingerprint} — the bridge would authenticate as somebody else`, fixForStop("credential_mismatch"));
338
+ `danx-dashboard MCP server is using ${record.fingerprint} — the bridge would authenticate as somebody else`, fixForStop("credential_mismatch"), instanceId, watchedPaths);
291
339
  }
292
340
  return {
293
341
  dashboardUrl: record.dashboardUrl.replace(/\/+$/, ""),
294
342
  token,
295
343
  sessionId: args.sessionId,
296
344
  resumeIds: args.resumeIds,
345
+ instanceId,
346
+ watchedPaths,
297
347
  };
298
348
  }
299
349
  /**
@@ -408,6 +458,13 @@ export async function mintTicket(options, deps) {
408
458
  if (status === 409 && errorCodeOf(body) === "session_not_connected") {
409
459
  return { kind: "terminal", reason: "not_connected", detail: "the session is not connected to a plan" };
410
460
  }
461
+ // DX-3028 (AC2) — matched BEFORE the generic mint_refused fallback below,
462
+ // which every other 409 falls into: this session id belongs to a worker
463
+ // dispatch (`SessionIsWorkerError`, the same shape the heartbeat route
464
+ // already gives it, DX-2952).
465
+ if (status === 409 && errorCodeOf(body) === "session_is_worker") {
466
+ return { kind: "terminal", reason: "session_is_worker", detail: "this session id belongs to a worker dispatch, not an operator plan session" };
467
+ }
411
468
  // DX-2920 (AC 30660) — this credential IS accepted; it just cannot read every
412
469
  // board the connected plan covers (`unreadableBoardsRefusal`, dashboard
413
470
  // `plan-scope.ts`). Calling that `unauthorized` told the session to fix its
@@ -511,36 +568,133 @@ function classifyAdmissionRefusal(refusal) {
511
568
  }
512
569
  return null;
513
570
  }
571
+ const EXIT_NEVER_RESTART = new Set([
572
+ "no_connection_record",
573
+ "not_connected",
574
+ "superseded",
575
+ "replaced",
576
+ "session_is_worker",
577
+ ]);
578
+ const DEGRADE_NO_HEARTBEAT = new Set(["credential_unavailable", "credential_mismatch"]);
579
+ export function degradePolicyFor(reason) {
580
+ if (EXIT_NEVER_RESTART.has(reason))
581
+ return "exit";
582
+ if (DEGRADE_NO_HEARTBEAT.has(reason))
583
+ return "degrade-no-heartbeat";
584
+ return "degrade-server-fixable";
585
+ }
586
+ /**
587
+ * One heartbeat POST. NEVER throws and NEVER stops the bridge on failure
588
+ * (card AC1 — "a failed heartbeat never stops the bridge"): `request()`
589
+ * itself already classifies every network/timeout/5xx failure as
590
+ * `{kind:"transient"}` rather than throwing, and a 401/403 (a revoked
591
+ * credential) simply comes back as an ordinary non-2xx answer this function
592
+ * discards — there is nothing meaningful to retry a heartbeat POST for, and
593
+ * the NEXT tick will just try again. A heartbeat refused with 401/403 cannot
594
+ * report itself, so `computeListenerHealth`'s staleness fallback
595
+ * (`heartbeatIsFresh`) is what eventually reflects it: the row's
596
+ * `last_heartbeat_at` stops advancing, and once it falls outside
597
+ * `SESSION_LIVENESS_WINDOW_MS` the badge reads `unattached`, NOT
598
+ * `credential_mismatch` — `credential_mismatch` is computed only from a
599
+ * heartbeat that DID land with `state:"streaming"` and a live ticket whose
600
+ * issuer hash differs from the caller's; it has no way to represent "the
601
+ * heartbeat call itself was rejected". This gap is real and is recorded on
602
+ * the card rather than silently papered over.
603
+ */
604
+ async function sendHeartbeat(options, deps, connection) {
605
+ const body = { state: connection.state };
606
+ if (connection.reason !== undefined)
607
+ body.reason = connection.reason;
608
+ if (connection.inventory !== undefined)
609
+ body.inventory = connection.inventory;
610
+ await request(options, deps, "heartbeat", `${options.dashboardUrl}${HEARTBEAT_PATH}`, {
611
+ method: "POST",
612
+ body: JSON.stringify({ connection: body }),
613
+ });
614
+ // Deliberately no error handling beyond this: `request()` never throws, and
615
+ // this function does not even inspect the answer — a heartbeat is
616
+ // fire-and-forget by design (see the docblock above).
617
+ }
514
618
  /**
515
619
  * Mint, stream, re-mint — until a terminal outcome, which it writes as the one
516
620
  * `stopped` record. Returns 0 when another listener took over, 1 otherwise.
517
621
  */
518
622
  export async function runBridge(options, deps) {
519
623
  const delivered = capResumeIds(options.resumeIds);
520
- function stop(reason, detail, code, extra) {
521
- // DX-2730 D4 — every terminal return goes through here, so this is the ONE
522
- // place that cancels a pending digest timer. CANCELLED, never flushed: for
624
+ // DX-3028 (AC1) — this process's own truthful state, reported every
625
+ // HEARTBEAT_INTERVAL_MS regardless of what the mint/stream loop is doing.
626
+ // `onState` (threaded into `runListener` below) is the ONLY thing that ever
627
+ // sets "streaming"; everything else (mint backoff, degraded idling) is
628
+ // "reconnecting" or "stopped". `heartbeatSuppressed` covers the two
629
+ // start-time-unauthenticatable degrade reasons (card AC2): the process has
630
+ // no credential it can report WITH, so it sends nothing at all rather than
631
+ // an unauthenticated POST that would just be refused.
632
+ let connectionState = "reconnecting";
633
+ let stoppedReason = null;
634
+ let heartbeatSuppressed = false;
635
+ let lastInventoryOk = true;
636
+ const heartbeatTick = () => {
637
+ if (heartbeatSuppressed)
638
+ return;
639
+ void sendHeartbeat(options, deps, connectionState === "streaming"
640
+ ? { state: "streaming", inventory: lastInventoryOk ? "ok" : "unavailable" }
641
+ : connectionState === "stopped"
642
+ ? { state: "stopped", reason: stoppedReason ?? undefined }
643
+ : { state: "reconnecting" });
644
+ };
645
+ const heartbeat = deps.scheduleInterval(heartbeatTick, HEARTBEAT_INTERVAL_MS);
646
+ async function settle(reason, detail, code, extra) {
647
+ // DX-2730 D4 — every settle goes through here, so this is the ONE place
648
+ // that cancels a pending digest timer. CANCELLED, never flushed: for
523
649
  // `superseded`/`replaced` a newer process is already streaming this same
524
650
  // session and will receive (and hold, and eventually flush) these same
525
651
  // held events itself via the dashboard's replay — flushing here too would
526
- // double-deliver the digest. For every other terminal reason, whatever is
527
- // still held was never marked delivered (see `emitNow`), so a future
528
- // process picks it up the same way — a live, un-fired `setTimeout` would
529
- // otherwise also keep this process's event loop alive for up to
530
- // `DIGEST_INTERVAL_MS` after it should have exited.
652
+ // double-deliver the digest. For every other reason, whatever is still
653
+ // held was never marked delivered (see `emitNow`), so a future process
654
+ // (or this same one, after a degrade retry) picks it up the same way — a
655
+ // live, un-fired `setTimeout` would otherwise also keep this process's
656
+ // event loop alive on top of the heartbeat interval, for no reason.
531
657
  if (digestTimer !== null) {
532
658
  clearTimeout(digestTimer);
533
659
  digestTimer = null;
534
660
  }
661
+ const policy = degradePolicyFor(reason);
662
+ const degraded = policy !== "exit";
663
+ const fix = reason === "scope_narrowed" ? scopeNarrowedFix(extra?.boards ?? []) : (extra?.fix ?? fixForStop(reason));
535
664
  if (reason === "scope_narrowed") {
536
665
  if (extra?.boards === undefined) {
537
- throw new Error('stop("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
666
+ throw new Error('settle("scope_narrowed", ...) requires boards — unreachable through the exported overloads above');
538
667
  }
539
- deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix: scopeNarrowedFix(extra.boards) });
540
- return code;
668
+ deps.write({ type: "stopped", reason, detail, boards: extra.boards, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
669
+ }
670
+ else {
671
+ deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix, instanceId: options.instanceId, paths: options.watchedPaths, degraded });
541
672
  }
542
- deps.write({ type: "stopped", reason, detail, refusal: extra?.refusal, fix: extra?.fix ?? fixForStop(reason) });
543
- return code;
673
+ if (policy === "exit") {
674
+ heartbeat.stop();
675
+ return { kind: "exit", code };
676
+ }
677
+ stoppedReason = reason;
678
+ connectionState = "stopped";
679
+ if (policy === "degrade-no-heartbeat") {
680
+ // DX-3028 (AC2) — "Three of these cannot report anything": this process
681
+ // has no credential it can authenticate a heartbeat WITH, so it sends
682
+ // none (`heartbeatSuppressed`) and has no basis to retry the mint
683
+ // itself either — it idles until the plugin, watching `watchedPaths`
684
+ // for a change, kills and restarts it fresh (a new process re-reads
685
+ // the credential from scratch). Never resolves on its own.
686
+ heartbeatSuppressed = true;
687
+ await new Promise(() => {
688
+ /* deliberately never settles — see the comment above */
689
+ });
690
+ }
691
+ // degrade-server-fixable: keep heartbeating `stopped`, and retry the
692
+ // mint/stream on our own after DEGRADED_RETRY_INTERVAL_MS, on top of the
693
+ // plugin's own watched-path restart (harmless overlap — whichever fires
694
+ // first wins; a restarted process just re-enters this same loop clean).
695
+ heartbeatSuppressed = false;
696
+ await deps.sleep(DEGRADED_RETRY_INTERVAL_MS);
697
+ return { kind: "retry" };
544
698
  }
545
699
  let backoff = MINT_INITIAL_BACKOFF_MS;
546
700
  let refusedInRow = 0;
@@ -617,9 +771,15 @@ export async function runBridge(options, deps) {
617
771
  emitNow(output);
618
772
  }
619
773
  for (;;) {
774
+ connectionState = "reconnecting";
620
775
  const minted = await mintTicket(options, deps);
621
- if (minted.kind === "terminal")
622
- return stop(minted.reason, minted.detail, 1);
776
+ if (minted.kind === "terminal") {
777
+ const outcome = await settle(minted.reason, minted.detail, 1);
778
+ if (outcome.kind === "exit")
779
+ return outcome.code;
780
+ refusedInRow = 0;
781
+ continue;
782
+ }
623
783
  if (minted.kind === "transient") {
624
784
  await deps.sleep(backoff);
625
785
  backoff = Math.min(MINT_MAX_BACKOFF_MS, backoff * 2);
@@ -684,6 +844,13 @@ export async function runBridge(options, deps) {
684
844
  run.emitted = true;
685
845
  routeEvent(output);
686
846
  },
847
+ // DX-3028 (AC1) — the only place `connectionState` ever becomes
848
+ // "streaming": `listen.ts`'s `connectOnce` calls this the moment the
849
+ // SSE connection is actually open, and the outer `for(;;)` above
850
+ // already resets it to "reconnecting" before every mint attempt.
851
+ onState: (state) => {
852
+ connectionState = state;
853
+ },
687
854
  });
688
855
  const stopped = run.stopped;
689
856
  if (stopped === null)
@@ -692,10 +859,15 @@ export async function runBridge(options, deps) {
692
859
  // `Set.has()` is a runtime check only, so it cannot narrow `stopped.reason`
693
860
  // for the compiler — leaving it a `Set` lookup here would keep every
694
861
  // downstream branch's exhaustive narrowing (which is what makes the final
695
- // fallthrough's `stop(stopped.reason, ...)` call type-check against the
862
+ // fallthrough's `settle(stopped.reason, ...)` call type-check against the
696
863
  // closed `ListenStopReason` overload) from working.
697
- if (stopped.reason === "superseded" || stopped.reason === "replaced")
698
- return stop(stopped.reason, stopped.detail, 0);
864
+ if (stopped.reason === "superseded" || stopped.reason === "replaced") {
865
+ const outcome = await settle(stopped.reason, stopped.detail, 0);
866
+ if (outcome.kind === "exit")
867
+ return outcome.code;
868
+ refusedInRow = 0;
869
+ continue;
870
+ }
699
871
  if (stopped.reason === "lease_expired") {
700
872
  refusedInRow = 0;
701
873
  continue;
@@ -707,14 +879,21 @@ export async function runBridge(options, deps) {
707
879
  // like its mint-time counterpart — never counted toward the generic
708
880
  // refused-ticket retry budget below, which exists for an admission
709
881
  // refusal with no more specific explanation than "not admitted".
710
- return stop(classified.reason, classified.detail, 1);
882
+ const outcome = await settle(classified.reason, classified.detail, 1);
883
+ if (outcome.kind === "exit")
884
+ return outcome.code;
885
+ refusedInRow = 0;
886
+ continue;
711
887
  }
712
888
  }
713
889
  if (stopped.reason === "refused") {
714
890
  const provedHealthy = run.emitted || deps.now() - startedAt >= HEALTHY_CONNECTION_MS;
715
891
  refusedInRow = provedHealthy ? 1 : refusedInRow + 1;
716
892
  if (refusedInRow >= MAX_REFUSED_TICKETS_IN_A_ROW) {
717
- return stop("refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`, 1);
893
+ const outcome = await settle("refused", `${stopped.detail} (a freshly minted ticket was refused ${refusedInRow} times in a row)`, 1);
894
+ if (outcome.kind === "exit")
895
+ return outcome.code;
896
+ refusedInRow = 0;
718
897
  }
719
898
  continue;
720
899
  }
@@ -725,9 +904,19 @@ export async function runBridge(options, deps) {
725
904
  // `detail`) — no `?? []`, no optional check, `stopped.boards` IS
726
905
  // `string[]` once narrowed to this branch.
727
906
  const boards = stopped.boards;
728
- return stop("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
907
+ const outcome = await settle("scope_narrowed", boardUnreadableDetail(boards, stopped.detail), 1, { boards });
908
+ if (outcome.kind === "exit")
909
+ return outcome.code;
910
+ refusedInRow = 0;
911
+ continue;
912
+ }
913
+ {
914
+ const outcome = await settle(stopped.reason, stopped.detail, 1);
915
+ if (outcome.kind === "exit")
916
+ return outcome.code;
917
+ refusedInRow = 0;
918
+ continue;
729
919
  }
730
- return stop(stopped.reason, stopped.detail, 1);
731
920
  }
732
921
  }
733
922
  /** The bin's `bridge` subcommand, wired to the real process. */
@@ -751,13 +940,27 @@ resolveFrom = {}) {
751
940
  }
752
941
  catch (err) {
753
942
  const start = err;
754
- // A start that never happened still reports itself the way every other
755
- // terminal outcome does — one stop record the plugin relays into the
756
- // session — AND on stderr, for the case where there is no session to relay
757
- // into (a hand run).
758
- write({ type: "stopped", reason: start.reason, detail: start.message, fix: start.fix });
943
+ // DX-3028 (AC2) — a start-time failure still reports itself the way every
944
+ // other stop does — one stop record the plugin relays into the session
945
+ // (now carrying `paths`/`instanceId`/`degraded`) — AND on stderr, for the
946
+ // case where there is no session to relay into (a hand run).
947
+ const policy = degradePolicyFor(start.reason);
948
+ const degraded = policy !== "exit";
949
+ write({ type: "stopped", reason: start.reason, detail: start.message, fix: start.fix, instanceId: start.instanceId, paths: start.paths, degraded });
759
950
  process.stderr.write(`${start.reason}: ${start.message}. Fix: ${start.fix}.\n`);
760
- return 1;
951
+ if (!degraded)
952
+ return 1;
953
+ // DX-3028 (AC2) — `credential_unavailable` / `credential_mismatch`:
954
+ // this process has no credential it can authenticate a heartbeat with
955
+ // (see `settle`'s identical `degrade-no-heartbeat` branch), so it sends
956
+ // none and idles until the plugin kills and restarts it fresh on a
957
+ // watched-path change. `return`ed directly (never `await`ed then fallen
958
+ // through) so TypeScript's definite-assignment check for `options` below
959
+ // sees this branch as never reaching that line, which is also true at
960
+ // runtime — this promise never settles.
961
+ return new Promise(() => {
962
+ /* deliberately never settles */
963
+ });
761
964
  }
762
965
  return runBridge(options, {
763
966
  fetch: (input, init) => fetch(input, init),
@@ -766,5 +969,9 @@ resolveFrom = {}) {
766
969
  now: Date.now,
767
970
  readIdleTimeoutMs: READ_IDLE_TIMEOUT_MS,
768
971
  requestTimeoutMs: REQUEST_TIMEOUT_MS,
972
+ scheduleInterval: (tick, ms) => {
973
+ const timer = setInterval(tick, ms);
974
+ return { stop: () => clearInterval(timer) };
975
+ },
769
976
  });
770
977
  }
package/dist/listen.js CHANGED
@@ -541,6 +541,9 @@ export async function runListener(options, deps) {
541
541
  await response.body?.cancel();
542
542
  return { kind: "dropped", healthy: false, detail: `HTTP ${response.status} with no readable body` };
543
543
  }
544
+ // DX-3028 — the connection is genuinely open and readable now; see
545
+ // `ListenDeps.onState`'s docblock for exactly what this reports.
546
+ deps.onState?.("streaming");
544
547
  const parser = new SseParser();
545
548
  const decoder = new TextDecoder();
546
549
  for await (const chunk of response.body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-dashboard-mcp",
3
- "version": "0.1.93",
3
+ "version": "0.1.95",
4
4
  "description": "Stdio MCP server wrapping danxbot's dashboard /api/issues/* normalized DB-backed HTTP routes for dispatched agents (DX-704 Phase 2).",
5
5
  "license": "MIT",
6
6
  "type": "module",