@north-light/crouter 0.3.204 → 0.3.205

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 (93) hide show
  1. package/dist/api/dto/broker.d.ts +2 -0
  2. package/dist/api/dto/nodes.d.ts +8 -5
  3. package/dist/api/index.d.ts +1 -0
  4. package/dist/api/index.js +1 -0
  5. package/dist/clients/attach/__tests__/context-message.test.js +54 -19
  6. package/dist/clients/attach/__tests__/page-block.test.d.ts +1 -0
  7. package/dist/clients/attach/__tests__/page-block.test.js +54 -0
  8. package/dist/clients/attach/render/chat-view.d.ts +3 -0
  9. package/dist/clients/attach/render/chat-view.js +56 -27
  10. package/dist/clients/attach/render/context-message.d.ts +6 -0
  11. package/dist/clients/attach/render/context-message.js +21 -3
  12. package/dist/clients/attach/render/group-activity.d.ts +5 -3
  13. package/dist/clients/attach/render/group-activity.js +10 -6
  14. package/dist/clients/attach/render/page-block.js +11 -11
  15. package/dist/clients/attach/viewer.js +717 -703
  16. package/dist/clients/conversation/projection.js +7 -37
  17. package/dist/commands/node/create.js +1 -1
  18. package/dist/commands/profile/project.js +1 -1
  19. package/dist/commands/profile/show.js +1 -1
  20. package/dist/commands/profile.js +1 -1
  21. package/dist/core/__tests__/canvas-inbox-watcher.test.js +88 -21
  22. package/dist/core/__tests__/human-deliver.test.js +32 -4
  23. package/dist/core/__tests__/serial/broker-snapshot-history.test.js +5 -2
  24. package/dist/core/__tests__/serial/deferred-no-wake.test.js +7 -4
  25. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -1
  26. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +4 -1
  27. package/dist/core/__tests__/serial/revive.test.js +8 -3
  28. package/dist/core/__tests__/session-cycles.test.js +10 -6
  29. package/dist/core/canvas/render-source.js +3 -0
  30. package/dist/core/feed/feed.d.ts +7 -1
  31. package/dist/core/feed/feed.js +8 -7
  32. package/dist/core/feed/inbox.d.ts +16 -12
  33. package/dist/core/feed/inbox.js +56 -56
  34. package/dist/core/human/page-eval.d.ts +9 -4
  35. package/dist/core/human/page-eval.js +3 -1
  36. package/dist/core/human/page-markdown.d.ts +3 -0
  37. package/dist/core/human/page-markdown.js +295 -0
  38. package/dist/core/human/page.js +4 -0
  39. package/dist/core/runtime/broker/client-registry.d.ts +1 -0
  40. package/dist/core/runtime/broker/client-registry.js +1 -0
  41. package/dist/core/runtime/broker/fault-retry.js +8 -3
  42. package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
  43. package/dist/core/runtime/broker/frame-dispatch.js +4 -2
  44. package/dist/core/runtime/broker/inbox.d.ts +14 -2
  45. package/dist/core/runtime/broker/inbox.js +52 -25
  46. package/dist/core/runtime/broker/passive.d.ts +1 -0
  47. package/dist/core/runtime/broker/tool-groups.js +3 -2
  48. package/dist/core/runtime/broker-protocol.d.ts +9 -0
  49. package/dist/core/runtime/broker-protocol.js +11 -0
  50. package/dist/core/runtime/broker.d.ts +2 -2
  51. package/dist/core/runtime/broker.js +31 -6
  52. package/dist/core/runtime/deliver-live.js +2 -1
  53. package/dist/core/runtime/kickoff.d.ts +3 -4
  54. package/dist/core/runtime/kickoff.js +23 -13
  55. package/dist/core/runtime/node-read.d.ts +2 -0
  56. package/dist/core/runtime/node-read.js +11 -3
  57. package/dist/core/runtime/session-cycles.d.ts +6 -1
  58. package/dist/core/runtime/session-cycles.js +17 -11
  59. package/dist/core/runtime/session-visibility.d.ts +7 -12
  60. package/dist/core/runtime/session-visibility.js +7 -13
  61. package/dist/core/runtime/situational-live.js +2 -1
  62. package/dist/core/runtime/stop-guard.js +8 -4
  63. package/dist/core/runtime/warm-pool.d.ts +1 -1
  64. package/dist/core/runtime/warm-pool.js +6 -9
  65. package/dist/daemon/api/handlers/nodes.js +10 -7
  66. package/dist/daemon/human/finish.js +44 -4
  67. package/dist/daemon/messaging/node-message.d.ts +5 -0
  68. package/dist/daemon/messaging/node-message.js +6 -1
  69. package/dist/daemon/review/comment-notify.js +8 -0
  70. package/dist/daemon/review/deliver.js +2 -2
  71. package/dist/daemon/review/finish.js +1 -0
  72. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
  73. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
  74. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
  75. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
  76. package/dist/pi-extensions/broker-local.d.ts +0 -2
  77. package/dist/pi-extensions/broker-local.js +0 -2
  78. package/dist/pi-extensions/canvas-context-intro.js +4 -3
  79. package/dist/pi-extensions/canvas-goal-capture.js +10 -6
  80. package/dist/pi-extensions/canvas-inbox-watcher.js +36 -25
  81. package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
  82. package/dist/pi-extensions/canvas-passive-context.js +23 -3
  83. package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
  84. package/dist/pi-extensions/canvas-review-boundary.js +11 -3
  85. package/dist/pi-extensions/canvas-stophook.js +3 -3
  86. package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
  87. package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
  88. package/dist/shared/generated-context.d.ts +57 -36
  89. package/dist/shared/generated-context.js +273 -174
  90. package/dist/shared/tool-groups.js +3 -2
  91. package/package.json +1 -1
  92. package/runtime.lock.json +2 -2
  93. package/scripts/postinstall.mjs +9 -0
@@ -24,6 +24,8 @@ export interface BrokerErrorFrame {
24
24
  */
25
25
  export interface BrokerWelcomeSnapshot<M = unknown> {
26
26
  messages: M[];
27
+ /** Stable session-entry ids aligned 1:1 with `messages`. */
28
+ messageIds?: string[];
27
29
  state?: {
28
30
  isStreaming?: boolean;
29
31
  };
@@ -5,12 +5,13 @@ export interface CreateNodeRequest {
5
5
  prompt?: string;
6
6
  profile?: string;
7
7
  mode?: ModeDTO;
8
- /** The DIRECTORY CONTEXT the create came from — not necessarily where the
9
- * node lands. It is what the daemon selects a profile from when the request
10
- * neither names one nor inherits one from a parent. */
8
+ /** The directory the create came from. It is what the daemon selects a
9
+ * profile from when the request neither names one nor inherits one from a
10
+ * parent, and — for a node with no `pin_cwd` and no spawner to inherit from —
11
+ * where the node lands. */
11
12
  cwd?: string;
12
- /** Pin the node to this exact directory, overriding the profile home a node
13
- * otherwise runs in. See `crtr node new --cwd`. */
13
+ /** Pin the node to this exact directory, overriding both the spawner's
14
+ * directory and the launch cwd. See `crtr node new --cwd`. */
14
15
  pin_cwd?: string;
15
16
  /** Display name (tmux window + resume picker). Defaults to the kind. */
16
17
  name?: string;
@@ -187,6 +188,8 @@ export interface NodeMessagesPageDTO {
187
188
  node_id: NodeIdDTO;
188
189
  /** pi AgentMessage[] JSON, chronological ascending within this page. */
189
190
  messages: unknown[];
191
+ /** Stable session-entry ids aligned 1:1 with `messages`. */
192
+ message_ids?: string[];
190
193
  /** Opaque cursor toward older messages; null at the start of the session. */
191
194
  next_cursor: Cursor | null;
192
195
  captured_at: IsoTime;
@@ -3,6 +3,7 @@ export type { CrtrClientOptions } from './client.js';
3
3
  export { ApiError, isErrorBody } from './errors.js';
4
4
  export type { ErrorBody } from './errors.js';
5
5
  export { API_VERSION, routes } from './routes.js';
6
+ export * from '../shared/generated-context.js';
6
7
  export * from './dto/common.js';
7
8
  export * from './dto/health.js';
8
9
  export * from './dto/nodes.js';
package/dist/api/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  export { CrtrClient } from './client.js';
5
5
  export { ApiError, isErrorBody } from './errors.js';
6
6
  export { API_VERSION, routes } from './routes.js';
7
+ export * from '../shared/generated-context.js';
7
8
  export * from './dto/common.js';
8
9
  export * from './dto/health.js';
9
10
  export * from './dto/nodes.js';
@@ -1,22 +1,27 @@
1
1
  // Regression coverage for the attach viewer's display-only folding of
2
- // crouter-generated context. Two user-visible hazards are pinned here:
2
+ // crouter-generated context. Three user-visible hazards are pinned here:
3
3
  //
4
4
  // 1. A folded row must never leak its body, and Ctrl+O must restore the full
5
5
  // agent-visible content — the fold is display-only, so nothing is lost.
6
6
  // 2. A human message that merely reads like runtime recovery prose must NOT
7
7
  // be classified as generated context; folding the user's own message away
8
8
  // is the hazard that misclassification produces.
9
+ // 3. An answer the person typed in their inbox renders as the person
10
+ // speaking, never as a runtime card.
9
11
  import assert from 'node:assert/strict';
10
12
  import test from 'node:test';
11
13
  import { Container } from '@earendil-works/pi-tui';
12
14
  import { applyTheme } from '../config.js';
13
15
  import { ChatView } from '../render/chat-view.js';
14
- import { CONTEXT_INTRO_CUSTOM_TYPE, CONTEXT_NUDGE_CUSTOM_TYPE, REVIVE_KICKOFF_SENTINEL, RUNTIME_RESTART_CONTINUATION, formatModelFallbackRecovery, formatReviewApproval, generatedContextPresentation, } from '../../../shared/generated-context.js';
16
+ import { CONTEXT_INTRO_CUSTOM_TYPE, CONTEXT_NUDGE_CUSTOM_TYPE, formatCard, formatInboxCard, formatModelFallbackRecovery, parseCard, } from '../../../shared/generated-context.js';
15
17
  applyTheme();
18
+ function chatView(container) {
19
+ const tui = { requestRender() { }, terminal: { rows: 40 } };
20
+ return new ChatView(tui, container, { toolOutputExpanded: false });
21
+ }
16
22
  test('collapsed generated context never leaks its body; Ctrl+O restores it', async () => {
17
23
  const container = new Container();
18
- const tui = { requestRender() { }, terminal: { rows: 40 } };
19
- const view = new ChatView(tui, container, { toolOutputExpanded: false });
24
+ const view = chatView(container);
20
25
  const internals = view;
21
26
  internals.summarizeToolCalls = true;
22
27
  internals.foldSettledTools = true;
@@ -27,41 +32,71 @@ test('collapsed generated context never leaks its body; Ctrl+O restores it', asy
27
32
  role: 'custom',
28
33
  customType: CONTEXT_INTRO_CUSTOM_TYPE,
29
34
  display: true,
30
- content: '<auto-loaded-context>\nloaded INDEX.md content\n</auto-loaded-context>',
35
+ content: formatCard('bearings', {}, '<auto-loaded-context>\nloaded INDEX.md content\n</auto-loaded-context>'),
31
36
  },
32
- { role: 'user', content: `${REVIVE_KICKOFF_SENTINEL}\n<yield-message>resume from this note</yield-message>` },
33
- { role: 'user', content: RUNTIME_RESTART_CONTINUATION },
37
+ { role: 'user', content: formatCard('revive', {}, '<yield-message>resume from this note</yield-message>') },
38
+ { role: 'user', content: formatCard('restart-continuation', {}, 'private restart continuation') },
39
+ // Pre-envelope custom message from an old session: customType still classifies.
34
40
  {
35
41
  role: 'custom',
36
42
  customType: CONTEXT_NUDGE_CUSTOM_TYPE,
37
43
  display: true,
38
44
  content: '[crtr] Context ~150k. private context reminder',
39
45
  },
40
- { role: 'user', content: 'From child-node-123 — 1 update:\n [update] private inbox detail' },
41
- { role: 'user', content: formatReviewApproval('The human reviewed and approved private-review.md.') },
46
+ {
47
+ role: 'user',
48
+ content: formatInboxCard([
49
+ { id: 'child-node-123', name: 'child', entries: [{ kind: 'update', body: 'private inbox detail' }] },
50
+ ]),
51
+ },
52
+ { role: 'user', content: formatCard('review-approval', {}, 'The human reviewed and approved private-review.md.') },
42
53
  ],
43
54
  stats: {},
44
55
  state: { isStreaming: false },
45
56
  });
46
- assert.doesNotMatch(container.render(80).join('\n'), /loaded INDEX\.md content|resume from this note|runtime-restart-continuation|private context reminder|private inbox detail|private-review\.md/);
57
+ assert.doesNotMatch(container.render(80).join('\n'), /loaded INDEX\.md content|resume from this note|private restart continuation|private context reminder|private inbox detail|private-review\.md/);
47
58
  assert.deepEqual(view.toggleToolsExpanded(), { kind: 'summaries', shown: false });
48
59
  assert.equal(internals.toolOutputExpanded, true);
49
60
  const expanded = container.render(80).join('\n');
50
61
  assert.match(expanded, /loaded INDEX\.md content/);
51
62
  assert.match(expanded, /resume from this note/);
52
- assert.doesNotMatch(expanded, /runtime-restart-continuation|private context reminder/);
63
+ assert.doesNotMatch(expanded, /private restart continuation|private context reminder/);
53
64
  assert.match(expanded, /private inbox detail/, 'an expanded inbox card shows the actual delivered update');
54
65
  assert.match(expanded, /private-review\.md/, 'an expanded review card shows the approval');
55
66
  assert.deepEqual(view.toggleToolsExpanded(), { kind: 'summaries', shown: true });
56
67
  assert.equal(internals.toolOutputExpanded, false);
57
- assert.doesNotMatch(container.render(80).join('\n'), /loaded INDEX\.md content|resume from this note|runtime-restart-continuation|private context reminder|private inbox detail/);
68
+ assert.doesNotMatch(container.render(80).join('\n'), /loaded INDEX\.md content|resume from this note|private restart continuation|private context reminder|private inbox detail/);
58
69
  });
59
70
  test('a human message that reads like runtime recovery is not folded as generated context', () => {
60
- assert.equal(generatedContextPresentation({ role: 'user', content: 'continue' }), null, 'a human one-word continue is not mistaken for runtime recovery');
61
- // The producer marker, not the prose, is what classifies a message. Strip the
62
- // marker lines off a real recovery body and it must read as a human message.
63
- const marked = formatModelFallbackRecovery('custom(provider)/model', 'new/model:high', 'not-found');
64
- assert.equal(generatedContextPresentation({ role: 'user', content: marked })?.kind, 'recovery');
65
- assert.equal(generatedContextPresentation({ role: 'user', content: marked.split('\n').slice(1, -1).join('\n') }), null, 'a human message matching the recovery prose lacks the producer marker');
66
- assert.equal(generatedContextPresentation({ role: 'user', content: 'The human reviewed and approved private-review.md.' }), null, 'a human message matching the approval prose lacks the producer marker');
71
+ assert.equal(parseCard({ role: 'user', content: 'continue' }), null, 'a human one-word continue is not mistaken for runtime recovery');
72
+ // The producer's envelope, not the prose, is what classifies a message. Strip
73
+ // the envelope off a real recovery body and it must read as a human message.
74
+ const guidance = formatModelFallbackRecovery('custom(provider)/model', 'new/model:high', 'not-found');
75
+ const recovery = formatCard('recovery', { reason: 'model-fallback' }, guidance);
76
+ assert.equal(parseCard({ role: 'user', content: recovery })?.kind, 'recovery');
77
+ assert.equal(parseCard({ role: 'user', content: guidance }), null, 'a human message matching the recovery prose lacks the envelope');
78
+ assert.equal(parseCard({ role: 'user', content: 'The human reviewed and approved private-review.md.' }), null, 'a human message matching the approval prose lacks the envelope');
79
+ });
80
+ test("a person's inbox answer renders as their own message, not a runtime card", async () => {
81
+ const container = new Container();
82
+ const view = chatView(container);
83
+ await view.applySnapshot({
84
+ messages: [
85
+ {
86
+ role: 'user',
87
+ content: formatInboxCard([
88
+ {
89
+ id: 'human-ticket-node',
90
+ name: 'human interaction',
91
+ entries: [{ kind: 'final', disposition: 'human-answer', body: 'ship it on friday' }],
92
+ },
93
+ ]),
94
+ },
95
+ ],
96
+ stats: {},
97
+ state: { isStreaming: false },
98
+ });
99
+ const rendered = container.render(80).join('\n');
100
+ assert.match(rendered, /ship it on friday/, "the person's words show without expanding anything");
101
+ assert.doesNotMatch(rendered, /crtr inbox|ctrl\+o/, 'an answer-only digest gets no card');
67
102
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, utimesSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { atomicWriteJson, pageManifestPath, pagePath } from '../../../core/human/convention.js';
7
+ import { derivePageManifestFromDocument } from '../../../core/human/page.js';
8
+ import { sanitize } from '../../inbox/tui/ansi.js';
9
+ import { PageBlockComponent } from '../render/page-block.js';
10
+ function proposal(intro) {
11
+ return `const DOCS = ['Local stack', 'Guest home', 'Data and dependencies', 'Troubleshooting'];
12
+ function Proposal({ docs }) {
13
+ return (
14
+ <Card>
15
+ <p>Move the <code>{docs.length}</code> operational areas behind focused pointers.</p>
16
+ <ul>{docs.map((doc) => <li key={doc}><strong>{doc}</strong> reference</li>)}</ul>
17
+ </Card>
18
+ );
19
+ }
20
+ export default function DevMemorySplit() {
21
+ return (
22
+ <Page title="Northlight dev memory" subtitle="Choose the documentation shape">
23
+ <p>${intro}</p>
24
+ <Proposal docs={DOCS} />
25
+ <UserQuestion id="decision" label="Approve the split?" body="The current file is 4,189 words." mode="single" options={[{ id: 'approve', label: 'Approve' }, { id: 'keep', label: 'Keep one file' }]} />
26
+ </Page>
27
+ );
28
+ }
29
+ `;
30
+ }
31
+ test('an inline page renders dynamic display JSX beside its manifest question and refreshes source-only revisions', () => {
32
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-page-block-'));
33
+ const source = proposal('The current guide mixes routing and operational detail.');
34
+ const manifest = derivePageManifestFromDocument(source);
35
+ writeFileSync(pagePath(dir), source);
36
+ atomicWriteJson(pageManifestPath(dir), manifest);
37
+ const block = new PageBlockComponent('dev-memory', dir, () => 'open it');
38
+ const rendered = sanitize(block.render(88).join('\n'));
39
+ assert.match(rendered, /The current guide mixes routing and operational detail\./);
40
+ assert.match(rendered, /Move the 4 operational areas behind focused pointers\./);
41
+ assert.match(rendered, /Local stack.*reference/);
42
+ assert.match(rendered, /Troubleshooting.*reference/);
43
+ assert.match(rendered, /Approve the split\?/);
44
+ assert.match(rendered, /The current file is 4,189 words\./);
45
+ assert.match(rendered, /Keep one file/);
46
+ const revised = proposal('The replacement keeps the intent router short.');
47
+ writeFileSync(pagePath(dir), revised);
48
+ const future = new Date(Date.now() + 2_000);
49
+ utimesSync(pagePath(dir), future, future);
50
+ assert.equal(block.refresh(), true);
51
+ const refreshed = sanitize(block.render(88).join('\n'));
52
+ assert.match(refreshed, /The replacement keeps the intent router short\./);
53
+ assert.doesNotMatch(refreshed, /mixes routing and operational detail/);
54
+ });
@@ -245,6 +245,9 @@ export declare class ChatView {
245
245
  private appendAssistantSeparator;
246
246
  private appendAssistantPart;
247
247
  private appendUserPart;
248
+ /** An answer the person typed in their inbox renders exactly like a message
249
+ * they typed here, and closes the run around it the same way. */
250
+ private appendHumanAnswer;
248
251
  /** Only the full-fidelity trailing region gets summary groups. Condensed
249
252
  * history intentionally drops tool traffic, so grouping there would create
250
253
  * phantom summary rows and cross the live/condensed boundary. */
@@ -23,11 +23,11 @@
23
23
  import { Container, Loader, Spacer, Text } from '@earendil-works/pi-tui';
24
24
  import { AssistantMessageComponent, BashExecutionComponent, BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomMessageComponent, parseSkillBlock, SkillInvocationMessageComponent, ToolExecutionComponent, UserMessageComponent, } from '@earendil-works/pi-coding-agent';
25
25
  import { attachMarkdownTheme } from '../config.js';
26
- import { ContextMessageComponent } from './context-message.js';
26
+ import { ContextMessageComponent, contextCardBody, expandsWithTools } from './context-message.js';
27
27
  import { detectInlinePageResult, PageBlockComponent } from './page-block.js';
28
28
  import { CRTR_OUTPUT_CUSTOM_TYPE, CrtrOutputMessageComponent, createCrtrBashToolDefinition, } from './crtr-output.js';
29
29
  import { CRTR_CYCLE_DIVIDER_CUSTOM_TYPE, endedByAbort } from '../../../core/runtime/session-cycles.js';
30
- import { generatedContextExpandedBody, generatedContextPresentation, isRuntimeRestartContinuation, } from '../../../shared/generated-context.js';
30
+ import { parseCard } from '../../../shared/generated-context.js';
31
31
  import { assistantVisibleText, isTrueUserMessage, } from '../../../shared/tool-groups.js';
32
32
  import { transformDiagramFences } from './diagram.js';
33
33
  import { styleAttachAgentMarkdown, styleAttachMarkdownSource, styleAttachMessageMarkdown, styleAttachSummaryMarkdown, } from './markdown-source.js';
@@ -167,6 +167,9 @@ function stripBackground(line) {
167
167
  return kept.length > 0 ? `\x1b[${kept.join(';')}m` : '';
168
168
  });
169
169
  }
170
+ /** Sender sections of an inbox card body; `parseCard` already read their entries.
171
+ * Whatever remains is other content riding the same digest. */
172
+ const INBOX_SENDER_SECTIONS = /<from\b[^>]*>[\s\S]*?<\/from>/g;
170
173
  /** A tool component that can fold itself down to its CALL. pi's `ToolExecutionComponent`
171
174
  * only knows expanded/collapsed, so each crtr-owned definition receives a local fold
172
175
  * controller and captures the compact call component it renders. Folded render paints
@@ -1024,33 +1027,49 @@ export class ChatView {
1024
1027
  addMessageToChat(message) {
1025
1028
  // Crouter-generated context retains its runtime role (user vs custom) so
1026
1029
  // delivery to the model is unchanged, but every attached view presents it
1027
- // through one compact disclosure contract. The shared classifier uses the
1028
- // producers' exact custom types/sentinels, so ordinary user prompts cannot
1029
- // accidentally acquire this treatment and saved transcripts gain it too.
1030
- const generated = generatedContextPresentation(message);
1031
- if (generated !== null &&
1030
+ // through one compact disclosure contract. The producer's envelope is what
1031
+ // classifies, so an ordinary user prompt cannot acquire this treatment and
1032
+ // saved transcripts gain it too.
1033
+ const card = parseCard(message);
1034
+ if (card !== null &&
1032
1035
  (message.role !== 'custom' || message.display === true)) {
1033
- this.pushTranscriptText(generated.body);
1034
- const component = new ContextMessageComponent(generatedContextExpandedBody(generated), this.toolOutputExpanded, this.dimStyle, (s) => this.spinnerStyle(this.labelStyle(s)), generated.label, ` ${generated.summary} — ctrl+o to expand`, generated.label === 'crtr context' || generated.kind === 'revive' || generated.kind === 'inbox' || generated.kind === 'review-approval');
1035
- // A successor broker's exact restart continuation is a visible seam, not
1036
- // a group member: the preceding span keeps its own recap and the next tool
1037
- // call begins a broker-aligned fresh group.
1038
- if (isRuntimeRestartContinuation(message)) {
1039
- this.closeOpenGroup();
1040
- this.append(component);
1041
- // Bearings and revive kickoffs only belong to an already-open run; they
1042
- // never create a summary block by themselves. Other injected context may.
1043
- }
1044
- else if (generated.kind === 'bearings' || generated.kind === 'revive') {
1045
- this.appendExistingGroupMember(component);
1046
- }
1047
- else {
1048
- const group = this.appendGroupMember(component);
1049
- // A child's final arrives as an inbox digest, not a tool call: without
1050
- // this the folded recap would hide the one row saying it finished.
1051
- if (generated.kind === 'inbox')
1052
- group?.activity.noteInboxDigest(generated.body);
1036
+ this.pushTranscriptText(card.body);
1037
+ // A person's inbox answer is the person speaking: their words leave the
1038
+ // card and render as their own message. A digest carrying nothing else
1039
+ // gets no card at all — but a passive backlog folded into the same body
1040
+ // is content, so the residue check keeps it from vanishing with the card.
1041
+ const answers = card.senders
1042
+ .flatMap((sender) => sender.entries)
1043
+ .filter((entry) => entry.disposition === 'human-answer')
1044
+ .map((entry) => entry.body.trim())
1045
+ .filter((text) => text !== '');
1046
+ const onlyHumanWords = answers.length > 0
1047
+ && card.body.replace(INBOX_SENDER_SECTIONS, '').trim() === ''
1048
+ && card.senders.every((sender) => sender.entries.every((entry) => entry.disposition === 'human-answer'));
1049
+ if (!onlyHumanWords) {
1050
+ const component = new ContextMessageComponent(contextCardBody(card), this.toolOutputExpanded, this.dimStyle, (s) => this.spinnerStyle(this.labelStyle(s)), card.label, ` ${card.summary} — ctrl+o to expand`, expandsWithTools(card));
1051
+ // A successor broker's exact restart continuation is a visible seam, not
1052
+ // a group member: the preceding span keeps its own recap and the next tool
1053
+ // call begins a broker-aligned fresh group.
1054
+ if (card.kind === 'restart-continuation') {
1055
+ this.closeOpenGroup();
1056
+ this.append(component);
1057
+ // Bearings and revive kickoffs only belong to an already-open run; they
1058
+ // never create a summary block by themselves. Other injected context may.
1059
+ }
1060
+ else if (card.kind === 'bearings' || card.kind === 'revive') {
1061
+ this.appendExistingGroupMember(component);
1062
+ }
1063
+ else {
1064
+ const group = this.appendGroupMember(component);
1065
+ // A child's final arrives as an inbox digest, not a tool call: without
1066
+ // this the folded recap would hide the one row saying it finished.
1067
+ if (card.kind === 'inbox')
1068
+ group?.activity.noteInboxDigest(card);
1069
+ }
1053
1070
  }
1071
+ for (const answer of answers)
1072
+ this.appendHumanAnswer(answer);
1054
1073
  return;
1055
1074
  }
1056
1075
  switch (message.role) {
@@ -1193,6 +1212,16 @@ export class ChatView {
1193
1212
  else
1194
1213
  this.append(component);
1195
1214
  }
1215
+ /** An answer the person typed in their inbox renders exactly like a message
1216
+ * they typed here, and closes the run around it the same way. */
1217
+ appendHumanAnswer(text) {
1218
+ this.closeOpenGroup();
1219
+ if (this.chatChildCount() > 0)
1220
+ this.append(new Spacer(1));
1221
+ const component = new UserMessageComponent(text, attachMarkdownTheme(), undefined, this.markdownTransformers);
1222
+ this.append(component);
1223
+ this.registerCopyBlock(component, 'user', text);
1224
+ }
1196
1225
  /** Only the full-fidelity trailing region gets summary groups. Condensed
1197
1226
  * history intentionally drops tool traffic, so grouping there would create
1198
1227
  * phantom summary rows and cross the live/condensed boundary. */
@@ -1,4 +1,10 @@
1
1
  import { type Component } from '@earendil-works/pi-tui';
2
+ import type { GeneratedCard } from '../../../shared/generated-context.js';
3
+ export declare function expandsWithTools(card: GeneratedCard): boolean;
4
+ /** What a card discloses when expanded. Bearings and a revive kickoff carry a
5
+ * long mandate around one region worth re-reading; every other card discloses
6
+ * the body the agent was given. */
7
+ export declare function contextCardBody(card: GeneratedCard): string;
2
8
  export declare class ContextMessageComponent implements Component {
3
9
  private readonly dim;
4
10
  private readonly label;
@@ -5,9 +5,9 @@
5
5
  // receives: collapsed → a padded dim line; expanded (Ctrl+O) → the ordinary
6
6
  // user-message surface, regardless of the message's runtime role. Selected
7
7
  // cards implement `setExpanded` for the global tool-display cycle; other cards
8
- // remain folded while tool output is shown. Classification and disclosure
9
- // selection live in shared/generated-context.ts; this component only renders
10
- // the presentation it is handed.
8
+ // remain folded while tool output is shown. Classification lives in
9
+ // shared/generated-context.ts; this module owns only what a parsed card
10
+ // discloses and how it renders.
11
11
  import { UserMessageComponent } from '@earendil-works/pi-coding-agent';
12
12
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
13
13
  import { attachMarkdownTheme } from '../config.js';
@@ -15,6 +15,24 @@ import { styleAttachMarkdownSource } from './markdown-source.js';
15
15
  /** Default label/hint for the `crtr-context` bearings block. */
16
16
  const LABEL = 'crtr context';
17
17
  const COLLAPSED_HINT = ' orienting bearings — ctrl+o to expand';
18
+ const AUTO_LOADED_CONTEXT_BLOCK = /<auto-loaded-context>([\s\S]*?)<\/auto-loaded-context>/;
19
+ const YIELD_MESSAGE_BLOCK = /<yield-message>([\s\S]*?)<\/yield-message>/;
20
+ /** Cards the global ctrl+o cycle expands; every other card stays folded while
21
+ * tool output is shown. */
22
+ const EXPANDS_WITH_TOOLS = new Set(['bearings', 'revive', 'inbox', 'review-approval']);
23
+ export function expandsWithTools(card) {
24
+ return EXPANDS_WITH_TOOLS.has(card.kind);
25
+ }
26
+ /** What a card discloses when expanded. Bearings and a revive kickoff carry a
27
+ * long mandate around one region worth re-reading; every other card discloses
28
+ * the body the agent was given. */
29
+ export function contextCardBody(card) {
30
+ if (card.kind === 'bearings')
31
+ return AUTO_LOADED_CONTEXT_BLOCK.exec(card.body)?.[1]?.trim() ?? '';
32
+ if (card.kind === 'revive')
33
+ return YIELD_MESSAGE_BLOCK.exec(card.body)?.[1]?.trim() ?? '';
34
+ return card.body;
35
+ }
18
36
  export class ContextMessageComponent {
19
37
  dim;
20
38
  label;
@@ -1,3 +1,4 @@
1
+ import type { GeneratedCard } from '../../../shared/generated-context.js';
1
2
  export type FileActivity = {
2
3
  key: string;
3
4
  displayPath: string;
@@ -38,9 +39,10 @@ export declare class GroupActivityRecorder {
38
39
  /** `roster` is the ChatView-wide node id → display name record: spawns fill
39
40
  * it, and a later group's finish reads the name a sibling group recorded. */
40
41
  constructor(cwd: string, roster?: Map<string, string>);
41
- /** Record the nodes an inbox digest reports finished. The digest names senders
42
- * by node id, so the spawn roster supplies the display name when it has one. */
43
- noteInboxDigest(body: string): void;
42
+ /** Record the nodes an inbox digest reports finished. The spawn roster supplies
43
+ * the display name when it has one. A sender carrying only the person's own
44
+ * words is the human speaking, not a node that finished. */
45
+ noteInboxDigest(card: GeneratedCard): void;
44
46
  noteCall(id: string, name: string, args: unknown): void;
45
47
  settle(id: string, result: unknown, isError: boolean): void;
46
48
  resolveWithoutResult(id: string): void;
@@ -3,7 +3,6 @@
3
3
  // calls into the exact evidence already present in the session transcript.
4
4
  import { homedir } from 'node:os';
5
5
  import { join, resolve } from 'node:path';
6
- import { inboxFinishedSenders } from '../../../shared/generated-context.js';
7
6
  import { countDisplayDiffLines } from './edit-diff.js';
8
7
  const EMPTY_ACTIVITY = Object.freeze({
9
8
  files: Object.freeze([]),
@@ -162,11 +161,16 @@ export class GroupActivityRecorder {
162
161
  this.cwd = cwd;
163
162
  this.roster = roster;
164
163
  }
165
- /** Record the nodes an inbox digest reports finished. The digest names senders
166
- * by node id, so the spawn roster supplies the display name when it has one. */
167
- noteInboxDigest(body) {
168
- for (const id of inboxFinishedSenders(body)) {
169
- this.addNode({ action: 'finished', name: this.roster.get(id) ?? id, id });
164
+ /** Record the nodes an inbox digest reports finished. The spawn roster supplies
165
+ * the display name when it has one. A sender carrying only the person's own
166
+ * words is the human speaking, not a node that finished. */
167
+ noteInboxDigest(card) {
168
+ for (const sender of card.senders) {
169
+ const onlyHumanWords = sender.entries.length > 0
170
+ && sender.entries.every((entry) => entry.disposition === 'human-answer');
171
+ if (!sender.finished || onlyHumanWords)
172
+ continue;
173
+ this.addNode({ action: 'finished', name: this.roster.get(sender.id) ?? sender.id, id: sender.id });
170
174
  }
171
175
  }
172
176
  noteCall(id, name, args) {
@@ -11,9 +11,10 @@
11
11
  // result text, so `--replace` revisions and the answer that settles a page both
12
12
  // land on the already-placed block when `refresh()` is driven by the viewer's
13
13
  // inbox-activity watcher.
14
- import { statSync } from 'node:fs';
14
+ import { readFileSync, statSync } from 'node:fs';
15
+ import { projectPageDisplayMarkdown } from '../../../core/human/page-markdown.js';
15
16
  import { parsePage } from '../../../core/human/page.js';
16
- import { pageManifestPath, responsePath } from '../../../core/human/convention.js';
17
+ import { pageManifestPath, pagePath, responsePath } from '../../../core/human/convention.js';
17
18
  import { pageTicketState, readTicketResult } from '../../../core/human/tickets.js';
18
19
  import { renderMarkdownBlockAwareLines } from '../../../core/termrender/termrender.js';
19
20
  import { describeSlot, tableMarkdown, terminalAnswerable } from '../../inbox/tui/slots.js';
@@ -101,7 +102,7 @@ export class PageBlockComponent {
101
102
  this.cache = undefined;
102
103
  }
103
104
  diskStamp() {
104
- return `${mtimeOf(pageManifestPath(this.dir))}|${mtimeOf(responsePath(this.dir))}`;
105
+ return `${mtimeOf(pageManifestPath(this.dir))}|${mtimeOf(pagePath(this.dir, 'jsx'))}|${mtimeOf(pagePath(this.dir, 'html'))}|${mtimeOf(responsePath(this.dir))}`;
105
106
  }
106
107
  reload() {
107
108
  this.stamp = this.diskStamp();
@@ -109,10 +110,14 @@ export class PageBlockComponent {
109
110
  this.cache = undefined;
110
111
  try {
111
112
  const manifest = parsePage(this.dir);
113
+ const displayMarkdown = manifest.dialect === 'jsx'
114
+ ? projectPageDisplayMarkdown(readFileSync(pagePath(this.dir, 'jsx'), 'utf8'), manifest)
115
+ : '';
112
116
  const state = pageTicketState(this.dir);
113
117
  const digest = state === 'resolved' ? readTicketResult(this.dir) : null;
114
118
  this.snapshot = {
115
119
  manifest,
120
+ displayMarkdown,
116
121
  state,
117
122
  ...(digest?.kind === 'page' ? { answerDigest: digest.summary.replace(/\s+/g, ' ').trim() } : {}),
118
123
  };
@@ -134,7 +139,7 @@ export class PageBlockComponent {
134
139
  for (const line of wrap(sanitize(snapshot.manifest.subtitle), maxW))
135
140
  out.push(` ${DIM}${line}${RESET}`);
136
141
  }
137
- const body = this.bodyLines(snapshot.manifest, maxW, Math.max(20, width - 4));
142
+ const body = this.bodyLines(snapshot.manifest, snapshot.displayMarkdown, maxW, Math.max(20, width - 4));
138
143
  if (body.length > 0) {
139
144
  out.push('');
140
145
  const shown = this.expanded || body.length <= FOLDED_BODY_LINES ? body : body.slice(0, FOLDED_BODY_LINES);
@@ -149,16 +154,11 @@ export class PageBlockComponent {
149
154
  out.push('');
150
155
  return out;
151
156
  }
152
- bodyLines(manifest, maxW, paneW) {
157
+ bodyLines(manifest, displayMarkdown, maxW, paneW) {
153
158
  const md = (text) => renderMarkdownBlockAwareLines(text, maxW, paneW);
154
159
  if (manifest.dialect === 'html')
155
160
  return [`${DIM}HTML page — opens in a browser surface${RESET}`];
156
- // Display components (Card, Alert, the rest of the shadcn set) are page markup,
157
- // not manifest slots, so a page built only from them keeps nothing here a
158
- // terminal could draw. Say so rather than paint a title over an empty body.
159
- if (manifest.slots.length === 0)
160
- return [`${DIM}display-only page — its content renders in Northlight${RESET}`];
161
- const lines = [];
161
+ const lines = displayMarkdown === '' ? [] : md(displayMarkdown);
162
162
  let priorStep = -1;
163
163
  for (const slot of manifest.slots) {
164
164
  if (manifest.steps > 1 && slot.step !== priorStep) {