@crouton-kit/crouter 0.3.68 → 0.3.70

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.
Files changed (46) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +31 -31
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +28 -8
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +11 -11
  4. package/dist/cli.d.ts +1 -1
  5. package/dist/cli.js +1 -0
  6. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -4
  7. package/dist/clients/attach/attach-cmd.js +667 -667
  8. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  9. package/dist/clients/attach/auth-pickers.js +11 -0
  10. package/dist/clients/attach/pickers.js +2 -0
  11. package/dist/clients/attach/slash-commands.js +1 -0
  12. package/dist/commands/node.js +3 -3
  13. package/dist/commands/profile/new.js +30 -5
  14. package/dist/commands/sys/setup-core.js +2 -0
  15. package/dist/commands/sys/setup.js +132 -16
  16. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  17. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  18. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  19. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  20. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  21. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  22. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  23. package/dist/core/__tests__/helpers/harness.js +28 -4
  24. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  25. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  26. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  27. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  28. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  29. package/dist/core/canvas/pid.d.ts +14 -10
  30. package/dist/core/canvas/pid.js +14 -10
  31. package/dist/core/runtime/broker.js +2 -0
  32. package/dist/core/runtime/launch.js +1 -1
  33. package/dist/core/runtime/revive.js +28 -18
  34. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  35. package/dist/daemon/crtrd-cli.d.ts +1 -1
  36. package/dist/daemon/crtrd-cli.js +1 -0
  37. package/dist/suppress-experimental-warnings.d.ts +1 -0
  38. package/dist/suppress-experimental-warnings.js +15 -0
  39. package/dist/types.js +10 -10
  40. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  41. package/dist/web-client/assets/{index-CnF5r8ky.js → index-DiFuLcp6.js} +18 -17
  42. package/dist/web-client/index.html +3 -3
  43. package/dist/web-client/sw.js +1 -1
  44. package/package.json +10 -7
  45. package/scripts/postinstall.mjs +1 -1
  46. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -26,10 +26,10 @@ function writeLadderConfig() {
26
26
  light: "anthropic/custom-light-x",
27
27
  },
28
28
  openai: {
29
- ultra: "openai-codex/gpt-5.5:xhigh",
30
- strong: "openai-codex/gpt-5.5:high",
31
- medium: "openai-codex/gpt-5.4-mini:medium",
32
- light: "openai-codex/gpt-5.3-codex-spark",
29
+ ultra: "openai-codex/gpt-5.6-sol:max",
30
+ strong: "openai-codex/gpt-5.6-sol:high",
31
+ medium: "openai-codex/gpt-5.6-terra:medium",
32
+ light: "openai-codex/gpt-5.6-luna:low",
33
33
  },
34
34
  },
35
35
  },
@@ -726,8 +726,8 @@ test("resolves Anthropic to strength-matched OpenAI Codex fallback", () => {
726
726
 
727
727
  assert.deepEqual(helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high"), {
728
728
  providerId: "openai-codex",
729
- modelId: "gpt-5.5",
730
- label: "openai-codex/gpt-5.5:high",
729
+ modelId: "gpt-5.6-sol",
730
+ label: "openai-codex/gpt-5.6-sol:high",
731
731
  strength: "strong",
732
732
  thinkingLevel: "high",
733
733
  });
@@ -736,7 +736,7 @@ test("resolves Anthropic to strength-matched OpenAI Codex fallback", () => {
736
736
  test("resolves OpenAI Codex to strength-matched Anthropic fallback", () => {
737
737
  writeLadderConfig();
738
738
 
739
- assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "openai-codex/gpt-5.4-mini:medium"), {
739
+ assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "openai-codex/gpt-5.6-terra:medium"), {
740
740
  providerId: "anthropic",
741
741
  modelId: "claude-sonnet-4-6",
742
742
  label: "anthropic/claude-sonnet-4-6:high",
@@ -745,15 +745,15 @@ test("resolves OpenAI Codex to strength-matched Anthropic fallback", () => {
745
745
  });
746
746
  });
747
747
 
748
- // Ultra and strong share the bare model id gpt-5.5; the live thinking level selects
748
+ // Ultra and strong share the bare model id gpt-5.6-sol; the live thinking level selects
749
749
  // the matching ladder rung.
750
750
  test("disambiguates colliding ladder rungs by the live thinking level", () => {
751
751
  resetRotationState();
752
752
  writeLadderConfig();
753
753
  const config = helpers.readRotationConfig();
754
754
 
755
- // xhigh -> ultra rung -> Anthropic ultra (claude-fable-5)
756
- assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config, "xhigh"), {
755
+ // max -> ultra rung -> Anthropic ultra (claude-fable-5)
756
+ assert.deepEqual(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "max"), {
757
757
  providerId: "anthropic",
758
758
  modelId: "claude-fable-5",
759
759
  label: "anthropic/claude-fable-5:high",
@@ -762,10 +762,10 @@ test("disambiguates colliding ladder rungs by the live thinking level", () => {
762
762
  });
763
763
 
764
764
  // high -> strong rung -> Anthropic strong (claude-opus-4-8)
765
- assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config, "high").modelId, "claude-opus-4-8");
765
+ assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config, "high").modelId, "claude-opus-4-8");
766
766
 
767
767
  // No thinking level falls back to the default-strength tie-break (strong -> opus).
768
- assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.5", config).modelId, "claude-opus-4-8");
768
+ assert.equal(helpers.resolveFallbackTarget("openai-codex", "gpt-5.6-sol", config).modelId, "claude-opus-4-8");
769
769
  });
770
770
 
771
771
  test("parses Retry-After seconds and HTTP dates", () => {
@@ -783,7 +783,7 @@ test("restores preferred model when its provider pool frees up", async () => {
783
783
  resetRotationState();
784
784
  writeLadderConfig();
785
785
  helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
786
- helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.5" } });
786
+ helpers.writeRotationConfig({ preferredModel: { providerId: "openai-codex", modelId: "gpt-5.6-sol" } });
787
787
 
788
788
  const setModelCalls: any[] = [];
789
789
  const ctx = {
@@ -807,7 +807,7 @@ test("restores preferred model when its provider pool frees up", async () => {
807
807
  assert.equal(restored, true);
808
808
  assert.deepEqual(setModelCalls[0], {
809
809
  provider: "openai-codex",
810
- id: "gpt-5.5",
810
+ id: "gpt-5.6-sol",
811
811
  api: "openai-codex-responses",
812
812
  });
813
813
  assert.equal(helpers.readRotationConfig().preferredModel, undefined);
@@ -860,7 +860,7 @@ test("switches exhausted Anthropic sessions to OpenAI Codex fallback", async ()
860
860
  model: { provider: "anthropic", id: "claude-sonnet-4-6", api: "anthropic-messages" },
861
861
  modelRegistry: {
862
862
  find(provider: string, id: string) {
863
- return provider === "openai-codex" && id === "gpt-5.4-mini" ? { provider, id, api: "openai-codex-responses" } : undefined;
863
+ return provider === "openai-codex" && id === "gpt-5.6-terra" ? { provider, id, api: "openai-codex-responses" } : undefined;
864
864
  },
865
865
  },
866
866
  } as any;
@@ -881,14 +881,14 @@ test("switches exhausted Anthropic sessions to OpenAI Codex fallback", async ()
881
881
 
882
882
  assert.deepEqual(target, {
883
883
  providerId: "openai-codex",
884
- modelId: "gpt-5.4-mini",
885
- label: "openai-codex/gpt-5.4-mini:medium",
884
+ modelId: "gpt-5.6-terra",
885
+ label: "openai-codex/gpt-5.6-terra:medium",
886
886
  strength: "medium",
887
887
  thinkingLevel: "medium",
888
888
  });
889
889
  assert.deepEqual(switchedTo, {
890
890
  provider: "openai-codex",
891
- id: "gpt-5.4-mini",
891
+ id: "gpt-5.6-terra",
892
892
  api: "openai-codex-responses",
893
893
  });
894
894
  assert.equal(thinkingLevel, "medium");
@@ -903,7 +903,7 @@ test("switches exhausted OpenAI Codex sessions to Anthropic fallback", async ()
903
903
  writeLadderConfig();
904
904
 
905
905
  const ctx = {
906
- model: { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
906
+ model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
907
907
  modelRegistry: {
908
908
  find(provider: string, id: string) {
909
909
  return provider === "anthropic" && id === "claude-opus-4-8" ? { provider, id, api: "anthropic-messages" } : undefined;
@@ -940,18 +940,18 @@ test("switches exhausted OpenAI Codex sessions to Anthropic fallback", async ()
940
940
  assert.equal(thinkingLevel, "high");
941
941
  assert.deepEqual(helpers.readRotationConfig().preferredModel, {
942
942
  providerId: "openai-codex",
943
- modelId: "gpt-5.5",
943
+ modelId: "gpt-5.6-sol",
944
944
  });
945
945
  });
946
946
 
947
947
  function makeRotationCtx() {
948
948
  const ctx = {
949
- model: { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
949
+ model: { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
950
950
  ui: { setStatus() {} },
951
951
  modelRegistry: {
952
952
  find(provider: string, id: string) {
953
953
  if (provider === "anthropic" && id === "claude-opus-4-8") return { provider, id, api: "anthropic-messages" };
954
- if (provider === "openai-codex" && id === "gpt-5.5") return { provider, id, api: "openai-codex-responses" };
954
+ if (provider === "openai-codex" && id === "gpt-5.6-sol") return { provider, id, api: "openai-codex-responses" };
955
955
  return undefined;
956
956
  },
957
957
  },
@@ -999,7 +999,7 @@ test("falls back transparently and serves the turn on Anthropic when Codex is ex
999
999
  await providerRotationExtension(pi);
1000
1000
  await events.session_start?.({}, ctx);
1001
1001
  const stream = providers["openai-codex"].streamSimple(
1002
- { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
1002
+ { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1003
1003
  ctx,
1004
1004
  );
1005
1005
  const emitted = [] as any[];
@@ -1377,7 +1377,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1377
1377
  modelRegistry: {
1378
1378
  find(provider: string, id: string) {
1379
1379
  if (provider === "anthropic" && id === "claude-opus-4-8") return { provider, id, api: "anthropic-messages" };
1380
- if (provider === "openai-codex" && id === "gpt-5.5") return { provider, id, api: "openai-codex-responses" };
1380
+ if (provider === "openai-codex" && id === "gpt-5.6-sol") return { provider, id, api: "openai-codex-responses" };
1381
1381
  return undefined;
1382
1382
  },
1383
1383
  },
@@ -1404,7 +1404,7 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1404
1404
  await providerRotationExtension(pi);
1405
1405
  await events.session_start?.({}, ctx);
1406
1406
  // Anthropic pool is empty (reset above), so the credential loop never runs. The builtin
1407
- // ladder resolves a cross-provider target (openai-codex/gpt-5.5), but that provider was
1407
+ // ladder resolves a cross-provider target (openai-codex/gpt-5.6-sol), but that provider was
1408
1408
  // never authenticated either (#123 gates fallback on the target actually being authed),
1409
1409
  // so the session model stays unchanged and the turn returns an actionable message.
1410
1410
  const stream = providers["anthropic"].streamSimple(ctx.model, ctx);
@@ -1419,16 +1419,16 @@ test("degrades gracefully via the builtin ladder when a config-less user has no
1419
1419
  });
1420
1420
 
1421
1421
  // readModelLadders() reads the builtin anthropic/openai ladder when config.json is absent.
1422
- test("resolves the builtin anthropic ladder (medium = sonnet-5) with no ~/.crouter/config.json present", () => {
1422
+ test("resolves the builtin anthropic ladder (medium = opus-4-8) with no ~/.crouter/config.json present", () => {
1423
1423
  resetRotationState();
1424
1424
  rmSync(join(home, ".crouter", "config.json"), { force: true });
1425
1425
 
1426
- const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-sonnet-5:high");
1426
+ const target = helpers.resolveFallbackTarget("anthropic", "anthropic/claude-opus-4-8:high");
1427
1427
 
1428
1428
  assert.deepEqual(target, {
1429
1429
  providerId: "openai-codex",
1430
- modelId: "gpt-5.5",
1431
- label: "openai-codex/gpt-5.5:medium",
1430
+ modelId: "gpt-5.6-terra",
1431
+ label: "openai-codex/gpt-5.6-terra:medium",
1432
1432
  strength: "medium",
1433
1433
  thinkingLevel: "medium",
1434
1434
  });
@@ -1578,7 +1578,7 @@ test("treats a Codex usage-limit message on a non-429 status as a rate limit: co
1578
1578
  helpers.writeSubscriptionPool("anthropic", [credential("personal")]);
1579
1579
 
1580
1580
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1581
- ctx.model = { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" };
1581
+ ctx.model = { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" };
1582
1582
  const streamedModels: any[] = [];
1583
1583
  rotationModule.__setStreamForProviderForTest((model) => {
1584
1584
  streamedModels.push(model);
@@ -1601,7 +1601,7 @@ test("treats a Codex usage-limit message on a non-429 status as a rate limit: co
1601
1601
  await providerRotationExtension(pi);
1602
1602
  await events.session_start?.({}, ctx);
1603
1603
  const stream = providers["openai-codex"].streamSimple(
1604
- { provider: "openai-codex", id: "gpt-5.5", api: "openai-codex-responses" },
1604
+ { provider: "openai-codex", id: "gpt-5.6-sol", api: "openai-codex-responses" },
1605
1605
  ctx,
1606
1606
  );
1607
1607
  const emitted = [] as any[];
@@ -736,31 +736,51 @@ function updateRotationStatus(ctx: ExtensionContext): void {
736
736
  if (current) setStatus(formatStatusLine(current.providerId));
737
737
  }
738
738
 
739
+ // Manual-code input is a human-in-the-loop OAuth round-trip (open link, log in on
740
+ // whatever device, copy the localhost redirect URL back) that routinely exceeds
741
+ // DEFAULT_DIALOG_TIMEOUT_MS -- give it a human-scale bound instead of the ordinary
742
+ // confirm/select default.
743
+ const OAUTH_MANUAL_INPUT_TIMEOUT_MS = 600_000;
744
+
739
745
  async function loginProvider(providerId: ManagedProviderId, ctx: ExtensionContext) {
740
746
  if (providerId === ANTHROPIC_PROVIDER_ID) {
747
+ let authorizeUrl: string | undefined;
741
748
  return loginAnthropic({
742
749
  onAuth: ({ url, instructions }) => {
743
- const lines = [`Open this URL to authenticate Claude: ${url}`];
744
- if (instructions) lines.push(instructions);
745
- ctx.ui.notify(lines.join("\n"), "info");
750
+ authorizeUrl = url;
751
+ // Best-effort for the local attach case where a browser is reachable; on a
752
+ // headless remote box this opens nothing and the URL below is the real path.
746
753
  openBrowser(url);
754
+ if (instructions) ctx.ui.notify(instructions, "info");
747
755
  },
748
756
  onPrompt: async (prompt) => (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "",
749
757
  onProgress: (message) => ctx.ui.notify(message, "info"),
750
- onManualCodeInput: async () => (await ctx.ui.input("Paste Anthropic redirect URL or code", "http://localhost:53692/callback?...")) ?? "",
758
+ onManualCodeInput: async () =>
759
+ (await ctx.ui.input(
760
+ `Open this URL to authenticate Claude:\n${authorizeUrl}\n\nPaste the redirect URL or code`,
761
+ "http://localhost:53692/callback?...",
762
+ { timeout: OAUTH_MANUAL_INPUT_TIMEOUT_MS },
763
+ )) ?? "",
751
764
  });
752
765
  }
753
766
 
767
+ let authorizeUrl: string | undefined;
754
768
  return loginOpenAICodex({
755
769
  onAuth: ({ url, instructions }) => {
756
- const lines = [`Open this URL to authenticate OpenAI Codex: ${url}`];
757
- if (instructions) lines.push(instructions);
758
- ctx.ui.notify(lines.join("\n"), "info");
770
+ authorizeUrl = url;
771
+ // Best-effort for the local attach case where a browser is reachable; on a
772
+ // headless remote box this opens nothing and the URL below is the real path.
759
773
  openBrowser(url);
774
+ if (instructions) ctx.ui.notify(instructions, "info");
760
775
  },
761
776
  onPrompt: async (prompt) => (await ctx.ui.input(prompt.message, prompt.placeholder ?? "")) ?? "",
762
777
  onProgress: (message) => ctx.ui.notify(message, "info"),
763
- onManualCodeInput: async () => (await ctx.ui.input("Paste OpenAI redirect URL or code", "http://localhost:1455/auth/callback?...")) ?? "",
778
+ onManualCodeInput: async () =>
779
+ (await ctx.ui.input(
780
+ `Open this URL to authenticate OpenAI Codex:\n${authorizeUrl}\n\nPaste the redirect URL or code`,
781
+ "http://localhost:1455/auth/callback?...",
782
+ { timeout: OAUTH_MANUAL_INPUT_TIMEOUT_MS },
783
+ )) ?? "",
764
784
  });
765
785
  }
766
786
 
@@ -291,7 +291,7 @@ export function parseRetryAfterHeader(value: string | undefined, now = Date.now(
291
291
  }
292
292
 
293
293
  function thinkingLevelOfRef(ref: string): ThinkingLevel | undefined {
294
- const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh)$/i);
294
+ const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
295
295
  if (!match) return undefined;
296
296
  const level = match[1].toLowerCase();
297
297
  return level === "off" || level === "thinking" ? undefined : (level as ThinkingLevel);
@@ -301,7 +301,7 @@ function normalizeModelRef(ref: string): string {
301
301
  return ref
302
302
  .trim()
303
303
  .replace(/^[^/]+\//, "")
304
- .replace(/:(?:thinking|off|minimal|low|medium|high|xhigh)$/i, "")
304
+ .replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
305
305
  .toLowerCase();
306
306
  }
307
307
 
@@ -313,7 +313,7 @@ function splitModelRef(ref: string): FallbackTarget {
313
313
  throw new Error(`Unsupported fallback provider: ${providerId}`);
314
314
  }
315
315
  const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
316
- const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh))$/i);
316
+ const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
317
317
  return {
318
318
  providerId,
319
319
  modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
@@ -333,15 +333,15 @@ function splitModelRef(ref: string): FallbackTarget {
333
333
  const BUILTIN_MODEL_LADDERS: Record<LadderProviderId, ModelLadderConfig> = {
334
334
  anthropic: {
335
335
  ultra: "anthropic/claude-fable-5:high",
336
- strong: "anthropic/claude-opus-4-8:high",
337
- medium: "anthropic/claude-sonnet-5:high",
338
- light: "anthropic/claude-haiku-4-5:high",
336
+ strong: "anthropic/claude-fable-5:medium",
337
+ medium: "anthropic/claude-opus-4-8:high",
338
+ light: "anthropic/claude-sonnet-5:high",
339
339
  },
340
340
  openai: {
341
- ultra: "openai-codex/gpt-5.5:xhigh",
342
- strong: "openai-codex/gpt-5.5:high",
343
- medium: "openai-codex/gpt-5.5:medium",
344
- light: "openai-codex/gpt-5.3-codex-spark",
341
+ ultra: "openai-codex/gpt-5.6-sol:max",
342
+ strong: "openai-codex/gpt-5.6-sol:high",
343
+ medium: "openai-codex/gpt-5.6-terra:medium",
344
+ light: "openai-codex/gpt-5.6-luna:low",
345
345
  },
346
346
  };
347
347
 
@@ -389,7 +389,7 @@ function findStrengthForModelId(
389
389
  if (ref && normalizeModelRef(ref) === bareId) matches.push(strength);
390
390
  }
391
391
  if (matches.length === 0) return undefined;
392
- // Rungs that share a bare model id (e.g. gpt-5.5:xhigh vs gpt-5.5:high) are
392
+ // Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
393
393
  // disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
394
394
  if (matches.length > 1 && currentThinkingLevel) {
395
395
  const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]!) === currentThinkingLevel);
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import './suppress-experimental-warnings.js';
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import './suppress-experimental-warnings.js';
2
3
  import { enableCompileCache } from 'node:module';
3
4
  import { runCli } from './core/command.js';
4
5
  import { resolveRoot } from './build-root.js';
@@ -152,7 +152,7 @@ function seedPoisonedLocalState() {
152
152
  last_activity: 'POISON ACTIVITY — local read leaked into remote attach chrome',
153
153
  }));
154
154
  }
155
- test('buildCanvasPanelLines suppresses every local-read adornment for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
155
+ test('buildCanvasPanelLines suppresses every local-read adornment for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
156
156
  seedPoisonedLocalState();
157
157
  const rootMeta = meta('root');
158
158
  const aMeta = meta('a'); // the REMOTE node's meta — unrelated to the poisoned local NodeMeta above
@@ -166,6 +166,10 @@ test('buildCanvasPanelLines suppresses every local-read adornment for a RemoteCa
166
166
  }
167
167
  return { status: 500, body: {} };
168
168
  });
169
+ // Register the close BEFORE any assertion runs — a listening server left
170
+ // open by a thrown assertion keeps this test file's process alive forever
171
+ // (see the CI-hang postmortem on rebuild-coalescer.test.ts).
172
+ t.after(() => server.close());
169
173
  const source = new RemoteCanvasSource(server.url, 'tok');
170
174
  // 'root' has 'a' as a live report → row('a') renders into `reports`.
171
175
  const { reports } = await buildCanvasPanelLines('root', {}, IDENTITY_PALETTE, source);
@@ -175,9 +179,8 @@ test('buildCanvasPanelLines suppresses every local-read adornment for a RemoteCa
175
179
  assert.ok(!line.includes('POISON'), `poisoned local text must never appear in a remote row: ${line}`);
176
180
  assert.ok(!line.includes('⚠'), `a local active fault must not surface as the hanging glyph on a remote row: ${line}`);
177
181
  assert.ok(!line.includes(BG_ATTACHED), `a local focus/attach must not paint a remote row as attached: ${line}`);
178
- await server.close();
179
182
  });
180
- test('GraphOverlay suppresses every local-read adornment for a RemoteCanvasSource AND makes Enter/m/x no-ops, even with conflicting local state for the same node id', async () => {
183
+ test('GraphOverlay suppresses every local-read adornment for a RemoteCanvasSource AND makes Enter/m/x no-ops, even with conflicting local state for the same node id', async (t) => {
181
184
  seedPoisonedLocalState();
182
185
  const rootMeta = meta('root');
183
186
  const aMeta = meta('a');
@@ -191,6 +194,8 @@ test('GraphOverlay suppresses every local-read adornment for a RemoteCanvasSourc
191
194
  }
192
195
  return { status: 500, body: {} };
193
196
  });
197
+ // Register the close BEFORE any assertion runs — see the note above.
198
+ t.after(() => server.close());
194
199
  const source = new RemoteCanvasSource(server.url, 'tok');
195
200
  const { tui } = fakeTui();
196
201
  const overlay = new GraphOverlay(tui, 'root', () => ({}), IDENTITY_PALETTE, source);
@@ -219,7 +224,6 @@ test('GraphOverlay suppresses every local-read adornment for a RemoteCanvasSourc
219
224
  // m must NOT close the overlay either (it would focusTarget the manager).
220
225
  overlay.handleInput('m');
221
226
  assert.equal(overlay.isOpen(), true, 'm must not act (close) on a remote graph row');
222
- await server.close();
223
227
  });
224
228
  // ---------------------------------------------------------------------------
225
229
  // Phase 3 review 2 — the ENCLOSING attach viewer (slash commands, pane tag,