@north-light/crouter 0.3.169 → 0.3.171

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 (122) hide show
  1. package/dist/build-root.d.ts +1 -16
  2. package/dist/build-root.js +1 -25
  3. package/dist/builtin-memory/insights/capture.md +66 -0
  4. package/dist/builtin-memory/insights/init.md +45 -0
  5. package/dist/builtin-memory/internal/plugins.md +12 -11
  6. package/dist/cli.js +2 -6
  7. package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -1
  8. package/dist/clients/attach/__tests__/context-message.test.js +50 -3
  9. package/dist/clients/attach/__tests__/diagram.test.js +1 -1
  10. package/dist/clients/attach/chrome/header.d.ts +10 -0
  11. package/dist/clients/attach/chrome/header.js +35 -0
  12. package/dist/clients/attach/chrome/status-line.d.ts +3 -0
  13. package/dist/clients/attach/chrome/status-line.js +5 -0
  14. package/dist/clients/attach/chrome/widgets.js +7 -18
  15. package/dist/clients/attach/input/controller.d.ts +10 -0
  16. package/dist/clients/attach/input/controller.js +3 -0
  17. package/dist/clients/attach/overlays/help.d.ts +33 -0
  18. package/dist/clients/attach/overlays/help.js +204 -0
  19. package/dist/clients/attach/render/chat-view.d.ts +81 -9
  20. package/dist/clients/attach/render/chat-view.js +434 -90
  21. package/dist/clients/attach/render/context-message.js +3 -3
  22. package/dist/clients/attach/render/diagram.js +2 -2
  23. package/dist/clients/attach/render/measured-container.d.ts +94 -0
  24. package/dist/clients/attach/render/measured-container.js +307 -0
  25. package/dist/clients/attach/render/transcript-copy.d.ts +9 -0
  26. package/dist/clients/attach/render/transcript-copy.js +81 -0
  27. package/dist/clients/attach/render/viewport.d.ts +29 -0
  28. package/dist/clients/attach/render/viewport.js +160 -0
  29. package/dist/clients/attach/session/bindings.d.ts +5 -1
  30. package/dist/clients/attach/session/bindings.js +16 -0
  31. package/dist/clients/attach/session/context.d.ts +5 -3
  32. package/dist/clients/attach/session/frame.d.ts +34 -0
  33. package/dist/clients/attach/session/frame.js +173 -0
  34. package/dist/clients/attach/session/frames.d.ts +2 -0
  35. package/dist/clients/attach/session/frames.js +3 -0
  36. package/dist/clients/attach/session/input-wiring.d.ts +10 -4
  37. package/dist/clients/attach/session/input-wiring.js +13 -13
  38. package/dist/clients/attach/session/keys.d.ts +4 -1
  39. package/dist/clients/attach/session/keys.js +9 -4
  40. package/dist/clients/attach/session/layout.d.ts +10 -8
  41. package/dist/clients/attach/session/layout.js +32 -31
  42. package/dist/clients/attach/session/mouse.d.ts +15 -0
  43. package/dist/clients/attach/session/mouse.js +61 -0
  44. package/dist/clients/attach/slash/dispatch.d.ts +10 -0
  45. package/dist/clients/attach/slash/dispatch.js +35 -2
  46. package/dist/clients/attach/viewer.js +605 -581
  47. package/dist/clients/web/dev-server.d.ts +4 -1
  48. package/dist/clients/web/dev-server.js +8 -19
  49. package/dist/clients/web/web-cmd.js +1 -1
  50. package/dist/commands/human/prompts.js +16 -6
  51. package/dist/commands/pkg/plugin-manage.js +143 -68
  52. package/dist/commands/sys/__tests__/setup-core.test.js +4 -2
  53. package/dist/commands/sys/setup-core.js +21 -16
  54. package/dist/core/__tests__/human-deliver.test.js +62 -2
  55. package/dist/core/clipboard-text.d.ts +8 -0
  56. package/dist/core/clipboard-text.js +18 -0
  57. package/dist/core/command-manifests/registry.d.ts +1 -3
  58. package/dist/core/command-plugins/bundle.d.ts +29 -0
  59. package/dist/core/command-plugins/bundle.js +198 -0
  60. package/dist/core/command-plugins/discovery.d.ts +0 -8
  61. package/dist/core/command-plugins/discovery.js +2 -39
  62. package/dist/core/command-plugins/transport/http-fetch.d.ts +4 -30
  63. package/dist/core/command-plugins/transport/http-fetch.js +17 -95
  64. package/dist/core/command.d.ts +9 -13
  65. package/dist/core/command.js +12 -17
  66. package/dist/core/config.js +2 -1
  67. package/dist/core/keybindings/__tests__/resolve.test.js +4 -0
  68. package/dist/core/keybindings/attach-control.d.ts +3 -0
  69. package/dist/core/keybindings/attach-control.js +1 -0
  70. package/dist/core/keybindings/catalog.d.ts +2 -2
  71. package/dist/core/keybindings/catalog.js +8 -0
  72. package/dist/core/runtime/broker-protocol.d.ts +13 -1
  73. package/dist/core/runtime/broker.js +213 -5
  74. package/dist/core/runtime/canvas-extensions.d.ts +3 -0
  75. package/dist/core/runtime/canvas-extensions.js +3 -0
  76. package/dist/core/runtime/front-door.js +4 -4
  77. package/dist/core/runtime/kickoff.d.ts +1 -5
  78. package/dist/core/runtime/kickoff.js +1 -5
  79. package/dist/core/runtime/naming.d.ts +7 -0
  80. package/dist/core/runtime/naming.js +5 -2
  81. package/dist/core/runtime/node-read.js +15 -0
  82. package/dist/core/runtime/package-health.d.ts +6 -21
  83. package/dist/core/runtime/package-health.js +12 -123
  84. package/dist/core/runtime/stop-guard.d.ts +1 -1
  85. package/dist/core/runtime/stop-guard.js +3 -3
  86. package/dist/core/runtime/tool-group-summary.d.ts +19 -0
  87. package/dist/core/runtime/tool-group-summary.js +117 -0
  88. package/dist/core/termrender/code-doc.d.ts +12 -0
  89. package/dist/core/termrender/code-doc.js +91 -0
  90. package/dist/core/termrender/display.d.ts +12 -0
  91. package/dist/core/termrender/display.js +19 -0
  92. package/dist/core/termrender/termrender.d.ts +73 -0
  93. package/dist/core/termrender/termrender.js +795 -0
  94. package/dist/core/termrender/version.d.ts +1 -0
  95. package/dist/core/termrender/version.js +1 -0
  96. package/dist/core/user-settings.d.ts +14 -0
  97. package/dist/core/user-settings.js +20 -0
  98. package/dist/daemon/crtrd.js +5 -6
  99. package/dist/index.d.ts +2 -0
  100. package/dist/index.js +1 -0
  101. package/dist/pi-extensions/__tests__/canvas-structured-output.test.d.ts +1 -0
  102. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +63 -0
  103. package/dist/pi-extensions/canvas-stophook.js +2 -2
  104. package/dist/pi-extensions/canvas-structured-output.js +52 -8
  105. package/dist/pi-extensions/summary-tool.d.ts +33 -0
  106. package/dist/pi-extensions/summary-tool.js +62 -0
  107. package/dist/shared/generated-context.d.ts +19 -4
  108. package/dist/shared/generated-context.js +98 -7
  109. package/dist/shared/tool-groups.d.ts +23 -0
  110. package/dist/shared/tool-groups.js +60 -0
  111. package/dist/types.d.ts +11 -0
  112. package/dist/types.js +1 -0
  113. package/dist/web-client/assets/{index-U_NZ66VE.js → index-D7I3gHCL.js} +26 -19
  114. package/dist/web-client/index.html +1 -1
  115. package/dist/web-client/sw.js +1 -1
  116. package/package.json +3 -1
  117. package/runtime.lock.json +62 -3
  118. package/scripts/postinstall.mjs +14 -1
  119. package/dist/core/command-plugins/store.d.ts +0 -16
  120. package/dist/core/command-plugins/store.js +0 -64
  121. package/dist/core/runtime/fault-recovery-nudge.d.ts +0 -3
  122. package/dist/core/runtime/fault-recovery-nudge.js +0 -3
@@ -5,6 +5,13 @@ export declare function sanitizeSessionName(raw: string): string;
5
5
  /** Local fallback: derive a name straight from the prompt (no pi call). Drops
6
6
  * stop-words, takes the first few content words. */
7
7
  export declare function slugFromPrompt(prompt: string): string;
8
+ /** The namer's model: the `light` rung of the DEFAULT provider's ladder, with
9
+ * any thinking suffix stripped (we pass `--thinking off` explicitly — naming is
10
+ * a trivial classification that never needs a reasoning budget). Reading the
11
+ * ladder rather than pinning a concrete model id keeps naming working for a
12
+ * user who never configured Anthropic: an OpenAI default provider resolves to
13
+ * its own light rung. `CRTR_NAME_MODEL` still overrides. */
14
+ export declare function lightModel(overrideEnv?: string): string;
8
15
  /** A generated session name: the kebab-case handle plus the Nerd Font glyph the
9
16
  * namer chose for it. `icon` is '' when the model returned nothing usable —
10
17
  * every surface renders the name alone in that case. */
@@ -119,13 +119,16 @@ export function slugFromPrompt(prompt) {
119
119
  * ladder rather than pinning a concrete model id keeps naming working for a
120
120
  * user who never configured Anthropic: an OpenAI default provider resolves to
121
121
  * its own light rung. `CRTR_NAME_MODEL` still overrides. */
122
- function nameModel() {
123
- const override = process.env['CRTR_NAME_MODEL'];
122
+ export function lightModel(overrideEnv) {
123
+ const override = overrideEnv === undefined ? undefined : process.env[overrideEnv];
124
124
  if (override !== undefined && override.trim() !== '')
125
125
  return override.trim();
126
126
  const ladders = modelLadders();
127
127
  return stripThinkingSuffix(ladders[defaultProvider(ladders)].light);
128
128
  }
129
+ function nameModel() {
130
+ return lightModel('CRTR_NAME_MODEL');
131
+ }
129
132
  /** The pi argv for a headless TEXT request (issue titles). Stripped down (no
130
133
  * tools, session, context files, extensions, skills, templates, themes) so
131
134
  * it's fast and side-effect free. */
@@ -8,6 +8,7 @@
8
8
  // and were imported into the handlers from there; Stage B-3 relocated them here
9
9
  // so the CLI leaves' import graph reaches no canvas state store (spec §1/§10).
10
10
  import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
11
+ import { readJsonIfExists } from '../fs-utils.js';
11
12
  import { tmpdir } from 'node:os';
12
13
  import { basename, join } from 'node:path';
13
14
  import { InputError } from '../io.js';
@@ -17,6 +18,7 @@ import { SessionManager } from './broker-sdk.js';
17
18
  import { piAgentDir } from './package-health.js';
18
19
  import { BUILTIN_SLASH_COMMANDS } from './pi-vendored.js';
19
20
  import { cycleAwareMessages } from './session-cycles.js';
21
+ import { normalizeToolGroupSummaryRecord } from '../../shared/tool-groups.js';
20
22
  import { ModelRegistry, ModelRuntime } from '@earendil-works/pi-coding-agent';
21
23
  import { PoolCredentialStore } from '../pool-credential-store.js';
22
24
  /** A session `.jsonl` records only `{ provider, modelId }` — pi never persists a
@@ -131,6 +133,15 @@ function readIfPresent(path) {
131
133
  return null;
132
134
  }
133
135
  }
136
+ function readToolGroupSummaries(nodeId) {
137
+ try {
138
+ const raw = readJsonIfExists(join(jobDir(nodeId), 'tool-summaries.json'));
139
+ return normalizeToolGroupSummaryRecord(raw);
140
+ }
141
+ catch {
142
+ return {};
143
+ }
144
+ }
134
145
  /** Reconstruct a persisted session without launching its broker. Backs the
135
146
  * machine-readable snapshot endpoint and the human transcript render. */
136
147
  export async function readNodeSnapshot(nodeId) {
@@ -175,6 +186,10 @@ export async function readNodeSnapshot(nodeId) {
175
186
  // process state (last setStatus/setWidget/setTitle) and is not persisted,
176
187
  // so an offline read has none.
177
188
  display: { statuses: {}, widgets: {}, title: undefined },
189
+ // Sidecar summaries survive broker exit so a dormant transcript receives
190
+ // the same completed-group data as a live welcome snapshot. Malformed or
191
+ // absent sidecars are deliberately ignored: tool calls remain visible.
192
+ toolGroupSummaries: readToolGroupSummaries(nodeId),
178
193
  };
179
194
  return {
180
195
  nodeId,
@@ -4,27 +4,12 @@ export interface MissingPackage {
4
4
  /** Absolute path it resolved to (which does not exist). */
5
5
  resolved: string;
6
6
  }
7
- export interface GlobalHumanloopHealth {
8
- bundledVersion: string | null;
9
- globalVersion: string | null;
10
- current: boolean;
11
- }
12
- /** Read the installed library's version rather than coupling this check to the
13
- * dependency range in crouter's package.json. The bundled copy is the floor
14
- * required by crouter's generated `hl inbox toggle` binding. */
15
- export declare function bundledHumanloopVersion(): string | null;
16
- /** Read the PATH-visible `hl` package directly. Spawning `npm ls -g` here
17
- * makes every front-door boot pay for npm's global dependency-tree scan; a
18
- * linked development install can make that scan take many seconds. The real
19
- * executable points into the package for both ordinary and npm-linked installs,
20
- * so walking upward to its package.json is authoritative for the CLI crtr will
21
- * actually invoke. */
22
- export declare function globalHumanloopVersion(): string | null;
23
- export declare function globalHumanloopHealth(): GlobalHumanloopHealth;
24
- /** A non-fatal action-oriented diagnostic for a missing, unreadable, or stale
25
- * global `hl` executable. */
26
- export declare function globalHumanloopWarning(): string | null;
27
- export declare function globalHumanloopInstallCommand(): string;
7
+ /** A non-fatal, action-oriented diagnostic for an unprovisioned managed
8
+ * renderer — the one remaining out-of-tree dependency. Deliberately calls
9
+ * `isRendererReady()`, NOT `ensureRenderer()`: this runs on the front-door boot
10
+ * path and at crtrd startup, where a `uv pip install` (up to 180s) must never
11
+ * happen. Readiness is a handful of fs stats, no subprocess. */
12
+ export declare function rendererWarning(): string | null;
28
13
  /**
29
14
  * The pi agent config dir (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`) — a local
30
15
  * reimplementation of pi's `getAgentDir()`. Reimplemented (not imported) ON
@@ -10,131 +10,20 @@
10
10
  // vanishes with no diagnostic. This turns that silent failure into a loud,
11
11
  // actionable warning. The fix it points at is `crtr sys setup`, which re-resolves
12
12
  // package paths to the repo's current location.
13
- import { accessSync, constants, existsSync, readFileSync, realpathSync } from 'node:fs';
14
- import { fileURLToPath } from 'node:url';
13
+ import { existsSync, readFileSync } from 'node:fs';
15
14
  import { homedir } from 'node:os';
16
- import { delimiter, dirname, join, resolve } from 'node:path';
17
- const GLOBAL_HUMANLOOP_INSTALL_COMMAND = 'npm i -g @crouton-kit/humanloop@latest';
18
- /** Read the installed library's version rather than coupling this check to the
19
- * dependency range in crouter's package.json. The bundled copy is the floor
20
- * required by crouter's generated `hl inbox toggle` binding. */
21
- export function bundledHumanloopVersion() {
22
- try {
23
- // package.json is deliberately not an exported subpath. Resolve the public
24
- // ESM entry, then walk from dist/index.js to the package root.
25
- const entryPath = fileURLToPath(import.meta.resolve('@crouton-kit/humanloop'));
26
- const packageJsonPath = join(dirname(entryPath), '..', 'package.json');
27
- const value = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
28
- return typeof value.version === 'string'
29
- ? value.version
30
- : null;
31
- }
32
- catch {
33
- return null;
34
- }
35
- }
36
- /** Compare ordinary npm semvers without loading a semver dependency on the
37
- * foreground/daemon startup path. Unparseable versions deliberately fail the
38
- * health check: setup can repair them and boot only warns. */
39
- function compareVersions(left, right) {
40
- const parse = (version) => {
41
- const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
42
- if (match === null)
43
- return null;
44
- return { core: [Number(match[1]), Number(match[2]), Number(match[3])], prerelease: match[4]?.split('.') ?? [] };
45
- };
46
- const a = parse(left);
47
- const b = parse(right);
48
- if (a === null || b === null)
15
+ import { join, resolve } from 'node:path';
16
+ import { isRendererReady } from '../termrender/termrender.js';
17
+ /** A non-fatal, action-oriented diagnostic for an unprovisioned managed
18
+ * renderer — the one remaining out-of-tree dependency. Deliberately calls
19
+ * `isRendererReady()`, NOT `ensureRenderer()`: this runs on the front-door boot
20
+ * path and at crtrd startup, where a `uv pip install` (up to 180s) must never
21
+ * happen. Readiness is a handful of fs stats, no subprocess. */
22
+ export function rendererWarning() {
23
+ if (isRendererReady())
49
24
  return null;
50
- for (let index = 0; index < a.core.length; index++) {
51
- const difference = a.core[index] - b.core[index];
52
- if (difference !== 0)
53
- return difference;
54
- }
55
- if (a.prerelease.length === 0 || b.prerelease.length === 0) {
56
- return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length === 0 ? 1 : -1;
57
- }
58
- for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) {
59
- const aPart = a.prerelease[index];
60
- const bPart = b.prerelease[index];
61
- if (aPart === undefined || bPart === undefined)
62
- return aPart === bPart ? 0 : aPart === undefined ? -1 : 1;
63
- if (aPart === bPart)
64
- continue;
65
- const aNumber = /^\d+$/.test(aPart);
66
- const bNumber = /^\d+$/.test(bPart);
67
- if (aNumber && bNumber)
68
- return Number(aPart) - Number(bPart);
69
- if (aNumber !== bNumber)
70
- return aNumber ? -1 : 1;
71
- return aPart < bPart ? -1 : 1;
72
- }
73
- return 0;
74
- }
75
- /** Read the PATH-visible `hl` package directly. Spawning `npm ls -g` here
76
- * makes every front-door boot pay for npm's global dependency-tree scan; a
77
- * linked development install can make that scan take many seconds. The real
78
- * executable points into the package for both ordinary and npm-linked installs,
79
- * so walking upward to its package.json is authoritative for the CLI crtr will
80
- * actually invoke. */
81
- export function globalHumanloopVersion() {
82
- const executable = (process.env['PATH'] ?? '')
83
- .split(delimiter)
84
- .filter(Boolean)
85
- .map((entry) => join(entry, 'hl'))
86
- .find((candidate) => {
87
- try {
88
- accessSync(candidate, constants.X_OK);
89
- return true;
90
- }
91
- catch {
92
- return false;
93
- }
94
- });
95
- if (executable === undefined)
96
- return null;
97
- try {
98
- let cursor = dirname(realpathSync(executable));
99
- for (;;) {
100
- const packageJsonPath = join(cursor, 'package.json');
101
- if (existsSync(packageJsonPath)) {
102
- const value = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
103
- const manifest = value;
104
- if (manifest.name === '@crouton-kit/humanloop')
105
- return typeof manifest.version === 'string' ? manifest.version : null;
106
- }
107
- const parent = dirname(cursor);
108
- if (parent === cursor)
109
- return null;
110
- cursor = parent;
111
- }
112
- }
113
- catch {
114
- return null;
115
- }
116
- }
117
- export function globalHumanloopHealth() {
118
- const bundledVersion = bundledHumanloopVersion();
119
- const globalVersion = globalHumanloopVersion();
120
- const comparison = bundledVersion !== null && globalVersion !== null
121
- ? compareVersions(globalVersion, bundledVersion)
122
- : null;
123
- return { bundledVersion, globalVersion, current: comparison !== null && comparison >= 0 };
124
- }
125
- /** A non-fatal action-oriented diagnostic for a missing, unreadable, or stale
126
- * global `hl` executable. */
127
- export function globalHumanloopWarning() {
128
- const { bundledVersion, globalVersion, current } = globalHumanloopHealth();
129
- if (current)
130
- return null;
131
- const floor = bundledVersion ?? 'the bundled version';
132
- const found = globalVersion === null ? 'missing or unprobeable' : globalVersion;
133
- return `crtr: global \`hl\` CLI is ${found}; crouter requires at least bundled humanloop ${floor} for its inbox toggle.\n` +
134
- ` Fix: run \`${GLOBAL_HUMANLOOP_INSTALL_COMMAND}\`.`;
135
- }
136
- export function globalHumanloopInstallCommand() {
137
- return GLOBAL_HUMANLOOP_INSTALL_COMMAND;
25
+ return 'crtr: the pinned termrender renderer is not provisioned — decks, reviews, and inline diagrams render as plaintext.\n' +
26
+ ' Fix: run `crtr sys setup` (requires `uv`: curl -LsSf https://astral.sh/uv/install.sh | sh).';
138
27
  }
139
28
  /** Expand a leading `~` against `homeDir` (pi's own convention). */
140
29
  function expandTilde(pathValue, homeDir) {
@@ -1,3 +1,4 @@
1
+ export { STALL_REPROMPT } from '../../shared/generated-context.js';
1
2
  export interface StopSignals {
2
3
  /** Did the node call `push --final` (finish) this turn? */
3
4
  pushedFinal: boolean;
@@ -12,7 +13,6 @@ export type StopAction = {
12
13
  reason: 'stalled';
13
14
  message: string;
14
15
  };
15
- export declare const STALL_REPROMPT: string;
16
16
  /** Decide what to do when a node stops. Pure given the canvas + this turn's
17
17
  * signals — the stophook supplies the signals and enacts the action. */
18
18
  export declare function evaluateStop(nodeId: string, signals: StopSignals): StopAction;
@@ -18,9 +18,9 @@
18
18
  // • otherwise → a TERMINAL node with nothing live to wait for and no
19
19
  // final pushed. Re-prompt it to finish or escalate.
20
20
  import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, getNode } from '../canvas/index.js';
21
+ import { formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
21
22
  import { hasPendingStructuredOutput, readOutputRequest } from './structured-output.js';
22
- export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone and haven't finished. " +
23
- "Pipe the result to `crtr push final` through a single-quoted heredoc if the work is done, or use `crtr human ask` if you are blocked or need the user.";
23
+ export { STALL_REPROMPT } from '../../shared/generated-context.js';
24
24
  function structuredOutputReprompt(nodeId) {
25
25
  let schema = '{}';
26
26
  try {
@@ -31,7 +31,7 @@ function structuredOutputReprompt(nodeId) {
31
31
  catch {
32
32
  /* keep a deterministic reprompt even if the schema file was corrupted */
33
33
  }
34
- return `You must call the \`submit\` tool with a result matching the required schema before you can stop. You cannot finish or go dormant any other way while this request is pending.\n\nRequired schema:\n\n\`\`\`json\n${schema}\n\`\`\``;
34
+ return formatStructuredOutputReprompt(schema);
35
35
  }
36
36
  /** Decide what to do when a node stops. Pure given the canvas + this turn's
37
37
  * signals — the stophook supplies the signals and enacts the action. */
@@ -0,0 +1,19 @@
1
+ import { type ToolGroupSummary } from '../../shared/tool-groups.js';
2
+ export type ToolGroupPiece = {
3
+ type: 'tool';
4
+ name: string;
5
+ args: unknown;
6
+ } | {
7
+ type: 'result';
8
+ name: string;
9
+ result: unknown;
10
+ isError: boolean;
11
+ } | {
12
+ type: 'text';
13
+ text: string;
14
+ };
15
+ /** Serialize only the causal material around one group, with fixed per-piece
16
+ * and total budgets so a verbose tool result cannot dominate the light-model call. */
17
+ export declare function serializeToolGroup(previousBoundaryProse: string, pieces: readonly ToolGroupPiece[]): string;
18
+ /** Generate a structured summary object, or null on every failure. */
19
+ export declare function headlessToolGroupSummary(segment: string): Promise<ToolGroupSummary | null>;
@@ -0,0 +1,117 @@
1
+ // Broker-side, best-effort tool-group summarization. The only successful output
2
+ // path is the constrained submit_summary tool; failures deliberately yield no
3
+ // substitute object so callers leave the normal folded calls visible.
4
+ import { execFile } from 'node:child_process';
5
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { SUMMARY_TOOL_PATH } from './canvas-extensions.js';
9
+ import { lightModel } from './naming.js';
10
+ import { bundledPiSubprocessEnv, resolveBundledPiCliPath } from './pi-cli.js';
11
+ import { normalizeToolGroupSummary } from '../../shared/tool-groups.js';
12
+ const BOUNDARY_CAP = 600;
13
+ const TOOL_ARGS_CAP = 300;
14
+ const RESULT_CAP = 400;
15
+ const TEXT_CAP = 1_000;
16
+ const SEGMENT_CAP = 10_000;
17
+ const SUMMARY_TIMEOUT_MS = 45_000;
18
+ function capped(value, cap) {
19
+ const normalized = value.replace(/\s+/g, ' ').trim();
20
+ return normalized.length <= cap ? normalized : `${normalized.slice(0, Math.max(0, cap - 1)).trimEnd()}…`;
21
+ }
22
+ function printable(value, cap) {
23
+ try {
24
+ return capped(typeof value === 'string' ? value : JSON.stringify(value), cap);
25
+ }
26
+ catch {
27
+ return capped(String(value), cap);
28
+ }
29
+ }
30
+ /** Serialize only the causal material around one group, with fixed per-piece
31
+ * and total budgets so a verbose tool result cannot dominate the light-model call. */
32
+ export function serializeToolGroup(previousBoundaryProse, pieces) {
33
+ const lines = [`Previous visible message: ${capped(previousBoundaryProse, BOUNDARY_CAP) || '(none)'}`, 'Between-message activity:'];
34
+ for (const piece of pieces) {
35
+ if (piece.type === 'tool')
36
+ lines.push(`- tool ${piece.name}(${printable(piece.args, TOOL_ARGS_CAP)})`);
37
+ else if (piece.type === 'result')
38
+ lines.push(`- ${piece.isError ? 'error' : 'result'} ${piece.name}: ${printable(piece.result, RESULT_CAP)}`);
39
+ else
40
+ lines.push(`- context: ${capped(piece.text, TEXT_CAP)}`);
41
+ }
42
+ return capped(lines.join('\n'), SEGMENT_CAP);
43
+ }
44
+ // Tool mechanics, identifiers, and inbox bookkeeping produce a machine log rather
45
+ // than the glanceable human recap this surface needs, so the prompt asks only for
46
+ // very short outcomes while the structured fields retain the underlying stats.
47
+ const SUMMARY_SYSTEM_PROMPT = 'Create a structured recap of this coding-agent tool activity. ' +
48
+ 'Prefer 1–3 bullets and use at most 5 only for distinct meaningful outcomes. ' +
49
+ 'Each bullet must be an extremely readable plain-English outcome of 3–10 words, without markdown markers. ' +
50
+ 'Describe what changed or was accomplished, not the tools, commands, or investigation steps; for code work, say what capability was added to a one- or two-phrase plain-English part of the system, such as "Added scheduling controls to the worker service." ' +
51
+ 'Never include IDs, hashes, paths, filenames, command names, tool names, raw values, or report text. ' +
52
+ 'Omit inbox receipt, delivery, and queueing; node-response and report bookkeeping; help lookups; status checks; and summary-generation activity. ' +
53
+ 'A node response may support a substantive outcome, but its arrival or resummarization is never itself an outcome. ' +
54
+ 'If no meaningful outcome remains after those omissions, submit the single bullet "No meaningful changes." ' +
55
+ 'Count nodes created and distinct files created, modified, or deleted only when evidenced by the supplied tool arguments or results; otherwise use zero, even when the corresponding mechanics are omitted from bullets. ' +
56
+ 'Do not invent success when a result shows failure. ' +
57
+ 'Call submit_summary exactly once with bullets, nodesSpawned, and filesEdited, then stop.';
58
+ function summaryArgs(segment) {
59
+ return [
60
+ '-p',
61
+ '--no-session',
62
+ '--no-context-files',
63
+ '--no-extensions',
64
+ '--no-skills',
65
+ '--no-prompt-templates',
66
+ '--no-themes',
67
+ '-e', SUMMARY_TOOL_PATH,
68
+ '--tools', 'submit_summary',
69
+ '--mode', 'text',
70
+ '--thinking', 'off',
71
+ '--model', lightModel(),
72
+ '--system-prompt', SUMMARY_SYSTEM_PROMPT,
73
+ `<tool-group>\n${segment}\n</tool-group>\n\nSummarize the activity above.`,
74
+ ];
75
+ }
76
+ /** Generate a structured summary object, or null on every failure. */
77
+ export function headlessToolGroupSummary(segment) {
78
+ return new Promise((resolve) => {
79
+ let dir = null;
80
+ try {
81
+ dir = mkdtempSync(join(tmpdir(), 'crtr-tool-summary-'));
82
+ const resultFile = join(dir, 'summary.json');
83
+ const done = (value) => {
84
+ if (dir !== null) {
85
+ try {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ }
88
+ catch { /* temp cleanup */ }
89
+ }
90
+ resolve(value);
91
+ };
92
+ const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...summaryArgs(segment)], {
93
+ encoding: 'utf8',
94
+ timeout: SUMMARY_TIMEOUT_MS,
95
+ env: { ...bundledPiSubprocessEnv(), CRTR_SUMMARY_RESULT_FILE: resultFile },
96
+ }, () => {
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(resultFile, 'utf8'));
99
+ done(normalizeToolGroupSummary(parsed));
100
+ }
101
+ catch {
102
+ done(null);
103
+ }
104
+ });
105
+ child.stdin?.end();
106
+ }
107
+ catch {
108
+ if (dir !== null) {
109
+ try {
110
+ rmSync(dir, { recursive: true, force: true });
111
+ }
112
+ catch { /* temp cleanup */ }
113
+ }
114
+ resolve(null);
115
+ }
116
+ });
117
+ }
@@ -0,0 +1,12 @@
1
+ import { type RenderedDoc } from './termrender.js';
2
+ export declare function isMarkdownFile(file: string): boolean;
3
+ /** Fence info string for a source file — `text` when the extension is unknown,
4
+ * which every highlighter degrades to plain text on. */
5
+ export declare function fenceLanguageFor(file: string): string;
6
+ /** Wrap source text as a fenced block. The fence is grown past the longest
7
+ * backtick run in the content, so a file that itself contains ``` can't close
8
+ * its own block. */
9
+ export declare function codeFenceDocument(content: string, language: string): string;
10
+ /** Render a source file as a syntax-highlighted code panel whose spans are
11
+ * source lines — the code-file counterpart of `renderReviewDoc`. */
12
+ export declare function codeRenderedDoc(content: string, language: string, width: number): RenderedDoc;
@@ -0,0 +1,91 @@
1
+ import { extname } from 'node:path';
2
+ import { renderMarkdownWithMap } from './termrender.js';
3
+ // ── Source files as review documents ─────────────────────────────────────────
4
+ //
5
+ // A review ticket may point at a .ts/.py/.sql source file, not just a .md
6
+ // artifact. Every surface still wants ONE rendering story, so a source file is
7
+ // presented as a single fenced code block: termrender (Pygments) and the
8
+ // browser (highlight.js) then syntax-highlight it for free, and the fence's
9
+ // language comes from the file extension.
10
+ //
11
+ // The one cost is a line shift: the fence's opening line makes source line N
12
+ // land on line N+1 of the wrapped document. `codeRenderedDoc` folds that back
13
+ // out so every span/anchor the surfaces expose is a real SOURCE line number.
14
+ /** Extension → fence info string. Names are the ones Pygments (termrender) and
15
+ * highlight.js (browser) both accept, so one map serves every surface. */
16
+ const FENCE_BY_EXT = {
17
+ '.ts': 'typescript', '.mts': 'typescript', '.cts': 'typescript', '.tsx': 'tsx',
18
+ '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'jsx',
19
+ '.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.java': 'java',
20
+ '.c': 'c', '.h': 'c', '.cc': 'cpp', '.cpp': 'cpp', '.hpp': 'cpp', '.cs': 'csharp',
21
+ '.swift': 'swift', '.kt': 'kotlin', '.php': 'php', '.lua': 'lua', '.pl': 'perl',
22
+ '.sh': 'bash', '.bash': 'bash', '.zsh': 'bash', '.fish': 'fish',
23
+ '.sql': 'sql', '.json': 'json', '.jsonc': 'json', '.yaml': 'yaml', '.yml': 'yaml',
24
+ '.toml': 'toml', '.ini': 'ini', '.xml': 'xml', '.html': 'html', '.htm': 'html',
25
+ '.css': 'css', '.scss': 'scss', '.less': 'less', '.vim': 'vim', '.el': 'lisp',
26
+ '.ex': 'elixir', '.exs': 'elixir', '.erl': 'erlang', '.hs': 'haskell',
27
+ '.scala': 'scala', '.dart': 'dart', '.r': 'r', '.jl': 'julia', '.zig': 'zig',
28
+ '.tf': 'terraform', '.proto': 'protobuf', '.graphql': 'graphql', '.gql': 'graphql',
29
+ '.dockerfile': 'dockerfile', '.diff': 'diff', '.patch': 'diff', '.csv': 'text',
30
+ '.txt': 'text', '.log': 'text', '.env': 'bash', '.mk': 'makefile',
31
+ };
32
+ /** Markdown extensions render as a DOCUMENT (headings, tables, Mermaid); every
33
+ * other text file renders as code. */
34
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.mkd']);
35
+ export function isMarkdownFile(file) {
36
+ return MARKDOWN_EXTENSIONS.has(extname(file).toLowerCase());
37
+ }
38
+ /** Fence info string for a source file — `text` when the extension is unknown,
39
+ * which every highlighter degrades to plain text on. */
40
+ export function fenceLanguageFor(file) {
41
+ const ext = extname(file).toLowerCase();
42
+ if (ext === '') {
43
+ // Extensionless but conventionally-named files still have obvious types.
44
+ const base = file.slice(file.lastIndexOf('/') + 1).toLowerCase();
45
+ if (base === 'dockerfile')
46
+ return 'dockerfile';
47
+ if (base === 'makefile')
48
+ return 'makefile';
49
+ return 'text';
50
+ }
51
+ return FENCE_BY_EXT[ext] ?? 'text';
52
+ }
53
+ /** Wrap source text as a fenced block. The fence is grown past the longest
54
+ * backtick run in the content, so a file that itself contains ``` can't close
55
+ * its own block. */
56
+ export function codeFenceDocument(content, language) {
57
+ let longest = 0;
58
+ for (const run of content.matchAll(/`+/g))
59
+ longest = Math.max(longest, run[0].length);
60
+ const fence = '`'.repeat(Math.max(3, longest + 1));
61
+ const body = content.endsWith('\n') ? content.slice(0, -1) : content;
62
+ return `${fence}${language}\n${body}\n${fence}\n`;
63
+ }
64
+ /** Shift a rendered wrapped-document map back onto SOURCE line numbers: the
65
+ * opening fence occupies line 1, so document line N is source line N-1.
66
+ *
67
+ * Rows that touch a fence line are the panel's border/title chrome, not
68
+ * source: they lose BOTH their span and their block row so nothing anchors to
69
+ * them — otherwise the top border would be a step in j/k that selects the
70
+ * whole file. */
71
+ function foldFenceOffset(doc, sourceLineCount) {
72
+ const closingFenceLine = sourceLineCount + 2;
73
+ const clamp = (line) => Math.max(1, Math.min(line - 1, sourceLineCount));
74
+ const chrome = doc.spans.map((span) => span !== null && (span[0] <= 1 || span[1] >= closingFenceLine));
75
+ return {
76
+ lines: doc.lines,
77
+ rows: doc.rows.map((row, index) => (chrome[index] === true ? null : row)),
78
+ spans: doc.spans.map((span, index) => {
79
+ if (span === null || chrome[index] === true)
80
+ return null;
81
+ return [clamp(span[0]), clamp(span[1])];
82
+ }),
83
+ blocks: doc.blocks.map((block) => ({ type: block.type, start: clamp(block.start), end: clamp(block.end) })),
84
+ };
85
+ }
86
+ /** Render a source file as a syntax-highlighted code panel whose spans are
87
+ * source lines — the code-file counterpart of `renderReviewDoc`. */
88
+ export function codeRenderedDoc(content, language, width) {
89
+ const sourceLineCount = content.split('\n').length;
90
+ return foldFenceOffset(renderMarkdownWithMap(codeFenceDocument(content, language), width), sourceLineCount);
91
+ }
@@ -0,0 +1,12 @@
1
+ /** Options for `display()` — the live-watch tmux pane surface. The pane always
2
+ * watches the file and live-updates on edits; there is no non-watched mode. */
3
+ export interface DisplayOpts {
4
+ /** `'auto'` (default) splits until the pane budget, then opens a new window. */
5
+ window?: 'auto' | 'split' | 'new';
6
+ /** Pane budget per window before `'auto'` opens a new window. Default 3. */
7
+ maxPanes?: number;
8
+ }
9
+ export declare function countPanesInCurrentWindow(): number;
10
+ export declare function display(path: string, opts?: DisplayOpts): {
11
+ paneId?: string;
12
+ };
@@ -0,0 +1,19 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { displayInPane } from './termrender.js';
3
+ export function countPanesInCurrentWindow() {
4
+ // -t '' targets the current window of the current session.
5
+ const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
6
+ encoding: 'utf8',
7
+ });
8
+ if (result.status !== 0)
9
+ return 0;
10
+ return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
11
+ }
12
+ export function display(path, opts) {
13
+ const window = (opts?.window === 'split' || opts?.window === 'new') ? opts.window : 'auto';
14
+ const maxPanes = (opts?.maxPanes !== undefined && opts.maxPanes > 0) ? opts.maxPanes : 3;
15
+ const newWindow = window === 'new' ||
16
+ (window === 'auto' && countPanesInCurrentWindow() >= maxPanes);
17
+ // The pane always watches the file — displayed docs are live by definition.
18
+ return displayInPane(path, { newWindow });
19
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Memoized, self-healing. Guarantees at most one authoritative renderer
3
+ * lifecycle per process: trust a valid stamp outright (zero subprocess spawns),
4
+ * else run ONE provision/verify/publish transition under the exclusive lock.
5
+ * There is no permanent legacy-verifier fallback — the spawn-based verify
6
+ * (`binaryOk` + `installedVersion`) is a step INSIDE that single transition,
7
+ * always followed by publishing the stamp, never a lasting alternate path.
8
+ *
9
+ * Single degradation path: `uv` absent → one stderr remediation line + plaintext.
10
+ * win32 → plaintext (no renderer).
11
+ *
12
+ * Invoked at postinstall AND lazily on the first render/check/display call,
13
+ * so `npm ci --ignore-scripts` consumers still self-heal on first use.
14
+ */
15
+ export declare function ensureRenderer(): void;
16
+ /** Cheap predicate — true when the pinned managed binary is verified ready. Does not install or spawn. */
17
+ export declare function isRendererReady(): boolean;
18
+ /** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
19
+ export declare function renderMarkdown(md: string, width: number): string[];
20
+ export interface RenderedDoc {
21
+ /** Rendered ANSI rows — byte-identical to the plain `doc render` output. */
22
+ lines: string[];
23
+ /** Per-row index into `blocks`; null for the blank separator rows between blocks. */
24
+ rows: (number | null)[];
25
+ /** Per-row finest 1-indexed inclusive source-line range the renderer knows
26
+ * (a bullet, a table row, a code line…); null for separator rows and rows
27
+ * of unmapped blocks. Same length as `lines`. */
28
+ spans: ([number, number] | null)[];
29
+ /** Per top-level block: its termrender block type plus 1-indexed inclusive
30
+ * source-line bounds. Never empty. */
31
+ blocks: {
32
+ type: string;
33
+ start: number;
34
+ end: number;
35
+ }[];
36
+ }
37
+ /** Render markdown with a row→source-line map via the pinned binary
38
+ * (`doc render --line-map`); plaintext paragraph-block fallback. */
39
+ export declare function renderMarkdownWithMap(md: string, width: number): RenderedDoc;
40
+ /**
41
+ * The shared block-aware mapped render: prose blocks keep the readability cap
42
+ * (`proseWidth`), while diagram blocks are re-rendered at the pane's full
43
+ * `paneWidth` and spliced back in by block index. Both surfaces (terminal
44
+ * review and the ask deck) go through this, so "which blocks may be wide" is
45
+ * decided once, from the renderer's own block types, never by heuristics on
46
+ * the emitted rows.
47
+ *
48
+ * Row order, block list and source spans are the narrow render's; only the
49
+ * rows OF a wide block are replaced, so anchoring stays source-based and
50
+ * unchanged.
51
+ */
52
+ export declare function renderMarkdownBlockAware(md: string, proseWidth: number, paneWidth: number): RenderedDoc;
53
+ /** Block-aware render as plain rows, for surfaces that do not anchor. */
54
+ export declare function renderMarkdownBlockAwareLines(md: string, proseWidth: number, paneWidth: number): string[];
55
+ /** Validate markdown via `termrender doc check`. */
56
+ export declare function checkMarkdown(md: string): {
57
+ ok: true;
58
+ } | {
59
+ ok: false;
60
+ error: string;
61
+ };
62
+ export interface DisplayInPaneOpts {
63
+ /** Open in a new tmux window instead of splitting the current one. */
64
+ newWindow?: boolean;
65
+ }
66
+ /**
67
+ * Spawn termrender into a live tmux pane. The pane-budget policy (whether to
68
+ * split vs open a new window) is decided by the caller (`src/surfaces/
69
+ * display.ts`); this is the thin managed-binary spawn it delegates to.
70
+ */
71
+ export declare function displayInPane(path: string, opts?: DisplayInPaneOpts): {
72
+ paneId?: string;
73
+ };