@north-light/crouter 0.3.162 → 0.3.163

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 (95) hide show
  1. package/dist/builtin-memory/00-runtime-base.md +1 -0
  2. package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
  3. package/dist/builtin-memory/init.md +38 -0
  4. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +34 -2
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +14 -3
  8. package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
  9. package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
  10. package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
  11. package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
  12. package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
  13. package/dist/clients/attach/render/chat-view.js +8 -3
  14. package/dist/clients/attach/render/crtr-output.js +31 -2
  15. package/dist/clients/attach/render/edit-diff.js +26 -2
  16. package/dist/clients/attach/render/tool-calls.d.ts +7 -2
  17. package/dist/clients/attach/render/tool-calls.js +25 -4
  18. package/dist/clients/attach/session/connection.d.ts +1 -1
  19. package/dist/clients/attach/session/editor-frame.d.ts +5 -1
  20. package/dist/clients/attach/session/editor-frame.js +21 -15
  21. package/dist/clients/attach/session/identity.d.ts +1 -1
  22. package/dist/clients/attach/viewer.js +621 -619
  23. package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
  24. package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
  25. package/dist/commands/__tests__/human.test.js +2 -0
  26. package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
  27. package/dist/commands/__tests__/search-contents.test.js +52 -0
  28. package/dist/commands/api-client.js +1 -0
  29. package/dist/commands/human/prompts.js +2 -2
  30. package/dist/commands/human/shared.d.ts +1 -0
  31. package/dist/commands/human/shared.js +7 -4
  32. package/dist/commands/memory/lint.d.ts +6 -0
  33. package/dist/commands/memory/lint.js +177 -35
  34. package/dist/commands/memory/read.js +22 -1
  35. package/dist/commands/memory/write.js +18 -4
  36. package/dist/commands/memory.js +1 -1
  37. package/dist/commands/search/contents.js +2 -2
  38. package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
  39. package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
  40. package/dist/commands/sys/setup-wizard.d.ts +8 -1
  41. package/dist/commands/sys/setup-wizard.js +209 -58
  42. package/dist/commands/sys/sync-deps.js +14 -18
  43. package/dist/commands/sys/sync-project-guidance.js +16 -19
  44. package/dist/core/__tests__/migration.test.js +8 -3
  45. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
  46. package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
  47. package/dist/core/__tests__/on-read-identity.test.js +8 -15
  48. package/dist/core/__tests__/revive.test.js +2 -2
  49. package/dist/core/__tests__/tmux-surface.test.js +10 -4
  50. package/dist/core/__tests__/worktree.test.js +3 -3
  51. package/dist/core/canvas/extensions.d.ts +1 -1
  52. package/dist/core/canvas/extensions.js +18 -11
  53. package/dist/core/canvas/labels.d.ts +4 -5
  54. package/dist/core/canvas/labels.js +9 -9
  55. package/dist/core/command-manifests/schema.js +18 -5
  56. package/dist/core/command.js +17 -7
  57. package/dist/core/configured-clis/invoker.js +2 -0
  58. package/dist/core/help.d.ts +3 -1
  59. package/dist/core/help.js +5 -2
  60. package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
  61. package/dist/core/keybindings/catalog.d.ts +3 -2
  62. package/dist/core/keybindings/catalog.js +34 -25
  63. package/dist/core/keybindings/index.d.ts +1 -1
  64. package/dist/core/keybindings/resolve.js +8 -0
  65. package/dist/core/keybindings/types.d.ts +6 -1
  66. package/dist/core/memory/doc-link-grammar.d.ts +20 -0
  67. package/dist/core/memory/doc-link-grammar.js +110 -0
  68. package/dist/core/memory-resolver.d.ts +5 -0
  69. package/dist/core/memory-resolver.js +12 -1
  70. package/dist/core/runtime/bearings.d.ts +8 -8
  71. package/dist/core/runtime/bearings.js +20 -16
  72. package/dist/core/runtime/broker.js +3 -13
  73. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  74. package/dist/core/runtime/canvas-extensions.js +2 -0
  75. package/dist/core/runtime/launch.d.ts +1 -1
  76. package/dist/core/runtime/launch.js +1 -1
  77. package/dist/core/runtime/tmux.js +136 -100
  78. package/dist/core/scope.js +4 -5
  79. package/dist/core/substrate/ceiling.d.ts +3 -6
  80. package/dist/core/substrate/ceiling.js +13 -15
  81. package/dist/core/substrate/index.d.ts +2 -3
  82. package/dist/core/substrate/index.js +2 -2
  83. package/dist/core/substrate/on-read.d.ts +4 -13
  84. package/dist/core/substrate/on-read.js +142 -262
  85. package/dist/core/substrate/render.js +4 -4
  86. package/dist/core/substrate/schema.d.ts +3 -2
  87. package/dist/core/substrate/schema.js +5 -5
  88. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
  89. package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
  90. package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
  91. package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
  92. package/dist/pi-extensions/canvas-tool-guide.js +74 -0
  93. package/package.json +1 -1
  94. package/runtime.lock.json +2 -2
  95. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
@@ -9,6 +9,7 @@ rationale: >-
9
9
  "Say what actually happens" exists because an approval request called a root a person had created an "attended root" — an invented category with no referent in the product, which forced Silas to halt the decision and ask what the term meant (2026-07-28). Agents coin taxonomies to compress a distinction; the reader pays by decoding a word that names nothing real.
10
10
 
11
11
  "Waiting is a way to end a turn" lived in its own ungated all-node doc until 2026-07-28. Same gate, same audience, never independently readable — so the split bought no routing and cost a stub cross-reference in this file pointing at a section spliced a few hundred tokens later. Split it back out only if it ever needs a gate of its own.
12
+ lint-ignore: length
12
13
  ---
13
14
 
14
15
  You are a **node** in a live agent graph (the crtr canvas). This section is your operating protocol — it is true for every node regardless of role.
@@ -6,6 +6,7 @@ file-read-visibility: none
6
6
  gate: {mode: orchestrator}
7
7
  rationale: >-
8
8
  Two observed orchestration failures set this kernel's stopping rules. A sole-writer feature lane produced a 5-deep 1:1 developer/orchestrator chain by repeatedly delegating the whole assignment; separately, the kernel's “idle capacity,” “maximum agents,” and “when in doubt, more rigor” objective helped produce review-only subtrees as large as 87 nodes and five levels deep. Coordination must optimize new evidence toward the goal rather than node count or process length.
9
+ lint-ignore: length
9
10
  ---
10
11
 
11
12
  ## You are an orchestrator
@@ -0,0 +1,38 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When bootstrapping a project's memory store — the user invoked /init or a workspace has no .crouter/memory/INDEX.md front door — this knowledge should be read because a lean routed front door gives every future agent in that workspace its operating contract for a fraction of the standing context cost an unstructured guidance dump would charge each session forever.
4
+ short-form: "Bootstrap a project memory store: survey the repo, then author a lean INDEX front door plus a linked dev doc — commands, map, non-default rules, pointers — and lint. Depth splits into linked docs; every always-loaded line must earn its keep."
5
+ system-prompt-visibility: none
6
+ file-read-visibility: none
7
+ slash: true
8
+ rationale: Unguided agents bootstrap project memory badly in both directions — either no front door at all, or a monolithic CLAUDE.md-style manual (copied README, generic framework advice, exhaustive command catalogs) that taxes every future session and decays. Distilled 2026-07-31 from ecosystem research across Anthropic/OpenAI/Cursor/GitHub guidance and production AGENTS.md files — the strongest evidence being Sentry's consolidation postmortem (critical commands buried in nested files got missed) and Anthropic's under-200-line target with adherence falling as always-loaded files grow.
9
+ ---
10
+
11
+ # /init — bootstrap this project's memory store
12
+
13
+ Give this workspace its memory front door: a root INDEX every future agent boots with when the project mounts, plus a small graph of linked docs carrying the depth. The front door is an operating contract and routing layer, never a manual — every line it carries is paid by every session here forever, so a line earns its place only by changing behavior: a fact every task needs, a non-obvious hazard, or a rule an agent would otherwise get wrong.
14
+
15
+ ## 1. Survey before writing
16
+
17
+ Learn the project from its own artifacts: README, package manifests and lockfiles, build/CI config, scripts, existing CLAUDE.md/AGENTS.md or docs. Extract the real commands (setup, run, build, targeted test, full verification) and RUN the important ones — record only commands you have seen work, with any required ordering, environment, or package-manager constraint. Note the architecture in a few sentences: major components, dependency direction, where generated or externally-owned code lives. Collect the conventions that differ from what a competent agent would assume — those deviations are the highest-value lines you will write.
18
+
19
+ ## 2. Read the contract, then author the front door
20
+
21
+ Run `crtr memory write -h` first — it carries the substrate frontmatter contract and the exact root-INDEX shape, including the workspace-mount route that `crtr memory lint` validates. Then author the INDEX with these sections, total well under the length lint's cap:
22
+
23
+ - **Project in one paragraph** — purpose, major components, dependency direction.
24
+ - **Common commands** — the 3–8 commands most changes need, copyable, verified.
25
+ - **Where to work** — a task-to-path map; mark generated or externally-owned areas.
26
+ - **Non-default rules and gotchas** — only high-impact deviations from defaults. Skip anything a formatter, linter, or typechecker already enforces; point at the check instead.
27
+ - **Done** — the minimum credible gate for an ordinary change.
28
+ - **Pointers** — a `[[link]]` per deeper doc with a one-clause read trigger (e.g. "debugging the daemon → the dev doc").
29
+
30
+ Exclude generic language/framework advice, copied docs or style guides, volatile version detail, rare edge cases, and multi-step procedures — depth belongs behind links, not in the standing load.
31
+
32
+ ## 3. Split depth into linked docs
33
+
34
+ Author `dev` alongside the INDEX by default: validated setup/run/build/test/debug commands with required order and environment, targeted-vs-full verification, and common failure modes. Add `architecture` only when the tree cannot be understood from the INDEX's brief map, and a conventions/gotchas doc only once real project-specific rules outgrow a few INDEX bullets — never pre-populate generic security or style manuals. These reference docs take `none` visibility on both axes; the INDEX's links are how they are found, so they cost nothing until followed.
35
+
36
+ ## 4. Validate
37
+
38
+ Run `crtr memory lint` — it strict-parses the store, checks the INDEX front-door contract, and fails on dangling links or over-length bodies. Fix findings rather than suppressing them. The store is a living contract: when commands, architecture, or conventions change, rewrite the affected doc in place and delete superseded guidance — an always-loaded line that has stopped being true is worse than no line at all.
@@ -2,8 +2,8 @@
2
2
  kind: knowledge
3
3
  when-and-why-to-read: When a node is marked wedged or remains active without progress while its inbox accumulates, this reference should be read so recovery restores progress without losing context or killing the wrong process.
4
4
  short-form: a child stuck mid-turn (runaway bash, or a stalled broker with no subprocess) is AUTO-DETECTED by the daemon (⚠ wedged + a doctrine wake) — kill the subprocess if one exists; if none, the daemon SIGTERM-kicks the broker and automatically starts a fresh recovery cycle
5
- system-prompt-visibility: none
6
- file-read-visibility: preview
5
+ system-prompt-visibility: preview
6
+ file-read-visibility: none
7
7
  ---
8
8
 
9
9
  A base node can wedge INDEFINITELY on a single runaway bash command (classic: `grep -rln "..." /` — a recursive grep from filesystem root that scans all of disk and never returns; also any unbounded find/scan over `/` or a network mount). The node's pi turn is blocked waiting on the bash call, so it never `push`es and never finishes.
@@ -32,7 +32,6 @@ directory's `node_modules/`.
32
32
  | Extension | What it does |
33
33
  |-----------|--------------|
34
34
  | `claude-plugin-commands.ts` | Surfaces commands and skills from `~/.claude`, the cwd, every project in the selected crouter profile, and installed plugins as pi slash commands with arg/shell-block execution. Writes shims to `~/.pi/agent/.plugin-command-shims/`. Skips any name pi already serves natively from `~/.pi/agent/prompts/` (e.g. crtr exports a slash command to both `~/.claude/commands` and `~/.pi/agent/prompts`), so the shim never collides with the native prompt. |
35
- | `crouter-help.ts` | Help integration for the `crtr` CLI. |
36
35
  | `provider-rotation.ts` | Subscription credential rotation across Anthropic / OpenAI-Codex: does its own OAuth login/refresh, rotates on rate-limits, falls back across the model ladder. Registers `/provider-sub <provider> <list\|add\|select\|rm>`. |
37
36
  | `crtr-commands/` | Auto-generates a slash command per `crtr` CLI node. The tree is derived in-process from crtr's own `buildRoot()` (no subprocesses, no cache on disk); `filters.json` controls which nodes are exposed. |
38
37
  | `sysprompt-window.ts` | Registers `/sysprompt`, which runs `crtr sys sysprompt --window` without injecting the prompt into context. |
@@ -965,6 +965,7 @@ function makeRotationCtx(modelIntent?: string) {
965
965
  },
966
966
  } as any;
967
967
  const setModelCalls: any[] = [];
968
+ const thinkingLevelCalls: string[] = [];
968
969
  const providers: Record<string, { streamSimple: (...args: any[]) => AsyncIterable<any> }> = {};
969
970
  const events: Record<string, (...args: any[]) => unknown> = {};
970
971
  const commands: Record<string, { handler: (args: string, ctx: any) => unknown }> = {};
@@ -992,11 +993,42 @@ function makeRotationCtx(modelIntent?: string) {
992
993
  ctx.model = model;
993
994
  return true;
994
995
  },
995
- setThinkingLevel(level: string) { ctx.thinkingLevel = level; },
996
+ setThinkingLevel(level: string) {
997
+ thinkingLevelCalls.push(level);
998
+ ctx.thinkingLevel = level;
999
+ },
996
1000
  } as any;
997
- return { ctx, setModelCalls, providers, events, commands, pi };
1001
+ return { ctx, setModelCalls, thinkingLevelCalls, providers, events, commands, pi };
998
1002
  }
999
1003
 
1004
+ // A live route carries the session's exact thinking level through every provider
1005
+ // request, including the requests that continue a turn after tool results.
1006
+ test("preserves a manually selected thinking level across provider requests", async () => {
1007
+ await resetRotationState();
1008
+ writeLadderConfig();
1009
+ await helpers.writeSubscriptionPool("openai-codex", [credential("codex")]);
1010
+
1011
+ const { ctx, thinkingLevelCalls, providers, events, pi } = makeRotationCtx("openai/strong");
1012
+ ctx.thinkingLevel = "max";
1013
+ rotationModule.__setStreamForProviderForTest(() => (async function* () {
1014
+ yield { type: "text_delta", text: "continued after tool result" };
1015
+ })() as any);
1016
+
1017
+ try {
1018
+ await providerRotationExtension(pi);
1019
+ await events.session_start?.({}, ctx);
1020
+ const stream = providers["openai-codex"].streamSimple(ctx.model, ctx);
1021
+ for await (const _event of stream) {
1022
+ // Drain the managed-provider request to completion.
1023
+ }
1024
+
1025
+ assert.deepEqual(thinkingLevelCalls, ["max"]);
1026
+ assert.equal(ctx.thinkingLevel, "max");
1027
+ } finally {
1028
+ rotationModule.__setStreamForProviderForTest(undefined);
1029
+ }
1030
+ });
1031
+
1000
1032
  // Codex exhaustion switches the model and serves the same turn on the Anthropic fallback.
1001
1033
  test("falls back transparently and serves the turn on Anthropic when Codex is exhausted", async () => {
1002
1034
  await resetRotationState();
@@ -512,6 +512,8 @@ function emitTerminalError(stream: AssistantMessageEventStream, model: Model<any
512
512
  // turn-local cooling state.
513
513
  type RouteCandidate = {
514
514
  routeId: string;
515
+ credentialSource: string;
516
+ rank: number;
515
517
  providerId: string;
516
518
  model: Model<any>;
517
519
  thinkingLevel: string | undefined;
@@ -549,6 +551,12 @@ async function switchToRoute(pi: ExtensionAPI, ctx: ExtensionContext, candidate:
549
551
  return ctx.model?.provider === candidate.providerId && ctx.model.id === candidate.model.id;
550
552
  }
551
553
 
554
+ function logRouteQueueAdvance(from: RouteCandidate, to: RouteCandidate | undefined, result: AttemptResult): void {
555
+ logRotation(
556
+ `from=${from.providerId}/${from.model.id} from_route_id=${from.routeId} from_credential_source=${from.credentialSource} from_rank=${from.rank} to=${to ? `${to.providerId}/${to.model.id}` : "none"} to_route_id=${to?.routeId ?? "none"} to_credential_source=${to?.credentialSource ?? "none"} to_rank=${to?.rank ?? "none"} reason=${result.classification?.reason ?? result.result}; advancing route queue`,
557
+ );
558
+ }
559
+
552
560
  // Drive the deterministic route queue from the live rank. Managed routes walk
553
561
  // their pool rows; every other declared route is one configured credential.
554
562
  async function runManagedProvider(
@@ -570,7 +578,7 @@ async function runManagedProvider(
570
578
  if (!registry || !routeModel) return undefined;
571
579
  const availability = probeRouteAvailability(route, registry);
572
580
  if (!availability.registered || (!isManagedProvider(route.providerId) && !availability.authenticated)) return undefined;
573
- return { routeId: route.routeId, providerId: route.providerId, model: routeModel, thinkingLevel: route.thinkingLevel };
581
+ return { routeId: route.routeId, credentialSource: route.credentialSource, rank: route.rank, providerId: route.providerId, model: routeModel, thinkingLevel: route.thinkingLevel };
574
582
  })
575
583
  .filter((candidate): candidate is RouteCandidate => candidate !== undefined);
576
584
  const isLiveRoute = (candidate: RouteCandidate): boolean =>
@@ -579,7 +587,7 @@ async function runManagedProvider(
579
587
  // later re-enter when a credential becomes ready, but it is never selected now.
580
588
  const candidates = routeCandidates.filter((candidate) => isLiveRoute(candidate) || !isManagedProvider(candidate.providerId) || readSubscriptionPool(candidate.providerId).some((entry) => entry.authFailure === undefined && (entry.rateLimitedUntil || 0) <= Date.now()));
581
589
  if (!candidates.some(isLiveRoute)) {
582
- candidates.unshift({ routeId: `live:${model.provider}/${model.id}`, providerId: model.provider, model, thinkingLevel: undefined });
590
+ candidates.unshift({ routeId: `live:${model.provider}/${model.id}`, credentialSource: 'live', rank: liveRank, providerId: model.provider, model, thinkingLevel: liveThinking });
583
591
  }
584
592
 
585
593
  const queuedRouteIds = new Set(candidates.map((candidate) => candidate.routeId));
@@ -609,7 +617,7 @@ async function runManagedProvider(
609
617
  if (result.result === 'fatal') { stream.end(); return; }
610
618
  const cooldownMs = result.result === 'rate_limited' ? result.retryAfterMs ?? DEFAULT_RATE_LIMIT_BACKOFF_MS : TRANSIENT_ROTATE_COOLDOWN_MS;
611
619
  localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
612
- logRotation(`${routeId} ${result.result === 'rate_limited' ? 'rate-limited' : 'transient failure'}; advancing route queue`);
620
+ logRouteQueueAdvance(candidate, candidates[0], result);
613
621
  continue;
614
622
  }
615
623
 
@@ -620,6 +628,7 @@ async function runManagedProvider(
620
628
  for (const credential of available) seenLabels.add(credential.label);
621
629
  seenReadyLabels.set(routeId, seenLabels);
622
630
 
631
+ let advanceResult: AttemptResult | undefined;
623
632
  for (const credential of available) {
624
633
  let freshCredential: ResolvedCredential;
625
634
  try {
@@ -654,9 +663,11 @@ async function runManagedProvider(
654
663
  } else {
655
664
  localCoolingDeadlines.set(routeId, Math.max(localCoolingDeadlines.get(routeId) ?? 0, Date.now() + cooldownMs));
656
665
  }
666
+ advanceResult = result;
657
667
  }
658
668
 
659
669
  enqueueFreshRouteCandidates(candidates, queuedRouteIds, knownRoutes, seenReadyLabels, cooldownAttempts, localCoolingDeadlines);
670
+ if (advanceResult !== undefined) logRouteQueueAdvance(candidate, candidates[0], advanceResult);
660
671
  }
661
672
 
662
673
  const coolingDeadline = earliestCoolingDeadline([...knownRoutes.values()], localCoolingDeadlines);
@@ -16,6 +16,11 @@ const plainTheme = {
16
16
  bg(_name, text) { return text; },
17
17
  bold(text) { return text; },
18
18
  };
19
+ /** pi calls tool renderers with a shared state bag; a normal (unfolded) call has an empty one. */
20
+ function renderCrtrCall(definition, args) {
21
+ const definitionWithContext = definition;
22
+ return definitionWithContext.renderCall(args, theme, { state: {} });
23
+ }
19
24
  function stripAnsi(text) {
20
25
  return text.replace(/\x1b\[[0-9;]*m/g, '');
21
26
  }
@@ -31,7 +36,7 @@ test('returns a viewer tool definition only for crtr bash commands', () => {
31
36
  assert.equal(createCrtrBashToolDefinition({ command: 'echo hi' }), undefined);
32
37
  const def = createCrtrBashToolDefinition({ command: 'crtr canvas dashboard' });
33
38
  assert.equal(typeof def.renderCall, 'function');
34
- const rendered = def.renderCall({ command: 'crtr canvas dashboard' }, theme).render(80).join('\n');
39
+ const rendered = renderCrtrCall(def, { command: 'crtr canvas dashboard' }).render(80).join('\n');
35
40
  assert.equal(rendered.trimEnd().split('\n').length, 1);
36
41
  assert.match(rendered, new RegExp(iconForPath(['canvas', 'dashboard'])));
37
42
  assert.match(rendered, /crtr/);
@@ -165,7 +170,7 @@ test('a branch help preview with more subcommands than the line cap truncates wi
165
170
  // displayed crtr path and help detection include only the command segment.
166
171
  test('a shell redirect on the outer bash call never leaks into the displayed crtr path or help detection', () => {
167
172
  const def = createCrtrBashToolDefinition({ command: 'crtr push -h 2>&1' });
168
- const rendered = def.renderCall({ command: 'crtr push -h 2>&1' }, theme).render(80).join('\n');
173
+ const rendered = renderCrtrCall(def, { command: 'crtr push -h 2>&1' }).render(80).join('\n');
169
174
  assert.match(rendered, new RegExp(HELP_ICON));
170
175
  assert.match(rendered, /crtr help/);
171
176
  assert.match(rendered, /<b>push<\/b>/);
@@ -175,18 +180,18 @@ test('a shell redirect on the outer bash call never leaks into the displayed crt
175
180
  // Other redirect shapes on the outer call, and mid-argv redirects, must
176
181
  // never survive into the path either.
177
182
  const other = createCrtrBashToolDefinition({ command: 'crtr node new --name foo > /tmp/log 2>&1' });
178
- const otherRendered = other.renderCall({ command: 'crtr node new --name foo > /tmp/log 2>&1' }, theme).render(80).join('\n');
183
+ const otherRendered = renderCrtrCall(other, { command: 'crtr node new --name foo > /tmp/log 2>&1' }).render(80).join('\n');
179
184
  assert.match(otherRendered, /node new --name foo/);
180
185
  assert.doesNotMatch(otherRendered, /tmp\/log/);
181
186
  // Redirect operators glued to their target with NO space (`2>/tmp/out`,
182
187
  // `>/tmp/log`) are still shell plumbing and never displayed as path text.
183
188
  const noSpace = createCrtrBashToolDefinition({ command: 'crtr push -h 2>/tmp/out' });
184
- const noSpaceRendered = noSpace.renderCall({ command: 'crtr push -h 2>/tmp/out' }, theme).render(80).join('\n');
189
+ const noSpaceRendered = renderCrtrCall(noSpace, { command: 'crtr push -h 2>/tmp/out' }).render(80).join('\n');
185
190
  assert.match(noSpaceRendered, /<b>push<\/b>/);
186
191
  assert.doesNotMatch(noSpaceRendered, /2>/);
187
192
  assert.doesNotMatch(noSpaceRendered, /tmp\/out/);
188
193
  const noSpaceMid = createCrtrBashToolDefinition({ command: 'crtr node new --name foo >/tmp/log' });
189
- const noSpaceMidRendered = noSpaceMid.renderCall({ command: 'crtr node new --name foo >/tmp/log' }, theme).render(80).join('\n');
194
+ const noSpaceMidRendered = renderCrtrCall(noSpaceMid, { command: 'crtr node new --name foo >/tmp/log' }).render(80).join('\n');
190
195
  assert.match(noSpaceMidRendered, /node new --name foo/);
191
196
  assert.doesNotMatch(noSpaceMidRendered, /tmp\/log/);
192
197
  });
@@ -194,7 +199,7 @@ test('a shell redirect on the outer bash call never leaks into the displayed crt
194
199
  // body contains only synthesized preview content, never a repeated header.
195
200
  test('a collapsed help card never shows the help label twice (header + body)', () => {
196
201
  const def = createCrtrBashToolDefinition({ command: 'crtr canvas dashboard -h' });
197
- const header = def.renderCall({ command: 'crtr canvas dashboard -h' }, theme).render(80).join('\n');
202
+ const header = renderCrtrCall(def, { command: 'crtr canvas dashboard -h' }).render(80).join('\n');
198
203
  const body = def
199
204
  .renderResult({ content: [{ type: 'text', text: LEAF_SCHEMA }] }, { expanded: false }, theme, {})
200
205
  .render(80)
@@ -414,7 +419,7 @@ test('a crtr memory lint with findings collapses to a findings count and warning
414
419
  // (possibly collapsed) result renders.
415
420
  test('renderCall shows a help-specific header for a -h/--help invocation, no bash chrome', () => {
416
421
  const def = createCrtrBashToolDefinition({ command: 'crtr node config -h' });
417
- const rendered = def.renderCall({ command: 'crtr node config -h' }, theme).render(80).join('\n');
422
+ const rendered = renderCrtrCall(def, { command: 'crtr node config -h' }).render(80).join('\n');
418
423
  assert.equal(rendered.trimEnd().split('\n').length, 1);
419
424
  assert.match(rendered, new RegExp(HELP_ICON));
420
425
  assert.match(rendered, /crtr help/);
@@ -447,7 +452,7 @@ test('renderCall uses a mapped Nerd Font glyph and one line for every mapped roo
447
452
  // strip for width purposes), so the visible-width accounting used by the
448
453
  // real Text component's word-wrap counts that markup as content width —
449
454
  // a generous width keeps every case here well clear of an unrelated wrap.
450
- const rendered = def.renderCall({ command }, theme).render(200).join('\n');
455
+ const rendered = renderCrtrCall(def, { command }).render(200).join('\n');
451
456
  assert.equal(rendered.trimEnd().split('\n').length, 1, `expected one line for ${command}`);
452
457
  assert.match(rendered, new RegExp(iconForPath(iconTokens)), `expected the ${iconTokens.join(' ')} glyph in ${command}`);
453
458
  assert.match(rendered, /crtr/, `expected "crtr" in ${command}`);
@@ -457,7 +462,7 @@ test('renderCall uses a mapped Nerd Font glyph and one line for every mapped roo
457
462
  });
458
463
  test('renderCall for a normal invocation previews the crtr path, not a raw bash echo', () => {
459
464
  const def = createCrtrBashToolDefinition({ command: 'crtr node config set foo bar' });
460
- const rendered = def.renderCall({ command: 'crtr node config set foo bar' }, theme).render(200).join('\n');
465
+ const rendered = renderCrtrCall(def, { command: 'crtr node config set foo bar' }).render(200).join('\n');
461
466
  assert.equal(rendered.trimEnd().split('\n').length, 1);
462
467
  assert.match(rendered, new RegExp(iconForPath(['node', 'config'])));
463
468
  assert.match(rendered, /crtr/);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { initTheme } from '@earendil-works/pi-coding-agent';
4
+ import { FOLDED_STATE_KEY } from '../render/tool-calls.js';
5
+ import { createCrtrEditToolDefinition } from '../render/edit-diff.js';
6
+ initTheme(undefined, false);
7
+ const theme = {
8
+ fg: (_name, text) => text,
9
+ bg: (_name, text) => text,
10
+ bold: (text) => text,
11
+ getFgAnsi: (_name) => '',
12
+ getBgAnsi: (_name) => '',
13
+ getColorMode: () => 'truecolor',
14
+ };
15
+ test('folded edit counts keep the executed diff when its speculative preview settles later', () => {
16
+ const definition = createCrtrEditToolDefinition(process.cwd());
17
+ const args = { path: 'example.ts', edits: [{ oldText: 'before', newText: 'after' }] };
18
+ const state = {};
19
+ const context = {
20
+ args,
21
+ argsComplete: false,
22
+ cwd: process.cwd(),
23
+ invalidate() { },
24
+ isError: false,
25
+ state,
26
+ };
27
+ definition.renderCall(args, theme, context);
28
+ definition.renderResult({ content: [{ type: 'text', text: 'edited' }], details: { diff: '- 1 before\n+ 1 after' } }, { isPartial: false }, theme, context);
29
+ state.callComponent.preview = { error: 'speculative preview completed after execution' };
30
+ state[FOLDED_STATE_KEY] = true;
31
+ const folded = definition.renderCall(args, theme, context).render(80).join('\n');
32
+ assert.match(folded, /\+1/);
33
+ assert.match(folded, /-1/);
34
+ });
@@ -0,0 +1,19 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { editorLabel } from '../../../core/canvas/labels.js';
4
+ import { displayEditorTitle } from '../session/editor-frame.js';
5
+ test('a reloaded renamed session still shows its generated icon', () => {
6
+ const identity = { kind: 'general', mode: 'base', icon: '\uf188' };
7
+ assert.equal(displayEditorTitle('test', identity), '\uf188 test');
8
+ assert.equal(displayEditorTitle('\uf188 test', identity), '\uf188 test');
9
+ });
10
+ test('a node restart restores the explicit editor name instead of the generated description', () => {
11
+ const meta = {
12
+ name: 'test',
13
+ kind: 'general',
14
+ mode: 'base',
15
+ description: 'preserve-icon-on-rename',
16
+ icon: '\uf188',
17
+ };
18
+ assert.equal(editorLabel(meta), '\uf188 test');
19
+ });
@@ -125,10 +125,15 @@ function stripBackground(line) {
125
125
  const kept = [];
126
126
  for (let i = 0; i < parts.length; i++) {
127
127
  const code = Number(parts[i] === '' ? 0 : parts[i]);
128
- // Extended background (`48;5;n` / `48;2;r;g;b`) — skip the code AND its args.
129
- if (code === 48) {
128
+ // Extended colors (`38` foreground, `48` background, `58` underline):
129
+ // consume the full sequence atomically so an RGB channel in the 40-47 or
130
+ // 100-107 range is not mistaken for a standalone background code.
131
+ if (code === 38 || code === 48 || code === 58) {
130
132
  const mode = Number(parts[i + 1]);
131
- i += mode === 5 ? 2 : mode === 2 ? 4 : 1;
133
+ const end = i + (mode === 5 ? 2 : mode === 2 ? 4 : 1);
134
+ if (code !== 48)
135
+ kept.push(...parts.slice(i, end + 1));
136
+ i = end;
132
137
  continue;
133
138
  }
134
139
  // Standard (40-47) and bright (100-107) backgrounds. `49` (reset to the
@@ -1,6 +1,7 @@
1
1
  import { truncateToVisualLines } from '@earendil-works/pi-coding-agent';
2
2
  import { Box, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
3
3
  import { HELP_ICON, iconForPath, suppressOutputForPath, summarizePath, } from '../../../core/preview-registry.js';
4
+ import { isFoldedContext } from './tool-calls.js';
4
5
  // Hard cap on a collapsed crtr card's total rendered lines (rule 12,
5
6
  // inline-ui-placement): help synthesis or a normal truncated result never
6
7
  // shows more than this many lines total, blank spacer + status + body +
@@ -148,6 +149,32 @@ function commandFromArgs(args) {
148
149
  const command = args.command;
149
150
  return typeof command === 'string' ? command : '';
150
151
  }
152
+ const CRTR_FLAG_TOKEN = /^-{1,2}[A-Za-z0-9]/;
153
+ const CRTR_ATTACHED_FLAG_VALUE = /^(--?[A-Za-z0-9][^=]*)(=)(.*)$/;
154
+ const CRTR_NUMBER_VALUE = /^-?(?:\d+(?:\.\d+)?|\.\d+)$/;
155
+ /** Syntax-color flags and their values while leaving the command path and
156
+ * positional arguments in the folded row's existing bold title style. */
157
+ function styleFoldedCrtrTokens(tokens, theme) {
158
+ let expectsValue = false;
159
+ return tokens.map((token) => {
160
+ const attached = CRTR_ATTACHED_FLAG_VALUE.exec(token);
161
+ if (attached !== null) {
162
+ expectsValue = false;
163
+ const valueColor = CRTR_NUMBER_VALUE.test(attached[3]) ? 'syntaxNumber' : 'syntaxString';
164
+ return `${theme.fg('syntaxKeyword', attached[1])}${theme.fg('syntaxOperator', attached[2])}${theme.fg(valueColor, attached[3])}`;
165
+ }
166
+ if (expectsValue && !CRTR_FLAG_TOKEN.test(token)) {
167
+ expectsValue = false;
168
+ return theme.fg(CRTR_NUMBER_VALUE.test(token) ? 'syntaxNumber' : 'syntaxString', token);
169
+ }
170
+ if (CRTR_FLAG_TOKEN.test(token)) {
171
+ expectsValue = true;
172
+ return theme.fg('syntaxKeyword', token);
173
+ }
174
+ expectsValue = false;
175
+ return theme.fg('toolTitle', theme.bold(token));
176
+ }).join(' ');
177
+ }
151
178
  function resultText(result) {
152
179
  return (result.content ?? [])
153
180
  .filter((part) => part?.type === 'text' || typeof part?.text === 'string')
@@ -670,7 +697,7 @@ export function createCrtrBashToolDefinition(args) {
670
697
  label: 'bash',
671
698
  description: 'Render crtr bash output with crtr-specific chrome in the attach viewer.',
672
699
  parameters: {},
673
- renderCall(callArgs, theme) {
700
+ renderCall(callArgs, theme, context) {
674
701
  const currentCommand = commandFromArgs(callArgs) || command;
675
702
  // Semantic card, not a bash echo: the header names what the invocation
676
703
  // IS (help schema vs. a crtr command), and the body previews only the
@@ -684,7 +711,9 @@ export function createCrtrBashToolDefinition(args) {
684
711
  const icon = isHelp ? HELP_ICON : iconForPath(pathTokens);
685
712
  const prefix = isHelp ? `${icon} crtr help` : `${icon} crtr`;
686
713
  const styledPrefix = theme.fg('accent', theme.bold(prefix));
687
- const styledPath = path ? ` ${theme.fg('toolTitle', theme.bold(path))}` : '';
714
+ const styledPath = path
715
+ ? ` ${isFoldedContext(context) ? styleFoldedCrtrTokens(pathTokens, theme) : theme.fg('toolTitle', theme.bold(path))}`
716
+ : '';
688
717
  return new Text(`${styledPrefix}${styledPath}`, 0, 0);
689
718
  },
690
719
  renderResult(result, options, theme, context) {
@@ -102,6 +102,20 @@ function parseDiffLine(line) {
102
102
  return null;
103
103
  return { prefix: match[1], lineNum: match[2], content: match[3] };
104
104
  }
105
+ /** Count only changed rows in pi's display diff; context and ellipsis rows do
106
+ * not contribute to the folded file summary. */
107
+ function countDiffLines(diff) {
108
+ let added = 0;
109
+ let removed = 0;
110
+ for (const line of diff.split('\n')) {
111
+ const parsed = parseDiffLine(line);
112
+ if (parsed?.prefix === '+')
113
+ added++;
114
+ if (parsed?.prefix === '-')
115
+ removed++;
116
+ }
117
+ return { added, removed };
118
+ }
105
119
  function replaceTabs(text) {
106
120
  return text.replace(/\t/g, ' ');
107
121
  }
@@ -247,7 +261,11 @@ export function createCrtrEditToolDefinition(cwd) {
247
261
  if (isFoldedContext(context)) {
248
262
  const a = args;
249
263
  const raw = typeof a?.file_path === 'string' ? a.file_path : typeof a?.path === 'string' ? a.path : '';
250
- return new Text(foldedFileCallLine('edit', raw, theme, context.cwd ?? cwd), 0, 0);
264
+ const preview = context.state.callComponent?.preview;
265
+ const changes = context.isError
266
+ ? undefined
267
+ : context.state.settledChanges ?? (preview && !('error' in preview) ? countDiffLines(preview.diff) : undefined);
268
+ return new Text(foldedFileCallLine('edit', raw, theme, context.cwd ?? cwd, changes), 0, 0);
251
269
  }
252
270
  const component = builtin.renderCall(args, theme, context);
253
271
  const callBox = context.state.callComponent;
@@ -258,6 +276,13 @@ export function createCrtrEditToolDefinition(cwd) {
258
276
  return component;
259
277
  },
260
278
  renderResult(result, options, theme, context) {
279
+ const resultDiff = result?.details?.diff;
280
+ if (!options?.isPartial) {
281
+ if (!context.isError && typeof resultDiff === 'string')
282
+ context.state.settledChanges = countDiffLines(resultDiff);
283
+ else
284
+ delete context.state.settledChanges;
285
+ }
261
286
  const component = builtin.renderResult(result, options, theme, context);
262
287
  const callBox = context.state.callComponent;
263
288
  if (callBox) {
@@ -269,7 +294,6 @@ export function createCrtrEditToolDefinition(cwd) {
269
294
  }
270
295
  // Fallback path (no call box state, e.g. a bare result replay): render
271
296
  // the result diff standalone. No host Box here, so restore to default bg.
272
- const resultDiff = result?.details?.diff;
273
297
  if (!context.isError && typeof resultDiff === 'string') {
274
298
  const container = new Container();
275
299
  container.addChild(new Spacer(1));
@@ -11,8 +11,13 @@ export declare const FOLDED_STATE_KEY = "crtrFolded";
11
11
  export declare function isFoldedContext(context: {
12
12
  state?: object;
13
13
  }): boolean;
14
- /** The one-line folded form of a file tool's call: `<icon> <label> <path>`. */
15
- export declare function foldedFileCallLine(tool: 'read' | 'write' | 'edit', rawPath: string, theme: Theme, cwd: string): string;
14
+ export type FileLineChanges = {
15
+ added: number;
16
+ removed: number;
17
+ };
18
+ /** The one-line folded form of a file tool's call: `<icon> <label> <path>`,
19
+ * optionally followed by its green/red changed-line counts. */
20
+ export declare function foldedFileCallLine(tool: 'read' | 'write' | 'edit', rawPath: string, theme: Theme, cwd: string, changes?: FileLineChanges): string;
16
21
  /** Prefix a pi-built `Text` in place (its `text` field is TS-private but a plain
17
22
  * runtime property). Used where the header line is baked into the same string as
18
23
  * a body we want to keep verbatim — write's syntax-highlighted content preview,
@@ -28,9 +28,29 @@ export const FOLDED_STATE_KEY = 'crtrFolded';
28
28
  export function isFoldedContext(context) {
29
29
  return context.state?.[FOLDED_STATE_KEY] === true;
30
30
  }
31
- /** The one-line folded form of a file tool's call: `<icon> <label> <path>`. */
32
- export function foldedFileCallLine(tool, rawPath, theme, cwd) {
33
- return `${header(TOOL_ICON[tool], tool, theme)} ${pathDisplay(rawPath, theme, cwd)}`;
31
+ /** Count file lines the way a diff does: a trailing newline terminates the last
32
+ * line instead of creating an extra empty one. */
33
+ function countFileLines(content) {
34
+ if (content.length === 0)
35
+ return 0;
36
+ const newlines = content.match(/\n/g)?.length ?? 0;
37
+ return newlines + (content.endsWith('\n') ? 0 : 1);
38
+ }
39
+ /** The green/red line-count suffix shown after a folded write/edit path. */
40
+ function lineChangesDisplay(changes, theme) {
41
+ if (!changes)
42
+ return '';
43
+ const counts = [];
44
+ if (changes.added > 0)
45
+ counts.push(theme.fg('toolDiffAdded', `+${changes.added}`));
46
+ if (changes.removed > 0)
47
+ counts.push(theme.fg('toolDiffRemoved', `-${changes.removed}`));
48
+ return counts.length > 0 ? ` ${counts.join(' ')}` : '';
49
+ }
50
+ /** The one-line folded form of a file tool's call: `<icon> <label> <path>`,
51
+ * optionally followed by its green/red changed-line counts. */
52
+ export function foldedFileCallLine(tool, rawPath, theme, cwd, changes) {
53
+ return `${header(TOOL_ICON[tool], tool, theme)} ${pathDisplay(rawPath, theme, cwd)}${lineChangesDisplay(changes, theme)}`;
34
54
  }
35
55
  /** `~`-shorten an absolute path the way pi's renderToolPath does (its
36
56
  * render-utils module is not exported from the package root). */
@@ -113,7 +133,8 @@ export function createCrtrWriteToolDefinition(cwd) {
113
133
  renderCall(args, theme, context) {
114
134
  if (isFoldedContext(context)) {
115
135
  const a = args;
116
- const line = foldedFileCallLine('write', str(a?.file_path ?? a?.path), theme, context.cwd ?? cwd);
136
+ const changes = context.isError ? undefined : { added: countFileLines(str(a?.content)), removed: 0 };
137
+ const line = foldedFileCallLine('write', str(a?.file_path ?? a?.path), theme, context.cwd ?? cwd, changes);
117
138
  // Reuse the previous component when it is Text-like (the builtin's is a
118
139
  // Text subclass), so fold toggles don't churn component identity.
119
140
  const last = context.lastComponent;
@@ -30,7 +30,7 @@ export interface AttachConnection {
30
30
  readonly meta: Pick<NodeMeta, 'cwd' | 'profile_id'>;
31
31
  /** The attached node's persona/lifecycle, when this canvas can tell us —
32
32
  * `undefined` for a remote attach until the viewer refetches it. */
33
- readonly nodeIdentity: Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle'> | undefined;
33
+ readonly nodeIdentity: Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle' | 'icon'> | undefined;
34
34
  /** The one socket to the node's headless broker, already connected. */
35
35
  readonly socket: BrokerClient;
36
36
  /** Re-throw a connect-adjacent failure that landed AFTER `connect` resolved.
@@ -14,7 +14,7 @@ export declare function resolveEditorFrameStyles(bashMode: 0 | 1 | 2, thinkingLe
14
14
  export interface EditorChromeHooks {
15
15
  /** The viewer's live node identity — a `let` runAttach owns and refreshes
16
16
  * (remote attach starts without one), so it MUST stay a getter. */
17
- nodeIdentity: () => Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle'> | undefined;
17
+ nodeIdentity: () => Pick<NodeMeta, 'kind' | 'mode' | 'lifecycle' | 'icon'> | undefined;
18
18
  /** The engine's current thinking level, from live session state. */
19
19
  thinkingLevel: () => BrokerSnapshot['state']['thinkingLevel'] | undefined;
20
20
  /** Repaint the status line — the bash-level segment tracks `bashMode`. */
@@ -36,6 +36,10 @@ export interface EditorChrome {
36
36
  /** Kick an async git-status fetch that repaints the top border. */
37
37
  pollGit: () => void;
38
38
  }
39
+ /** Render the generated task icon on every non-empty editor title. Session
40
+ * names are persisted independently by pi, so this derives the icon from the
41
+ * canvas identity again whenever a viewer attaches or reloads. */
42
+ export declare function displayEditorTitle(sessionName: string | undefined, identity: Pick<NodeMeta, 'kind' | 'mode' | 'icon'> | undefined): string;
39
43
  /** The editor-chrome collaborator: owns the shell/bash level (`deriveBashMode`
40
44
  * over `editor.onChange` is its ONE writer) and the `/color` chip override,
41
45
  * and paints every frame-attached adornment — border hue, title chip, context