@crouton-kit/crouter 0.3.65 → 0.3.67

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 (83) hide show
  1. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  2. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  8. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  9. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  10. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  11. package/dist/clients/attach/attach-cmd.js +632 -632
  12. package/dist/clients/attach/canvas-panels.js +9 -3
  13. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  14. package/dist/clients/attach/graph-overlay.js +65 -11
  15. package/dist/clients/attach/input-controller.d.ts +9 -0
  16. package/dist/clients/attach/input-controller.js +47 -8
  17. package/dist/clients/attach/slash-commands.d.ts +11 -4
  18. package/dist/clients/attach/slash-commands.js +44 -9
  19. package/dist/clients/attach/transport-relay.d.ts +5 -5
  20. package/dist/clients/attach/transport-relay.js +3 -30
  21. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  22. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  23. package/dist/commands/canvas-browse.js +26 -1
  24. package/dist/commands/canvas-config.d.ts +2 -0
  25. package/dist/commands/canvas-config.js +210 -0
  26. package/dist/commands/canvas-use.d.ts +1 -0
  27. package/dist/commands/canvas-use.js +63 -0
  28. package/dist/commands/canvas.js +4 -2
  29. package/dist/commands/node-lifecycle-revive.js +9 -2
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
  32. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
  33. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  34. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  35. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  36. package/dist/core/__tests__/secrets.test.js +55 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  38. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  40. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  42. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  44. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  46. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  47. package/dist/core/canvas/browse/app.d.ts +14 -0
  48. package/dist/core/canvas/browse/app.js +97 -15
  49. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  50. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  51. package/dist/core/canvas/browse/render.d.ts +4 -0
  52. package/dist/core/canvas/browse/render.js +3 -1
  53. package/dist/core/canvas/nav-model.d.ts +19 -10
  54. package/dist/core/canvas/nav-model.js +30 -12
  55. package/dist/core/canvas/pid.d.ts +71 -13
  56. package/dist/core/canvas/pid.js +73 -29
  57. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  58. package/dist/core/canvas/remote-canvas-source.js +222 -0
  59. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  60. package/dist/core/canvas/remote-event-stream.js +94 -0
  61. package/dist/core/canvas/render.d.ts +13 -1
  62. package/dist/core/canvas/render.js +56 -37
  63. package/dist/core/canvas/source.d.ts +9 -0
  64. package/dist/core/canvas/source.js +15 -0
  65. package/dist/core/config.js +4 -3
  66. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  67. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  68. package/dist/core/runtime/nodes.js +10 -0
  69. package/dist/core/runtime/revive-all.js +8 -2
  70. package/dist/core/runtime/revive.js +18 -8
  71. package/dist/core/secrets.d.ts +25 -0
  72. package/dist/core/secrets.js +55 -0
  73. package/dist/core/substrate/on-read.js +13 -2
  74. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  75. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  76. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  77. package/dist/core/view/remote-canvas-target.js +12 -7
  78. package/dist/core/view/transport-remote.d.ts +2 -0
  79. package/dist/core/view/transport-remote.js +53 -0
  80. package/dist/daemon/crtrd.js +18 -8
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/package.json +1 -1
@@ -17,4 +17,4 @@ You are an implementation agent. Your job is to **implement this feature or chan
17
17
 
18
18
  Work directly. Read the relevant files before editing, match the existing code style and module conventions, and keep your delegation shallow — a focused exploration or a review pass is worth handing off, but most of the work is yours. Throw errors early; no silent fallbacks. Break things correctly rather than patching them badly; prefer clean, breaking changes over backwards-compat hacks in pre-production code.
19
19
 
20
- Done means **provably correct against the spec's acceptance criteria** — not "it builds," not "the tests pass." Green output proves the code ran, not that it does what was asked; check the result against each acceptance criterion yourself. On a load-bearing change, get it critiqued by something other than you before calling it done — spawn a reviewer on the diff and fold in what it finds. And if the change outgrows what one window can finish well — many files, several phases, a design that keeps moving — promote yourself into a developer orchestrator and decompose it rather than grinding past the edge of your context.
20
+ Done means **provably correct against the spec's acceptance criteria** — not "it builds," not "the tests pass." Green output proves the code ran, not that it does what was asked; check the result against each acceptance criterion yourself. On a load-bearing change, get it critiqued by something other than you before calling it done — spawn a reviewer on the diff and fold in what it finds. But validate judiciously: a delegate's green report is settled evidence — don't re-run a suite or re-read a diff that already cleared its gate; check only what changed since. And if the change outgrows what one window can finish well — many files, several phases, a design that keeps moving — promote yourself into a developer orchestrator and decompose it rather than grinding past the edge of your context.
@@ -12,4 +12,6 @@ Before you shape the roadmap, read `crtr memory read development` for the roadma
12
12
 
13
13
  Stay flexible, not waterfall. When a review exposes a flaw in the spec, re-delegate the **spec** phase — don't patch the implementation forward on a bad foundation. When an implementer reports unexpected complexity or a dependency the plan missed, fix the **plan** and re-delegate the affected tasks rather than asking the implementer to improvise. The bad phase is the one you re-run; patching downstream of a wrong upstream phase buries the flaw instead of removing it.
14
14
 
15
+ Validate judiciously — trust the agent. A gate that already ran green is settled evidence: when a child reports its build clean, its suite passing, or a reviewer reports the diff read, take the report and move on — do not re-run the same suite, rebuild the same tree, or re-read the same code to reassure yourself. Attach each new check to what *changed* since the last green gate (the fix diff, the new tests), never the whole feature from scratch; re-verify a report only when it is internally inconsistent or contradicted by evidence, not on general suspicion. Redundant re-validation burns whole windows and adds no information — the second identical green proves nothing the first didn't.
16
+
15
17
  Post-implementation review is not one generic "review this" pass — it is several distinct perspectives, each its own assessment: does this **reuse** what the codebase already provides rather than reinventing it, is the **quality** sound, is it **efficient**, and are the **tests** real rather than green theatre. Hand each lens to its own reviewer so it assesses independently. Size the reviewer to the surface: a small focused diff goes to a single `review` worker; a whole feature's worth of files goes to a `review` orchestrator that fans the lenses across it and returns one verdict.
@@ -1074,10 +1074,10 @@ test("treats a request timeout as transient: retries the SAME provider in place,
1074
1074
  }
1075
1075
  });
1076
1076
 
1077
- // Regression: when a transient blip never recovers, the turn must end with a clear,
1078
- // actionable error instead of spinning forever or silently rotating a healthy credential
1079
- // away to a fallback provider that was never the actual problem.
1080
- test("ends the turn without rotating once the transient retry budget is exhausted", async () => {
1077
+ // Once the in-place transient retry budget is exhausted, the failure is surfaced to the
1078
+ // user AND the credential is rotated away: the struggling credential cools down briefly and
1079
+ // the turn continues on the fallback provider, rather than hard-failing.
1080
+ test("surfaces the transient error and rotates to the fallback once the in-place retry budget is exhausted", async () => {
1081
1081
  resetRotationState();
1082
1082
  writeLadderConfig();
1083
1083
  helpers.writeSubscriptionPool("anthropic", [credential("primary")]);
@@ -1085,6 +1085,10 @@ test("ends the turn without rotating once the transient retry budget is exhauste
1085
1085
 
1086
1086
  const { ctx, setModelCalls, providers, events, pi } = makeRotationCtx();
1087
1087
  ctx.model = { provider: "anthropic", id: "claude-opus-4-8", api: "anthropic-messages" };
1088
+ const notifications: Array<{ message: string; level: string }> = [];
1089
+ ctx.ui.notify = (message: string, level: string) => {
1090
+ notifications.push({ message, level });
1091
+ };
1088
1092
  const streamedModels: any[] = [];
1089
1093
  const sleepCalls: number[] = [];
1090
1094
  rotationModule.__setSleepForTest(async (ms) => {
@@ -1093,8 +1097,12 @@ test("ends the turn without rotating once the transient retry budget is exhauste
1093
1097
  rotationModule.__setStreamForProviderForTest((model) => {
1094
1098
  streamedModels.push(model);
1095
1099
  return (async function* () {
1096
- // 503 with no retry-after header is transient by STATUS, not just by message.
1097
- yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
1100
+ if (model.provider === "anthropic") {
1101
+ // 503 with no retry-after header is transient by STATUS, not just by message.
1102
+ yield { type: "error", error: Object.assign(new Error("Service Unavailable"), { status: 503 }) };
1103
+ return;
1104
+ }
1105
+ yield { type: "text_delta", text: "hello from codex fallback" };
1098
1106
  })() as any;
1099
1107
  });
1100
1108
 
@@ -1108,13 +1116,17 @@ test("ends the turn without rotating once the transient retry budget is exhauste
1108
1116
  const emitted = [] as any[];
1109
1117
  for await (const event of stream) emitted.push(event);
1110
1118
 
1111
- assert.deepEqual(setModelCalls, []);
1112
- assert.deepEqual(streamedModels.map((m) => m.provider), ["anthropic", "anthropic", "anthropic"]);
1119
+ // Retried the same anthropic credential in place (3 attempts) then rotated to codex.
1120
+ assert.equal(streamedModels.filter((m) => m.provider === "anthropic").length, 3);
1113
1121
  assert.deepEqual(sleepCalls, [250, 750]);
1114
- assert.equal(emitted.length, 1);
1115
- assert.equal(emitted[0].type, "error");
1116
- assert.match(emitted[0].error.errorMessage, /transient error after 3 attempts/);
1117
- assert.equal(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil, 0);
1122
+ assert.deepEqual(setModelCalls.map((m) => m.provider), ["openai-codex"]);
1123
+ // The turn was served on the fallback, never failed with a terminal error.
1124
+ assert.ok(emitted.some((e) => e.type === "text_delta"));
1125
+ assert.ok(!emitted.some((e) => e.type === "error"));
1126
+ // The user was told about the transient failure AND that it rotated.
1127
+ assert.ok(notifications.some((n) => /transient error after 3 attempts/.test(n.message) && /rotating/i.test(n.message)));
1128
+ // The struggling anthropic credential was cooled down so rotation moved on.
1129
+ assert.ok(helpers.readSubscriptionPool("anthropic")[0].rateLimitedUntil > Date.now());
1118
1130
  } finally {
1119
1131
  rotationModule.__setStreamForProviderForTest(undefined);
1120
1132
  rotationModule.__setSleepForTest(undefined);
@@ -47,17 +47,23 @@ const INVALID_REFRESH_TOKEN_BACKOFF_MS = 30 * 24 * 60 * 60 * 1000;
47
47
  // not a wait. Cap at the same window as our own default rate-limit backoff.
48
48
  const SOLE_PROVIDER_MAX_WAIT_MS = DEFAULT_RATE_LIMIT_BACKOFF_MS;
49
49
  // Short in-place retry budget for a transient network/5xx blip on the SAME credential
50
- // (#122): a couple of quick retries, no cooldown, no rotation to another credential or
51
- // provider. Once this budget is exhausted the turn ends with an actionable error.
50
+ // (#122): a couple of quick retries, no cooldown. Once this budget is exhausted we no
51
+ // longer hard-fail the turn -- we surface the error and rotate (see TRANSIENT_ROTATE below).
52
52
  const TRANSIENT_RETRY_BACKOFFS_MS = [250, 750];
53
+ // After the in-place transient retry budget is exhausted, rotate instead of failing: the
54
+ // credential itself is probably healthy (this is a provider-side blip like "overloaded"),
55
+ // so cool it down only briefly -- long enough to rotate to another credential / fallback
56
+ // provider, short enough that it comes back quickly if it was the only option.
57
+ const TRANSIENT_ROTATE_COOLDOWN_MS = 60 * 1000;
53
58
 
54
59
  let runtimeContext: ExtensionContext | undefined;
55
60
  let extensionAPI: ExtensionAPI | undefined;
56
61
 
57
62
  type AttemptResult = {
58
- result: "success" | "rate_limited" | "fatal";
63
+ result: "success" | "rate_limited" | "transient_exhausted" | "fatal";
59
64
  retryAfterMs?: number;
60
65
  attemptAt: number;
66
+ attempts?: number;
61
67
  classification?: ProviderErrorClassification;
62
68
  };
63
69
 
@@ -466,21 +472,16 @@ async function streamProviderAttempt(
466
472
  return { result: "rate_limited", retryAfterMs: requestedRetryAfterMs, attemptAt, classification };
467
473
  }
468
474
 
469
- // Transient: retry the SAME credential/provider in place -- no cooldown, no rotation.
475
+ // Transient: retry the SAME credential/provider in place first -- no cooldown for the
476
+ // quick in-place budget.
470
477
  const backoffMs = TRANSIENT_RETRY_BACKOFFS_MS[attempt - 1];
471
478
  if (backoffMs === undefined) {
472
- // Exhausted the short in-place retry budget: surface a clear terminal error rather
473
- // than spinning forever or silently rotating a healthy credential away.
474
- bufferedEvents.push({
475
- type: "error",
476
- reason: "error",
477
- error: toAssistantError(
478
- model,
479
- `${getProviderLabel(providerId)} "${credential.label}" hit a transient error after ${attempt} attempts (${classification.reason})`,
480
- ),
481
- });
482
- flush();
483
- return { result: "fatal", attemptAt, classification };
479
+ // In-place retry budget exhausted. Rather than hard-failing the turn, hand back a
480
+ // transient-exhausted result so the caller surfaces the error to the user AND rotates
481
+ // to another credential / fallback provider. Nothing is emitted into the stream here:
482
+ // the buffered (pre-content) events are discarded exactly like the rate-limit path, so
483
+ // the rotation target streams a clean turn.
484
+ return { result: "transient_exhausted", attemptAt, classification, attempts: attempt };
484
485
  }
485
486
  logRotation(
486
487
  `${getProviderLabel(providerId)} subscription "${credential.label}" hit a transient error (${classification.reason}, status=${classification.status ?? "n/a"}); retrying same credential in ${backoffMs}ms (attempt ${attempt + 1})`,
@@ -599,6 +600,30 @@ async function runManagedProvider(
599
600
  logRotation(
600
601
  `${getProviderLabel(providerId)} subscription "${freshCredential.label}" rate-limited (status=${result.classification?.status ?? "n/a"}, reason=${result.classification?.reason ?? "unclassified"}, ${retryAfterPart}); cooling down for ${Math.ceil(cooldownMs / 1000)}s, trying next credential`,
601
602
  );
603
+ // Surface the rotation to the user too (parity with the transient-exhausted path), so a
604
+ // rate-limit-driven credential/provider switch is visible rather than silent.
605
+ runtimeContext?.ui.notify?.(
606
+ `${getProviderLabel(providerId)} "${freshCredential.label}" rate-limited (cooling down ${Math.ceil(cooldownMs / 1000)}s); rotating to another credential/provider.`,
607
+ "warn",
608
+ );
609
+ continue;
610
+ }
611
+
612
+ if (result.result === "transient_exhausted") {
613
+ // The credential is likely healthy -- this was a provider-side transient blip (e.g.
614
+ // "overloaded") that survived the in-place retries. Surface it to the user and rotate:
615
+ // cool the credential down briefly so this loop (and the pool-exhaustion logic below)
616
+ // moves on to the next credential / fallback provider instead of failing the turn.
617
+ const reason = result.classification?.reason ?? "transient error";
618
+ markSubscriptionRateLimited(providerId, freshCredential.label, TRANSIENT_ROTATE_COOLDOWN_MS, result.attemptAt);
619
+ runtimeContext?.ui.notify?.(
620
+ `${getProviderLabel(providerId)} "${freshCredential.label}" hit a transient error after ${result.attempts ?? 1} attempts (${reason}); rotating to another credential/provider.`,
621
+ "warn",
622
+ );
623
+ logRotation(
624
+ `${getProviderLabel(providerId)} subscription "${freshCredential.label}" transient error exhausted (reason=${reason}, status=${result.classification?.status ?? "n/a"}); cooling down ${Math.ceil(TRANSIENT_ROTATE_COOLDOWN_MS / 1000)}s and rotating`,
625
+ );
626
+ setStatus(formatStatusLine(providerId));
602
627
  continue;
603
628
  }
604
629
 
@@ -0,0 +1,371 @@
1
+ // attach-chrome-remote.test.ts — the safe-defaults contract for the attach
2
+ // chrome (buildCanvasPanelLines + GraphOverlay) when the `canvasSource` is a
3
+ // RemoteCanvasSource, i.e. `crtr surface attach --canvas <name>`.
4
+ //
5
+ // Adversarial, not merely a clean-machine assertion (mirrors
6
+ // core/canvas/__tests__/render-remote.test.ts): BEFORE rendering, this test
7
+ // seeds real, CONFLICTING local state for node id `a` — a poisoned telemetry
8
+ // reading (context tokens + a live-activity cue), an active fault, a local
9
+ // tmux focus, and a broker attach.json marking it human-watched — every
10
+ // historically-broken local read the remote suppression in nav-model.ts is
11
+ // supposed to bypass. If the production suppression ever regressed back to
12
+ // consulting local disk/SQLite for a remote id, the rendered lines below would
13
+ // carry the poisoned tokens/glyph/background instead of the safe defaults, so
14
+ // a clean-machine run could never mask the regression.
15
+ //
16
+ // Also proves the read-only contract: GraphOverlay's Enter/m/x key handlers
17
+ // must no-op against a remote graph row instead of shelling local
18
+ // `crtr node focus` / `crtr node lifecycle close`.
19
+ import { test, before, beforeEach, after } from 'node:test';
20
+ import assert from 'node:assert/strict';
21
+ import { createServer } from 'node:http';
22
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { RemoteCanvasSource } from '../../../core/canvas/remote-canvas-source.js';
26
+ import { createNode } from '../../../core/canvas/canvas.js';
27
+ import { closeDb } from '../../../core/canvas/db.js';
28
+ import { openFocusRow } from '../../../core/canvas/focuses.js';
29
+ import { jobDir } from '../../../core/canvas/paths.js';
30
+ import { recordFault } from '../../../core/runtime/fault.js';
31
+ import { BG_ATTACHED } from '../../../core/canvas/nav-model.js';
32
+ import { dispatchSlashCommand, slashCommandList } from '../slash-commands.js';
33
+ import { resolveInitialMode, requestModeSwitch, tagAttachPane, untagAttachPane, reapOnDetach, } from '../attach-cmd.js';
34
+ import { buildCanvasPanelLines } from '../canvas-panels.js';
35
+ import { GraphOverlay } from '../graph-overlay.js';
36
+ function startMockCrtrServer(route) {
37
+ return new Promise((resolve) => {
38
+ const server = createServer((req, res) => {
39
+ const chunks = [];
40
+ req.on('data', (c) => chunks.push(c));
41
+ req.on('end', () => {
42
+ const bodyText = Buffer.concat(chunks).toString('utf8');
43
+ const parsed = JSON.parse(bodyText);
44
+ const result = route(parsed.args);
45
+ res.writeHead(result.status ?? 200, { 'content-type': 'application/json' });
46
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(result.body), stderr: '' }));
47
+ });
48
+ });
49
+ server.listen(0, '127.0.0.1', () => {
50
+ const addr = server.address();
51
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
52
+ resolve({
53
+ url: `http://127.0.0.1:${port}`,
54
+ close: () => new Promise((res) => server.close(() => res())),
55
+ });
56
+ });
57
+ });
58
+ }
59
+ function meta(id, extra) {
60
+ return {
61
+ node_id: id,
62
+ name: id,
63
+ created: new Date().toISOString(),
64
+ cwd: '/tmp/work',
65
+ kind: 'general',
66
+ mode: 'base',
67
+ lifecycle: 'terminal',
68
+ status: 'active',
69
+ ...extra,
70
+ };
71
+ }
72
+ function show(node, reports, managers) {
73
+ return {
74
+ node,
75
+ reports: reports.map((id) => ({ node_id: id, name: id, kind: 'general', status: 'active', active: true })),
76
+ managers: managers.map((id) => ({ node_id: id, name: id, kind: 'general', status: 'active', active: true })),
77
+ artifacts: { report: 0, doc: 0, roadmap: 0 },
78
+ pending_triggers: 0,
79
+ paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
80
+ follow_up: '',
81
+ };
82
+ }
83
+ const IDENTITY_PALETTE = {
84
+ accent: (s) => s,
85
+ active: (s) => s,
86
+ info: (s) => s,
87
+ muted: (s) => s,
88
+ faint: (s) => s,
89
+ border: (s) => s,
90
+ bold: (s) => s,
91
+ error: (s) => s,
92
+ warning: (s) => s,
93
+ bashMode: (s) => s,
94
+ bashModeAlt: (s) => s,
95
+ surface: (s) => s,
96
+ };
97
+ /** A fake `TUI` carrying just what `GraphOverlay` touches: `showOverlay` (a
98
+ * no-op handle) and `requestRender`. GraphOverlay's key handlers only shell
99
+ * `crtr` (via execFile) and toggle local overlay/handle state — they never
100
+ * reach into the real TUI beyond these two calls. */
101
+ function fakeTui() {
102
+ let renders = 0;
103
+ const tui = {
104
+ showOverlay: () => ({
105
+ hide: () => { },
106
+ setHidden: () => { },
107
+ isHidden: () => false,
108
+ focus: () => { },
109
+ unfocus: () => { },
110
+ isFocused: () => true,
111
+ }),
112
+ requestRender: () => { renders++; },
113
+ };
114
+ return { tui, renderCount: () => renders };
115
+ }
116
+ let home;
117
+ let localCwd;
118
+ before(() => {
119
+ home = mkdtempSync(join(tmpdir(), 'crtr-attach-chrome-remote-'));
120
+ localCwd = mkdtempSync(join(tmpdir(), 'crtr-attach-chrome-remote-cwd-'));
121
+ process.env['CRTR_HOME'] = home;
122
+ });
123
+ beforeEach(() => {
124
+ closeDb();
125
+ rmSync(home, { recursive: true, force: true });
126
+ });
127
+ after(() => {
128
+ closeDb();
129
+ rmSync(home, { recursive: true, force: true });
130
+ rmSync(localCwd, { recursive: true, force: true });
131
+ delete process.env['CRTR_HOME'];
132
+ });
133
+ /** Seed conflicting local state for node id `a` — every local read the
134
+ * attach-chrome remote suppression is supposed to bypass. */
135
+ function seedPoisonedLocalState() {
136
+ createNode(meta('a', { cwd: localCwd }));
137
+ // A local focus — poisons focusedNodeIds()/isAttached's fast path.
138
+ openFocusRow('poison-focus', null, null, 'a');
139
+ // A broker attach.json — poisons isAttached's slow path.
140
+ writeFileSync(join(jobDir('a'), 'attach.json'), JSON.stringify({ viewers: 1 }));
141
+ // An active, manually-retried fault — poisons nodeGlyph's hanging overlay.
142
+ recordFault('a', {
143
+ link: 'viewer↔broker',
144
+ op: 'poison',
145
+ kind: 'other',
146
+ retry: { disposition: 'manual' },
147
+ message: 'POISON FAULT — local read leaked into remote attach chrome',
148
+ });
149
+ // Local telemetry — poisons tokensCell + activityCell.
150
+ writeFileSync(join(jobDir('a'), 'telemetry.json'), JSON.stringify({
151
+ context_tokens: 999_999,
152
+ last_activity: 'POISON ACTIVITY — local read leaked into remote attach chrome',
153
+ }));
154
+ }
155
+ test('buildCanvasPanelLines suppresses every local-read adornment for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
156
+ seedPoisonedLocalState();
157
+ const rootMeta = meta('root');
158
+ const aMeta = meta('a'); // the REMOTE node's meta — unrelated to the poisoned local NodeMeta above
159
+ const server = await startMockCrtrServer((args) => {
160
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
161
+ const id = args[4];
162
+ if (id === 'root')
163
+ return { body: show(rootMeta, ['a'], []) };
164
+ if (id === 'a')
165
+ return { body: show(aMeta, [], ['root']) };
166
+ }
167
+ return { status: 500, body: {} };
168
+ });
169
+ const source = new RemoteCanvasSource(server.url, 'tok');
170
+ // 'root' has 'a' as a live report → row('a') renders into `reports`.
171
+ const { reports } = await buildCanvasPanelLines('root', {}, IDENTITY_PALETTE, source);
172
+ assert.equal(reports.length, 1);
173
+ const line = reports[0];
174
+ assert.ok(!line.includes('1000k'), `local telemetry (999999 tokens) must not leak into a remote row: ${line}`);
175
+ assert.ok(!line.includes('POISON'), `poisoned local text must never appear in a remote row: ${line}`);
176
+ assert.ok(!line.includes('⚠'), `a local active fault must not surface as the hanging glyph on a remote row: ${line}`);
177
+ assert.ok(!line.includes(BG_ATTACHED), `a local focus/attach must not paint a remote row as attached: ${line}`);
178
+ await server.close();
179
+ });
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 () => {
181
+ seedPoisonedLocalState();
182
+ const rootMeta = meta('root');
183
+ const aMeta = meta('a');
184
+ const server = await startMockCrtrServer((args) => {
185
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
186
+ const id = args[4];
187
+ if (id === 'root')
188
+ return { body: show(rootMeta, ['a'], []) };
189
+ if (id === 'a')
190
+ return { body: show(aMeta, [], ['root']) };
191
+ }
192
+ return { status: 500, body: {} };
193
+ });
194
+ const source = new RemoteCanvasSource(server.url, 'tok');
195
+ const { tui } = fakeTui();
196
+ const overlay = new GraphOverlay(tui, 'root', () => ({}), IDENTITY_PALETTE, source);
197
+ overlay.open();
198
+ // buildSnapshot() is async (chased through the mock server); poll for it to land.
199
+ for (let i = 0; i < 100 && overlay.snapshot === undefined; i++) {
200
+ await new Promise((r) => setImmediate(r));
201
+ }
202
+ assert.notEqual(overlay.snapshot, undefined, 'snapshot never built');
203
+ const lines = overlay.render(100).join('\n');
204
+ assert.ok(!lines.includes('1000k'), `local telemetry must not leak into the remote graph overlay: ${lines}`);
205
+ assert.ok(!lines.includes('POISON'), `poisoned local text must never appear in the remote graph overlay: ${lines}`);
206
+ assert.ok(!lines.includes('⚠'), `a local active fault must not surface as the hanging glyph in the remote graph overlay: ${lines}`);
207
+ assert.ok(!lines.includes(BG_ATTACHED), `a local focus/attach must not paint a remote row as attached in the graph overlay: ${lines}`);
208
+ // Footer hint must drop the focus/kill/mgr hints in remote mode.
209
+ assert.ok(!lines.includes('kill'), `remote overlay footer must not advertise kill: ${lines}`);
210
+ assert.ok(!lines.includes('focus') && !lines.includes('mgr'), `remote overlay footer must not advertise focus/mgr: ${lines}`);
211
+ // Enter must NOT close the overlay (a local `close()` is the observable
212
+ // effect of a real `focusTarget` dispatch) — it must no-op instead.
213
+ overlay.handleInput('\r');
214
+ assert.equal(overlay.isOpen(), true, 'Enter must not act (close) on a remote graph row');
215
+ // x must NOT arm the y/n kill confirm — render() would show it in the hint.
216
+ overlay.handleInput('x');
217
+ const afterX = overlay.render(100).join('\n');
218
+ assert.ok(!afterX.includes('y/n'), `x must not arm a kill confirm on a remote graph row: ${afterX}`);
219
+ // m must NOT close the overlay either (it would focusTarget the manager).
220
+ overlay.handleInput('m');
221
+ assert.equal(overlay.isOpen(), true, 'm must not act (close) on a remote graph row');
222
+ await server.close();
223
+ });
224
+ // ---------------------------------------------------------------------------
225
+ // Phase 3 review 2 — the ENCLOSING attach viewer (slash commands, pane tag,
226
+ // detach-reap, Alt+M mode-switch), not just the GraphOverlay/panel rendering
227
+ // the fix above already covers. Same adversarial shape: seed colliding LOCAL
228
+ // state for the remote node id `a`, then assert the remote path never touches
229
+ // it — and, symmetrically, that the LOCAL path is byte-for-byte unchanged
230
+ // (every guard below defaults to acting exactly as before when `remote` is
231
+ // `false`).
232
+ // ---------------------------------------------------------------------------
233
+ function fakeSlashContext(remote, nodeId) {
234
+ const notices = [];
235
+ const sent = [];
236
+ const ctx = {
237
+ send: (frame) => sent.push(frame),
238
+ notify: (m) => notices.push(m),
239
+ cwd: '/tmp/does-not-matter',
240
+ // Explicit empty string (not `undefined`) so promoteNode/openContext's
241
+ // `ctx.nodeId ?? process.env['CRTR_NODE_ID']` fallback can NEVER pick up
242
+ // this test process's own real CRTR_NODE_ID (this test runs INSIDE a live
243
+ // crtr node) — the local-mode assertions below must reach the real
244
+ // no-node-id early return, never an accidental real `crtr node promote`
245
+ // against ourselves.
246
+ nodeId: '',
247
+ remote,
248
+ };
249
+ return { ctx, notices, sent };
250
+ }
251
+ test('slash autocomplete structurally omits the local-only native canvas commands (/promote, /resume-node, /context) in remote mode, and keeps them in local mode', () => {
252
+ const remoteNames = slashCommandList(undefined, true).map((c) => c.name);
253
+ assert.ok(!remoteNames.includes('promote'), `remote autocomplete must not offer /promote: ${remoteNames.join(',')}`);
254
+ assert.ok(!remoteNames.includes('resume-node'), `remote autocomplete must not offer /resume-node: ${remoteNames.join(',')}`);
255
+ assert.ok(!remoteNames.includes('context'), `remote autocomplete must not offer /context: ${remoteNames.join(',')}`);
256
+ // Remote-safe canvas commands stay offered.
257
+ assert.ok(remoteNames.includes('graph'), 'remote autocomplete must still offer /graph (already remote-safe)');
258
+ assert.ok(remoteNames.includes('view'), 'remote autocomplete must still offer /view (self-contained popup)');
259
+ // Local (remote=false, the default) is unchanged — all five canvas commands.
260
+ const localNames = slashCommandList().map((c) => c.name);
261
+ for (const name of ['graph', 'promote', 'resume-node', 'view', 'context']) {
262
+ assert.ok(localNames.includes(name), `local autocomplete must still offer /${name}: ${localNames.join(',')}`);
263
+ }
264
+ });
265
+ test('dispatchSlashCommand no-ops /promote, /resume-node, /context in remote mode instead of shelling a local crtr command', () => {
266
+ const { ctx, notices, sent } = fakeSlashContext(true, 'a');
267
+ for (const cmd of ['/promote', '/resume-node', '/context', '/promote orchestrator']) {
268
+ notices.length = 0;
269
+ const handled = dispatchSlashCommand(cmd, ctx);
270
+ assert.equal(handled, true, `${cmd} must be handled (native, never forwarded to the engine as a prompt)`);
271
+ assert.equal(notices.length, 1, `${cmd} must notify exactly once (the remote-unsafe notice), got: ${JSON.stringify(notices)}`);
272
+ assert.match(notices[0], /not available for a remote attach/, `${cmd} must notify the remote-unsafe message, not proceed into its local handler: ${notices[0]}`);
273
+ }
274
+ assert.equal(sent.length, 0, 'no command frame must ever be sent for a gated remote canvas command');
275
+ // /graph and /view are UNaffected by the remote gate (graph is already
276
+ // remote-safe via GraphOverlay's own `remote` handling; view is self-
277
+ // contained). Neither reaches the remote-unsafe notice.
278
+ notices.length = 0;
279
+ ctx.onGraph = () => notices.push('graph-toggled');
280
+ dispatchSlashCommand('/graph', ctx);
281
+ assert.deepEqual(notices, ['graph-toggled'], '/graph must still work normally in remote mode');
282
+ });
283
+ test('dispatchSlashCommand leaves the LOCAL (non-remote) path for /promote, /resume-node, /context byte-for-byte unchanged', () => {
284
+ const { ctx, notices } = fakeSlashContext(false, '');
285
+ // No TMUX in this test process (a headless `node --test` run), so
286
+ // /resume-node and /context hit their OWN "needs tmux" notices — proving
287
+ // dispatch reached their real local handlers, not our new remote gate.
288
+ const savedTmux = process.env['TMUX'];
289
+ const savedTmuxPane = process.env['TMUX_PANE'];
290
+ delete process.env['TMUX'];
291
+ delete process.env['TMUX_PANE'];
292
+ try {
293
+ notices.length = 0;
294
+ dispatchSlashCommand('/promote', ctx);
295
+ assert.deepEqual(notices, ['/promote: no node to promote (viewer has no node id)']);
296
+ notices.length = 0;
297
+ dispatchSlashCommand('/resume-node', ctx);
298
+ assert.deepEqual(notices, ['/resume-node needs tmux']);
299
+ notices.length = 0;
300
+ dispatchSlashCommand('/context', ctx);
301
+ assert.deepEqual(notices, ['/context needs tmux']);
302
+ }
303
+ finally {
304
+ if (savedTmux !== undefined)
305
+ process.env['TMUX'] = savedTmux;
306
+ else
307
+ delete process.env['TMUX'];
308
+ if (savedTmuxPane !== undefined)
309
+ process.env['TMUX_PANE'] = savedTmuxPane;
310
+ else
311
+ delete process.env['TMUX_PANE'];
312
+ }
313
+ });
314
+ test('resolveInitialMode/requestModeSwitch never touch the local mode-switch read/write for a remote attach, and are unchanged for local', () => {
315
+ let reads = 0;
316
+ const readMode = (_cwd, _nodeId) => {
317
+ reads++;
318
+ return 'plan'; // poisoned value — must never surface for remote
319
+ };
320
+ assert.equal(resolveInitialMode('/tmp/cwd', 'a', true, readMode), 'normal', 'remote must always start normal');
321
+ assert.equal(reads, 0, 'remote must never read the local mode-switch current file');
322
+ assert.equal(resolveInitialMode('/tmp/cwd', 'a', false, readMode), 'plan', 'local must read through unchanged');
323
+ assert.equal(reads, 1, 'local must call the injected local reader exactly once');
324
+ let writes = 0;
325
+ let lastWrite;
326
+ const writeRequest = (cwd, nodeId, mode) => {
327
+ writes++;
328
+ lastWrite = { cwd, nodeId, mode };
329
+ };
330
+ const remoteOk = requestModeSwitch('/tmp/cwd', 'a', true, 'spec', writeRequest);
331
+ assert.equal(remoteOk, false, 'remote Alt+M must no-op (return false)');
332
+ assert.equal(writes, 0, 'remote must never write the local mode-switch request file');
333
+ const localOk = requestModeSwitch('/tmp/cwd', 'a', false, 'spec', writeRequest);
334
+ assert.equal(localOk, true, 'local Alt+M must still write through');
335
+ assert.equal(writes, 1, 'local must call the injected local writer exactly once');
336
+ assert.deepEqual(lastWrite, { cwd: '/tmp/cwd', nodeId: 'a', mode: 'spec' });
337
+ });
338
+ test('tagAttachPane/untagAttachPane never tag/untag the pane for a remote attach, and are unchanged for local', () => {
339
+ let calls = [];
340
+ const setOpt = (pane, name, value) => {
341
+ calls.push([pane, name, value]);
342
+ };
343
+ calls = [];
344
+ tagAttachPane(true, '%3', 'a', setOpt);
345
+ assert.deepEqual(calls, [], 'remote must never tag the pane with @crtr_node');
346
+ calls = [];
347
+ tagAttachPane(false, '%3', 'a', setOpt);
348
+ assert.deepEqual(calls, [['%3', '@crtr_node', 'a']], 'local tag must still fire unchanged');
349
+ calls = [];
350
+ untagAttachPane(true, '%3', setOpt);
351
+ assert.deepEqual(calls, [], 'remote must never untag the pane');
352
+ calls = [];
353
+ untagAttachPane(false, '%3', setOpt);
354
+ assert.deepEqual(calls, [['%3', '@crtr_node', '']], 'local untag must still fire unchanged');
355
+ // No pane resolved (outside tmux) — unaffected either way.
356
+ calls = [];
357
+ tagAttachPane(false, undefined, 'a', setOpt);
358
+ assert.deepEqual(calls, [], 'no pane to tag when outside tmux');
359
+ });
360
+ test('reapOnDetach never reaps a local node for a remote attach even on an id collision, and is unchanged for local', () => {
361
+ let reaped = [];
362
+ const reap = (nodeId) => {
363
+ reaped.push(nodeId);
364
+ };
365
+ reaped = [];
366
+ reapOnDetach(true, 'a', reap);
367
+ assert.deepEqual(reaped, [], 'remote detach must never reap a local node, even one colliding with the remote id');
368
+ reaped = [];
369
+ reapOnDetach(false, 'a', reap);
370
+ assert.deepEqual(reaped, ['a'], 'local detach-reap must still fire unchanged');
371
+ });