@north-light/crouter 0.3.192 → 0.3.194

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 (190) hide show
  1. package/dist/api/client.d.ts +31 -10
  2. package/dist/api/client.js +83 -24
  3. package/dist/api/dto/bash-jobs.d.ts +24 -0
  4. package/dist/api/dto/bash-jobs.js +9 -0
  5. package/dist/api/dto/human.d.ts +3 -4
  6. package/dist/api/dto/inbox.d.ts +106 -65
  7. package/dist/api/dto/inbox.js +2 -8
  8. package/dist/api/index.d.ts +1 -0
  9. package/dist/api/index.js +1 -0
  10. package/dist/api/routes.d.ts +7 -0
  11. package/dist/api/routes.js +7 -0
  12. package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
  13. package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
  14. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  15. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
  20. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
  21. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  22. package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
  23. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  24. package/dist/clients/attach/input/overlay-owner.js +1 -1
  25. package/dist/clients/attach/overlays/auth.js +34 -8
  26. package/dist/clients/attach/overlays/pickers.d.ts +5 -8
  27. package/dist/clients/attach/overlays/pickers.js +37 -43
  28. package/dist/clients/attach/viewer.js +643 -620
  29. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
  30. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
  31. package/dist/clients/inbox/controller.d.ts +2 -3
  32. package/dist/clients/inbox/controller.js +41 -42
  33. package/dist/clients/inbox/page-adapter.d.ts +33 -0
  34. package/dist/clients/inbox/page-adapter.js +54 -0
  35. package/dist/clients/inbox/resolve.d.ts +2 -2
  36. package/dist/clients/inbox/resolve.js +1 -1
  37. package/dist/clients/inbox/review-visibility.d.ts +1 -1
  38. package/dist/clients/inbox/review-visibility.js +1 -1
  39. package/dist/clients/inbox/tui/ansi.d.ts +1 -1
  40. package/dist/clients/inbox/tui/ansi.js +2 -2
  41. package/dist/clients/inbox/tui/input.d.ts +1 -3
  42. package/dist/clients/inbox/tui/input.js +242 -553
  43. package/dist/clients/inbox/tui/panel.d.ts +3 -1
  44. package/dist/clients/inbox/tui/panel.js +59 -187
  45. package/dist/clients/inbox/tui/render.d.ts +0 -25
  46. package/dist/clients/inbox/tui/render.js +165 -416
  47. package/dist/clients/inbox/tui/slots.d.ts +32 -0
  48. package/dist/clients/inbox/tui/slots.js +156 -0
  49. package/dist/clients/inbox/tui/types.d.ts +25 -13
  50. package/dist/clients/inbox/tui.js +5 -3
  51. package/dist/commands/attention.js +2 -2
  52. package/dist/commands/human/doc.js +66 -20
  53. package/dist/commands/human/prompts.d.ts +3 -3
  54. package/dist/commands/human/prompts.js +158 -112
  55. package/dist/commands/human/queue.d.ts +0 -1
  56. package/dist/commands/human/queue.js +34 -94
  57. package/dist/commands/human/shared.d.ts +10 -4
  58. package/dist/commands/human/shared.js +62 -13
  59. package/dist/commands/human.js +8 -4
  60. package/dist/commands/memory/read.js +13 -5
  61. package/dist/commands/node/bash.js +18 -30
  62. package/dist/commands/node/lifecycle.js +2 -2
  63. package/dist/commands/surface/node/focus.js +1 -1
  64. package/dist/core/__tests__/human-deliver.test.js +40 -46
  65. package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
  66. package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
  67. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
  68. package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
  69. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
  70. package/dist/core/auth-interaction.d.ts +2 -2
  71. package/dist/core/bash-jobs.d.ts +43 -0
  72. package/dist/core/bash-jobs.js +96 -2
  73. package/dist/core/canvas/__tests__/attention.test.js +16 -10
  74. package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
  75. package/dist/core/canvas/attention.js +1 -1
  76. package/dist/core/canvas/browse/app.js +1 -1
  77. package/dist/core/canvas/nav-model.d.ts +1 -1
  78. package/dist/core/canvas/nav-model.js +1 -1
  79. package/dist/core/canvas/remote-canvas-source.js +1 -1
  80. package/dist/core/canvas/types.d.ts +2 -1
  81. package/dist/core/config.js +4 -1
  82. package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
  83. package/dist/core/human/__tests__/page-render.test.js +49 -0
  84. package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
  85. package/dist/core/human/__tests__/page-tickets.test.js +112 -0
  86. package/dist/core/human/__tests__/page.test.d.ts +1 -0
  87. package/dist/core/human/__tests__/page.test.js +139 -0
  88. package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
  89. package/dist/core/human/claim.js +2 -2
  90. package/dist/core/human/convention.d.ts +4 -4
  91. package/dist/core/human/convention.js +6 -13
  92. package/dist/core/human/markdown-html.d.ts +8 -0
  93. package/dist/core/human/markdown-html.js +581 -0
  94. package/dist/core/human/page-catalog.d.ts +6 -0
  95. package/dist/core/human/page-catalog.js +43 -0
  96. package/dist/core/human/page-render.d.ts +3 -0
  97. package/dist/core/human/page-render.js +131 -0
  98. package/dist/core/human/page-schema.d.ts +312 -0
  99. package/dist/core/human/page-schema.js +291 -0
  100. package/dist/core/human/page-synth.d.ts +9 -0
  101. package/dist/core/human/page-synth.js +17 -0
  102. package/dist/core/human/page.d.ts +22 -0
  103. package/dist/core/human/page.js +427 -0
  104. package/dist/core/human/review-schema.d.ts +19 -0
  105. package/dist/core/human/review-schema.js +34 -0
  106. package/dist/core/human/scan.d.ts +12 -3
  107. package/dist/core/human/scan.js +69 -26
  108. package/dist/core/human/summary.d.ts +5 -6
  109. package/dist/core/human/summary.js +54 -37
  110. package/dist/core/human/tickets.d.ts +61 -11
  111. package/dist/core/human/tickets.js +100 -57
  112. package/dist/core/human/types.d.ts +21 -66
  113. package/dist/core/human/types.js +1 -5
  114. package/dist/core/inspector/model.d.ts +5 -0
  115. package/dist/core/inspector/model.js +5 -0
  116. package/dist/core/inspector/text.js +2 -2
  117. package/dist/core/inspector/tui.js +10 -3
  118. package/dist/core/keybindings/catalog.d.ts +1 -1
  119. package/dist/core/keybindings/catalog.js +4 -2
  120. package/dist/core/preview-registry.js +0 -1
  121. package/dist/core/review/ticket-filter.d.ts +1 -1
  122. package/dist/core/review/ticket-filter.js +2 -8
  123. package/dist/core/runtime/broker/read-ops.js +3 -0
  124. package/dist/core/runtime/broker.js +19 -10
  125. package/dist/core/runtime/package-health.js +1 -1
  126. package/dist/core/runtime/persona.js +2 -1
  127. package/dist/core/runtime/tmux-bindings.js +8 -1
  128. package/dist/core/termrender/termrender.d.ts +1 -1
  129. package/dist/core/termrender/termrender.js +2 -2
  130. package/dist/core/termrender/version.d.ts +1 -1
  131. package/dist/core/termrender/version.js +1 -1
  132. package/dist/core/tui/page-host.d.ts +1 -1
  133. package/dist/core/tui/page-host.js +2 -2
  134. package/dist/core/user-settings.d.ts +19 -0
  135. package/dist/core/user-settings.js +22 -0
  136. package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
  137. package/dist/daemon/api/handlers/bash-jobs.js +90 -0
  138. package/dist/daemon/api/handlers/human.js +16 -14
  139. package/dist/daemon/api/handlers/inbox.js +311 -227
  140. package/dist/daemon/api/router.d.ts +5 -1
  141. package/dist/daemon/api/router.js +5 -0
  142. package/dist/daemon/api/server.js +2 -0
  143. package/dist/daemon/cron-run.js +23 -10
  144. package/dist/daemon/human/finish.d.ts +8 -3
  145. package/dist/daemon/human/finish.js +35 -15
  146. package/dist/daemon/reconcilers/broker-supervision.js +7 -1
  147. package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
  148. package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
  149. package/dist/daemon/reconcilers/live-obligation.js +11 -0
  150. package/dist/pages/bundle.css +1 -0
  151. package/dist/pages/bundle.js +1347 -0
  152. package/dist/pages/comments.d.ts +34 -0
  153. package/dist/pages/comments.js +72 -0
  154. package/dist/pages/elements/cards.d.ts +17 -0
  155. package/dist/pages/elements/cards.js +785 -0
  156. package/dist/pages/elements/chart.d.ts +1 -0
  157. package/dist/pages/elements/chart.js +648 -0
  158. package/dist/pages/elements/options.d.ts +21 -0
  159. package/dist/pages/elements/options.js +604 -0
  160. package/dist/pages/elements/pages.d.ts +24 -0
  161. package/dist/pages/elements/pages.js +355 -0
  162. package/dist/pages/elements/slot.d.ts +21 -0
  163. package/dist/pages/elements/slot.js +105 -0
  164. package/dist/pages/elements/table.d.ts +19 -0
  165. package/dist/pages/elements/table.js +906 -0
  166. package/dist/pages/elements/text.d.ts +25 -0
  167. package/dist/pages/elements/text.js +951 -0
  168. package/dist/pages/entry.d.ts +8 -0
  169. package/dist/pages/entry.js +8 -0
  170. package/dist/pages/host.d.ts +132 -0
  171. package/dist/pages/host.js +149 -0
  172. package/dist/pages/register.d.ts +2 -0
  173. package/dist/pages/register.js +3 -0
  174. package/dist/pages/slot-config.d.ts +36 -0
  175. package/dist/pages/slot-config.js +50 -0
  176. package/dist/pages/types.d.ts +98 -0
  177. package/dist/pages/types.js +23 -0
  178. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
  179. package/dist/pi-extensions/canvas-bash-valve.js +25 -7
  180. package/dist/types.d.ts +4 -0
  181. package/dist/types.js +2 -0
  182. package/package.json +8 -8
  183. package/runtime.lock.json +156 -101
  184. package/scripts/install-runtime.mjs +172 -5
  185. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  186. package/dist/clients/inbox/deck-adapter.js +0 -63
  187. package/dist/core/human/deck-factories.d.ts +0 -8
  188. package/dist/core/human/deck-factories.js +0 -15
  189. package/dist/core/human/deck-schema.d.ts +0 -70
  190. package/dist/core/human/deck-schema.js +0 -92
@@ -4,16 +4,15 @@
4
4
  // delivery is daemon-owned; no subprocess handler or delivery receipt exists.
5
5
  import { test, before, beforeEach, afterEach, after } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
- import { mkdtempSync, existsSync, writeFileSync } from 'node:fs';
7
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { ApiError, CrtrClient } from '../../../api/index.js';
11
11
  import { createApiServer } from '../../../daemon/api/server.js';
12
12
  import { apiSocketPath } from '../../canvas/paths.js';
13
- import { validateDeck } from '../../human/deck-schema.js';
14
13
  import { claimTicket, readTicketClaim } from '../../human/claim.js';
15
14
  import { ticketDir } from '../../human/root.js';
16
- import { submitDeck, submitReview } from '../../human/tickets.js';
15
+ import { submitPage, submitReview } from '../../human/tickets.js';
17
16
  import { spawnNode } from '../../runtime/nodes.js';
18
17
  import { getNode, closeDb } from '../../canvas/index.js';
19
18
  import { readInboxSince } from '../../feed/inbox.js';
@@ -63,6 +62,30 @@ async function expectBadResolve(nodeId, body) {
63
62
  async function expectReviewSubmitGuidance(nodeId, body) {
64
63
  await assert.rejects(() => client.request('POST', `/v1/human/tickets/${nodeId}/resolve`, body), (error) => error instanceof ApiError && error.status === 400 && error.code === 'invalid_request' && error.message.includes('POST /v1/human/reviews/:review_id/submit'));
65
64
  }
65
+ /** Publish a one-text-slot page ticket into the bridge's ticket directory. */
66
+ function submitBridgePage(bridge) {
67
+ const dir = ticketDir(bridge);
68
+ const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-e2e-page-'));
69
+ const sourceFile = join(sourceDir, 'page.md');
70
+ writeFileSync(sourceFile, [
71
+ '---',
72
+ 'title: Ship it?',
73
+ 'subtitle: Decide whether to ship now so the release can proceed.',
74
+ '---',
75
+ '',
76
+ '```slot:text',
77
+ '{ "id": "question", "initialText": "", "editable": true }',
78
+ '```',
79
+ '',
80
+ ].join('\n'));
81
+ try {
82
+ submitPage({ dir, sourceFile, dialect: 'md' });
83
+ }
84
+ finally {
85
+ rmSync(sourceDir, { recursive: true, force: true });
86
+ }
87
+ return dir;
88
+ }
66
89
  function reportBody(ref) {
67
90
  const resolved = resolveRef(ref);
68
91
  if (resolved === null || resolved.source !== 'report')
@@ -71,24 +94,17 @@ function reportBody(ref) {
71
94
  }
72
95
  test('malformed resolves leave the mounted ticket claim in place', async () => {
73
96
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
74
- const deckBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-deck', lifecycle: 'terminal' }).node_id;
75
- const deckDir = ticketDir(deckBridge);
76
- submitDeck({
77
- dir: deckDir,
78
- deck: validateDeck({
79
- title: 'Ship it?',
80
- interactions: [{ id: 'question', title: 'Ship it?', subtitle: 'Decide whether to ship now so the release can proceed.', options: [], allowFreetext: true }],
81
- }),
82
- });
83
- const deckClaim = claimTicket(deckDir);
84
- assert.ok(deckClaim !== null);
85
- for (const body of [{ responses: [null] }, { response: [] }, { responses: [], debug: true }]) {
86
- await expectBadResolve(deckBridge, body);
87
- assert.equal(readTicketClaim(deckDir)?.token, deckClaim.token, 'malformed deck input must not take over the live claim');
97
+ const pageBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
98
+ const pageDir = submitBridgePage(pageBridge);
99
+ const pageClaim = claimTicket(pageDir);
100
+ assert.ok(pageClaim !== null);
101
+ for (const body of [{ responses: [] }, { response: {} }, { responses: {}, debug: true }]) {
102
+ await expectBadResolve(pageBridge, body);
103
+ assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'malformed page input must not take over the live claim');
88
104
  }
89
- for (const body of [{ responses: [], feedback: {} }, { feedback: [] }]) {
90
- await expectReviewSubmitGuidance(deckBridge, body);
91
- assert.equal(readTicketClaim(deckDir)?.token, deckClaim.token, 'feedback-shaped input must not take over the live claim');
105
+ for (const body of [{ responses: {}, feedback: {} }, { feedback: [] }]) {
106
+ await expectReviewSubmitGuidance(pageBridge, body);
107
+ assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'feedback-shaped input must not take over the live claim');
92
108
  }
93
109
  const reviewBridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
94
110
  const reviewDir = ticketDir(reviewBridge);
@@ -97,22 +113,15 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
97
113
  submitReview({ dir: reviewDir, review: { title: 'Review', subtitle: 'Review this file so implementation can proceed safely.', source: {}, file } });
98
114
  const reviewClaim = claimTicket(reviewDir);
99
115
  assert.ok(reviewClaim !== null);
100
- await expectReviewSubmitGuidance(reviewBridge, { responses: [] });
116
+ await expectReviewSubmitGuidance(reviewBridge, { responses: {} });
101
117
  assert.equal(readTicketClaim(reviewDir)?.token, reviewClaim.token, 'review ticket resolve must not take over the live claim');
102
118
  });
103
119
  test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
104
120
  const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
105
121
  const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
106
- const dir = ticketDir(bridge);
107
- submitDeck({
108
- dir,
109
- deck: validateDeck({
110
- title: 'Ship it?',
111
- interactions: [{ id: 'question', title: 'Ship it?', subtitle: 'Decide whether to ship now so the release can proceed.', options: [], allowFreetext: true }],
112
- }),
113
- });
114
- const result = await client.resolveHumanTicket(bridge, { responses: [{ id: 'question', freetext: 'yes, ship' }] });
115
- assert.deepEqual(result, { delivered: true, kind: 'deck' });
122
+ const dir = submitBridgePage(bridge);
123
+ const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true, comments: [] } } });
124
+ assert.deepEqual(result, { delivered: true, kind: 'page' });
116
125
  assert.equal(getNode(bridge)?.status, 'done', 'bridge finalized by crtrd');
117
126
  const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
118
127
  assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
@@ -8,6 +8,7 @@ import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { createServer } from 'node:net';
10
10
  import { spawn as spawnProcess } from 'node:child_process';
11
+ import { findDaemonPids, stopDaemonProcess } from '../../../daemon/manage.js';
11
12
  import { createNode, getNode, getRow, subscribersOf } from '../../canvas/canvas.js';
12
13
  import { closeDb } from '../../canvas/db.js';
13
14
  import { nodeDir } from '../../canvas/paths.js';
@@ -40,8 +41,9 @@ beforeEach(() => {
40
41
  closeDb();
41
42
  rmSync(home, { recursive: true, force: true });
42
43
  });
43
- after(() => {
44
+ after(async () => {
44
45
  closeDb();
46
+ await Promise.all(findDaemonPids().map((pid) => stopDaemonProcess(pid)));
45
47
  rmSync(home, { recursive: true, force: true });
46
48
  });
47
49
  test('managed child: parent on the spine + active subscription + provenance', () => {
@@ -18,6 +18,7 @@ import { closeDb } from '../canvas/db.js';
18
18
  import { spawnNode } from '../runtime/nodes.js';
19
19
  import { spawnChild } from '../runtime/spawn.js';
20
20
  import { managedWorktreePath } from '../worktree.js';
21
+ import { findDaemonPids, stopDaemonProcess } from '../../daemon/manage.js';
21
22
  let home;
22
23
  let repoDir;
23
24
  function run(args, cwd) {
@@ -41,8 +42,9 @@ beforeEach(() => {
41
42
  rmSync(home, { recursive: true, force: true });
42
43
  process.env['CRTR_HOME'] = home;
43
44
  });
44
- after(() => {
45
+ after(async () => {
45
46
  closeDb();
47
+ await Promise.all(findDaemonPids().map((pid) => stopDaemonProcess(pid)));
46
48
  rmSync(home, { recursive: true, force: true });
47
49
  rmSync(repoDir, { recursive: true, force: true });
48
50
  });
@@ -1,4 +1,4 @@
1
- import type { AuthInteraction } from '@earendil-works/pi-ai';
1
+ import type { ProviderAuthInteraction } from '@earendil-works/pi-ai';
2
2
  import type { LoginDialogComponent } from '@earendil-works/pi-coding-agent';
3
3
  export type DialogInteractionOptions = {
4
4
  /** Open the verification URL for `auth_url` / `device_code` events. */
@@ -16,4 +16,4 @@ export type DialogInteractionOptions = {
16
16
  * the dialog occupies crouter's single inline slot in both surfaces, so swapping in pi's
17
17
  * separate selector component (what pi's own interactive mode does) has nowhere to go.
18
18
  */
19
- export declare function dialogAuthInteraction(dialog: LoginDialogComponent, options?: DialogInteractionOptions): AuthInteraction;
19
+ export declare function dialogAuthInteraction(dialog: LoginDialogComponent, options?: DialogInteractionOptions): ProviderAuthInteraction;
@@ -12,12 +12,53 @@ export interface BashJobPaths {
12
12
  * Inspector's cancel) — without it on disk, only the agent's own handoff
13
13
  * notice knows the group to signal. */
14
14
  jobPgid: string;
15
+ /** Human-readable label supplied with the bash call, when any. */
16
+ jobPurpose: string;
15
17
  }
18
+ /** A purpose is display text, never shell input. Keep the file-backed control
19
+ * plane to one bounded, visible line so old or hand-written job directories
20
+ * cannot inject control content into terminal or JSON presenters. */
21
+ export declare function normalizeBashJobPurpose(value: unknown): string | null;
16
22
  export declare function bashJobsDir(contextDir: string): string;
23
+ /** Whether a job id can safely be used as one path segment. Generated ids are
24
+ * numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
25
+ * older file-backed ids readable without allowing path or request delimiters. */
26
+ export declare function isSafeBashJobId(jobId: string): boolean;
17
27
  export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
18
28
  /** The job's supervisor process group, or undefined for a job started before
19
29
  * pgid was persisted (or a partially-written dir). */
20
30
  export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
31
+ /** The optional human-readable job label. Jobs created before labels existed
32
+ * have no file and deliberately project null. */
33
+ export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
34
+ /** Whether a persisted job supervisor process group is still present. EPERM
35
+ * means the group exists but cannot be signaled by this process; it is still
36
+ * live for the status surface. */
37
+ export declare function isBashJobProcessGroupAlive(pgid: number): boolean;
38
+ export type StopBackgroundBashJobResult = {
39
+ kind: 'stopped';
40
+ signaled: boolean;
41
+ pgid: number;
42
+ logPath: string;
43
+ } | {
44
+ kind: 'missing';
45
+ } | {
46
+ kind: 'retired';
47
+ logPath: string;
48
+ } | {
49
+ kind: 'not-stoppable';
50
+ logPath: string;
51
+ };
52
+ /** Stop one live background job using the same process-group and sentinel
53
+ * semantics as `crtr node bash kill`. The caller owns the user-facing
54
+ * notification; this primitive owns only the job control-plane mutation.
55
+ *
56
+ * Only a job in the live-background state the roster projects (job.bg
57
+ * present, job.exit absent) may be signaled. Job directories persist after
58
+ * the supervisor exits, so a retired, still-foreground, or already-stopped
59
+ * job would otherwise have its recorded pgid signaled long after the OS
60
+ * could have reused that id for an unrelated process group. */
61
+ export declare function stopBackgroundBashJob(contextDir: string, jobId: string): Promise<StopBackgroundBashJobResult>;
21
62
  /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
22
63
  * rather than the whole file, which for a long-running job can be enormous. */
23
64
  export declare function tailJobLog(logPath: string, count: number): string[];
@@ -40,6 +81,8 @@ export interface BashJobStatus {
40
81
  jobId: string;
41
82
  /** Full text of cmd.sh. */
42
83
  command: string;
84
+ /** Human-readable label, or null for an unlabeled or legacy job. */
85
+ purpose: string | null;
43
86
  startedAtMs: number;
44
87
  logPath: string;
45
88
  }
@@ -3,11 +3,32 @@
3
3
  // job.bg, while foreground completion renames it to job.done. The detached
4
4
  // supervisor can therefore outlive pi and still decide whether to report exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync } from 'node:fs';
6
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
+ const MAX_BASH_JOB_PURPOSE_BYTES = 280;
9
+ /** A purpose is display text, never shell input. Keep the file-backed control
10
+ * plane to one bounded, visible line so old or hand-written job directories
11
+ * cannot inject control content into terminal or JSON presenters. */
12
+ export function normalizeBashJobPurpose(value) {
13
+ if (typeof value !== 'string' || /[\p{Cc}\p{Cf}]/u.test(value))
14
+ return null;
15
+ const purpose = value.trim();
16
+ if (purpose === '')
17
+ return null;
18
+ return Buffer.byteLength(purpose, 'utf8') <= MAX_BASH_JOB_PURPOSE_BYTES ? purpose : null;
19
+ }
8
20
  export function bashJobsDir(contextDir) {
9
21
  return join(contextDir, 'jobs');
10
22
  }
23
+ /** Whether a job id can safely be used as one path segment. Generated ids are
24
+ * numeric epoch prefixes plus a hex suffix; this broader safe alphabet keeps
25
+ * older file-backed ids readable without allowing path or request delimiters. */
26
+ export function isSafeBashJobId(jobId) {
27
+ return typeof jobId === 'string'
28
+ && jobId !== '' && jobId !== '.' && jobId !== '..'
29
+ && /^[A-Za-z0-9._~-]+$/u.test(jobId)
30
+ && Buffer.byteLength(jobId, 'utf8') <= 128;
31
+ }
11
32
  export function bashJobPaths(contextDir, jobId) {
12
33
  const dir = join(bashJobsDir(contextDir), jobId);
13
34
  return {
@@ -20,6 +41,7 @@ export function bashJobPaths(contextDir, jobId) {
20
41
  jobBg: join(dir, 'job.bg'),
21
42
  jobDone: join(dir, 'job.done'),
22
43
  jobPgid: join(dir, 'job.pgid'),
44
+ jobPurpose: join(dir, 'purpose'),
23
45
  };
24
46
  }
25
47
  /** The job's supervisor process group, or undefined for a job started before
@@ -33,6 +55,78 @@ export function readJobPgid(contextDir, jobId) {
33
55
  return undefined;
34
56
  }
35
57
  }
58
+ /** The optional human-readable job label. Jobs created before labels existed
59
+ * have no file and deliberately project null. */
60
+ export function readJobPurpose(contextDir, jobId) {
61
+ try {
62
+ return normalizeBashJobPurpose(readFileSync(bashJobPaths(contextDir, jobId).jobPurpose, 'utf8'));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** Whether a persisted job supervisor process group is still present. EPERM
69
+ * means the group exists but cannot be signaled by this process; it is still
70
+ * live for the status surface. */
71
+ export function isBashJobProcessGroupAlive(pgid) {
72
+ try {
73
+ process.kill(-pgid, 0);
74
+ return true;
75
+ }
76
+ catch (err) {
77
+ return err.code === 'EPERM';
78
+ }
79
+ }
80
+ /** Stop one live background job using the same process-group and sentinel
81
+ * semantics as `crtr node bash kill`. The caller owns the user-facing
82
+ * notification; this primitive owns only the job control-plane mutation.
83
+ *
84
+ * Only a job in the live-background state the roster projects (job.bg
85
+ * present, job.exit absent) may be signaled. Job directories persist after
86
+ * the supervisor exits, so a retired, still-foreground, or already-stopped
87
+ * job would otherwise have its recorded pgid signaled long after the OS
88
+ * could have reused that id for an unrelated process group. */
89
+ export async function stopBackgroundBashJob(contextDir, jobId) {
90
+ const paths = bashJobPaths(contextDir, jobId);
91
+ if (!existsSync(paths.dir))
92
+ return { kind: 'missing' };
93
+ if (!existsSync(paths.jobBg) || existsSync(paths.jobExit))
94
+ return { kind: 'retired', logPath: paths.jobLog };
95
+ const pgid = readJobPgid(contextDir, jobId);
96
+ if (pgid === undefined)
97
+ return { kind: 'not-stoppable', logPath: paths.jobLog };
98
+ // Claim the stop by creating job.exit exclusively, before signaling. The
99
+ // loser of two concurrent stops — and a stop that races the supervisor's own
100
+ // completion — reports retired instead of signaling a departed group and
101
+ // sending the owning node a second "was stopped" notice.
102
+ try {
103
+ const fd = openSync(paths.jobExit, 'wx');
104
+ try {
105
+ writeFileSync(fd, '143\n');
106
+ }
107
+ finally {
108
+ closeSync(fd);
109
+ }
110
+ }
111
+ catch (err) {
112
+ if (err.code === 'EEXIST')
113
+ return { kind: 'retired', logPath: paths.jobLog };
114
+ throw err;
115
+ }
116
+ let signaled = true;
117
+ try {
118
+ process.kill(-pgid, 'SIGTERM');
119
+ }
120
+ catch {
121
+ signaled = false;
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, 400));
124
+ try {
125
+ process.kill(-pgid, 'SIGKILL');
126
+ }
127
+ catch { /* already gone */ }
128
+ return { kind: 'stopped', signaled, pgid, logPath: paths.jobLog };
129
+ }
36
130
  /** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
37
131
  * rather than the whole file, which for a long-running job can be enormous. */
38
132
  export function tailJobLog(logPath, count) {
@@ -122,7 +216,7 @@ export function activeBackgroundBashJobs(contextDir) {
122
216
  catch {
123
217
  continue;
124
218
  }
125
- jobs.push({ jobId, command, startedAtMs, logPath: paths.jobLog });
219
+ jobs.push({ jobId, command, purpose: readJobPurpose(contextDir, jobId), startedAtMs, logPath: paths.jobLog });
126
220
  }
127
221
  return jobs.sort((a, b) => a.startedAtMs - b.startedAtMs);
128
222
  }
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
4
4
  import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { join } from 'node:path';
7
- import { atomicWriteJson, deckPath } from '../../human/convention.js';
7
+ import { atomicWriteJson, pageManifestPath } from '../../human/convention.js';
8
8
  import { createNode } from '../canvas.js';
9
9
  import { closeDb } from '../db.js';
10
10
  import { nodeDir } from '../paths.js';
@@ -22,12 +22,18 @@ function meta(id, cwd, extra) {
22
22
  ...extra,
23
23
  };
24
24
  }
25
- function seedDeck(bridgeId) {
25
+ function seedPage(bridgeId) {
26
26
  const dir = nodeDir(bridgeId);
27
27
  mkdirSync(dir, { recursive: true });
28
- atomicWriteJson(deckPath(dir), {
28
+ atomicWriteJson(pageManifestPath(dir), {
29
+ schema: 'crtr.page/v1',
30
+ dialect: 'md',
29
31
  title: bridgeId,
30
- interactions: [{ id: 'q', title: bridgeId, subtitle: 'Acknowledge this ticket so the request can proceed.', options: [] }],
32
+ subtitle: 'Acknowledge this ticket so the request can proceed.',
33
+ placement: 'inline',
34
+ document: 'page.md',
35
+ steps: 1,
36
+ slots: [],
31
37
  });
32
38
  }
33
39
  function bridge(id, parent, cwd) {
@@ -61,10 +67,10 @@ test('ticketCountsForNodes follows bridge parents even when asking nodes share a
61
67
  bridge('ha2', 'a', sharedCwd);
62
68
  bridge('hb', 'b', sharedCwd);
63
69
  bridge('hc', 'c', soloCwd);
64
- seedDeck('ha1');
65
- seedDeck('ha2');
66
- seedDeck('hb');
67
- seedDeck('hc');
70
+ seedPage('ha1');
71
+ seedPage('ha2');
72
+ seedPage('hb');
73
+ seedPage('hc');
68
74
  assert.deepEqual(ticketCountsForNodes(['a', 'b', 'c']), { a: 2, b: 1, c: 1 });
69
75
  });
70
76
  test('pendingTicketsForView returns the asking node rather than a cwd bucket', () => {
@@ -72,8 +78,8 @@ test('pendingTicketsForView returns the asking node rather than a cwd bucket', (
72
78
  createNode(meta('b', sharedCwd));
73
79
  bridge('ha', 'a', sharedCwd);
74
80
  bridge('hb', 'b', sharedCwd);
75
- seedDeck('ha');
76
- seedDeck('hb');
81
+ seedPage('ha');
82
+ seedPage('hb');
77
83
  assert.deepEqual(pendingTicketsForView('a').map(({ node_id, count }) => ({ node_id, count })), [
78
84
  { node_id: 'a', count: 1 },
79
85
  ]);
@@ -19,7 +19,7 @@ import { createServer } from 'node:http';
19
19
  import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
20
20
  import { tmpdir } from 'node:os';
21
21
  import { join } from 'node:path';
22
- import { atomicWriteJson, deckPath } from '#core/human/convention';
22
+ import { atomicWriteJson, pageManifestPath } from '#core/human/convention';
23
23
  import { RemoteCanvasSource } from '../remote-canvas-source.js';
24
24
  import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
25
25
  import { createNode } from '../canvas.js';
@@ -113,9 +113,15 @@ function seedPoisonedLocalState() {
113
113
  createNode(meta('a', { cwd: localCwd }));
114
114
  const askDir = nodeDir('poison-ask');
115
115
  mkdirSync(askDir, { recursive: true });
116
- atomicWriteJson(deckPath(askDir), {
116
+ atomicWriteJson(pageManifestPath(askDir), {
117
+ schema: 'crtr.page/v1',
118
+ dialect: 'md',
117
119
  title: 'poison',
118
- interactions: [{ id: 'q', kind: 'ask', title: 'poison', subtitle: 'This local ticket must not affect the remote rendering.' }],
120
+ subtitle: 'This local ticket must not affect the remote rendering.',
121
+ placement: 'inline',
122
+ document: 'page.md',
123
+ steps: 1,
124
+ slots: [],
119
125
  });
120
126
  // A local focus + broker viewer — poison both `viewed` signals.
121
127
  openFocusRow('poison-focus', null, null, 'a');
@@ -3,7 +3,7 @@
3
3
  // Every ticket is a direct child of the single registered `<canvas>/nodes` root,
4
4
  // named for its terminal human bridge node. The bridge's parent is the asking
5
5
  // node, so storage identity and attribution come from the canvas graph rather
6
- // than duplicated attribution fields in deck/review data.
6
+ // than duplicated attribution fields in page/review data.
7
7
  import { basename } from 'node:path';
8
8
  import { scanInbox } from '../human/scan.js';
9
9
  import { filterTerminalReviewTickets } from '../review/ticket-filter.js';
@@ -55,7 +55,7 @@ export async function runBrowse(opts) {
55
55
  process.stdout.write(await renderForestFromSource(source) + '\n');
56
56
  return;
57
57
  }
58
- // Snapshot the canvas. Drop kind:'human' control-plane decks — they have no pi
58
+ // Snapshot the canvas. Drop kind:'human' control-plane pages — they have no pi
59
59
  // session, so `surface node focus` refuses them; they are never a navigation/resume
60
60
  // target (mirrors the surface-node focus guard).
61
61
  let rows;
@@ -3,7 +3,7 @@ import type { NodeMeta } from './index.js';
3
3
  export { PI_MAX_WIDGET_LINES, VIEWPORT_FALLBACK_ROWS, ESC, RESET, BOLD, DIM, REVERSE, BG_ATTACHED, GREEN, RED, YELLOW, CYAN, GRAY, nodeGlyph, visibleWidth, truncate, fillWidth, fillBar, readTelemetry, fmtTokens, tokensCell, activityCell, cycleBadge, navLabel, shortId, askBadge, liveBelowBadge, isAttached, hasBrokerViewers, fetchAsksMap, graphWidgetBudget, GRAPH_HINT, type Telemetry, type FlatRow, type FoldState, type SubtreeActivity, } from './nav-render.js';
4
4
  /** First manager (by created) — the UP step for the ancestry spine. */
5
5
  export declare function managerOf(id: string): string | undefined;
6
- /** A kind:'human' node is a control-plane ASK (a deck on the human's
6
+ /** A kind:'human' node is a control-plane ASK (a page on the human's
7
7
  * screen), NOT a pi conversation — it has no session, so focusing/reviving it
8
8
  * boots a confused blank "you have been revived" pi. Its pending-ask signal
9
9
  * already rides the ⚑ badge on the ASKING node (attention.ts follows the
@@ -73,7 +73,7 @@ function mgrsOf(id) {
73
73
  export function managerOf(id) {
74
74
  return mgrsOf(id)[0]?.node_id;
75
75
  }
76
- /** A kind:'human' node is a control-plane ASK (a deck on the human's
76
+ /** A kind:'human' node is a control-plane ASK (a page on the human's
77
77
  * screen), NOT a pi conversation — it has no session, so focusing/reviving it
78
78
  * boots a confused blank "you have been revived" pi. Its pending-ask signal
79
79
  * already rides the ⚑ badge on the ASKING node (attention.ts follows the
@@ -208,7 +208,7 @@ export class RemoteCanvasSource {
208
208
  }
209
209
  async ticketCountsForView(root) {
210
210
  // The `canvas attention map` transport is unchanged; its `counts` values now
211
- // mean all pending human tickets (deck/review/notify), not just asks.
211
+ // mean all pending human tickets (page/review), not just asks.
212
212
  const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
213
213
  return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
214
214
  }
@@ -11,7 +11,8 @@
11
11
  * only `dead` is a fault. */
12
12
  export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
13
13
  /** Does stopping finalize the node? terminal = worker (finalizes on push --final);
14
- * resident = manager/orchestrator (stays dormant, woken by inbox). */
14
+ * resident = interactable while attached or holding a live obligation. An
15
+ * unattended obligation-free resident completes after the daemon grace. */
15
16
  export type Lifecycle = 'terminal' | 'resident';
16
17
  /** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
17
18
  export type Mode = 'base' | 'orchestrator';
@@ -10,6 +10,7 @@ import { profileRoot } from './profiles/manifest.js';
10
10
  import { withExclusiveDirectoryLock } from './exclusive-lock.js';
11
11
  import { SCOPE_CONFIG_KEYS } from './user-settings.js';
12
12
  import { normalizeWorkingGerunds } from '../shared/working-activity.js';
13
+ import { normalizePageComponents } from './human/page-catalog.js';
13
14
  function configPathFor(root) {
14
15
  return join(root, CONFIG_FILE);
15
16
  }
@@ -478,6 +479,8 @@ function mergeConfig(partial) {
478
479
  ? [...defaults.working_gerunds]
479
480
  : normalizeWorkingGerunds(partial.working_gerunds);
480
481
  const whip_mode = typeof partial.whip_mode === 'boolean' ? partial.whip_mode : defaults.whip_mode;
482
+ const html_pages = typeof partial.html_pages === 'boolean' ? partial.html_pages : defaults.html_pages;
483
+ const page_components = normalizePageComponents(partial.page_components);
481
484
  const whip_messages = normalizeWhipMessages(partial.whip_messages);
482
485
  const mouse_mode_default = MOUSE_MODE_DEFAULTS.includes(partial.mouse_mode_default)
483
486
  ? partial.mouse_mode_default
@@ -497,7 +500,7 @@ function mergeConfig(partial) {
497
500
  const kinds = mergeKinds(partial.kinds);
498
501
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
499
502
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
500
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, whip_messages, mouse_mode_default, live_cycles, condensed_history, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
503
+ return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, auto_open_child_viewers, brokerThresholds, completion_bell, working_gerunds, whip_mode, html_pages, page_components, whip_messages, mouse_mode_default, live_cycles, condensed_history, fold_finished_tools, summarize_tool_calls, detailed_tool_recaps, keybindings, tmux_passthrough, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
501
504
  }
502
505
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
503
506
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ // Two properties of the default page template whose absence would be SILENT —
2
+ // every ordinary page still renders and still looks right when either breaks.
3
+ // Nothing else about rendering is pinned here; prose shape is the renderer's
4
+ // own business and a change there is visible the moment anyone looks at a page.
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import test from 'node:test';
10
+ import { derivePageManifest } from '../page.js';
11
+ import { renderPageHtml } from '../page-render.js';
12
+ function render(name, source) {
13
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-page-render-test-'));
14
+ try {
15
+ const path = join(dir, name);
16
+ writeFileSync(path, source);
17
+ return renderPageHtml(derivePageManifest(path, name.endsWith('.md') ? 'md' : 'html'), readFileSync(path, 'utf8'));
18
+ }
19
+ finally {
20
+ rmSync(dir, { recursive: true, force: true });
21
+ }
22
+ }
23
+ test('a config value carrying script markup cannot end its own <script> element', () => {
24
+ const html = render('page.md', [
25
+ '---', 'title: Escaping', '---', '',
26
+ 'Pick one.', '',
27
+ '```slot:options',
28
+ '{"id":"esc","mode":"single","options":[{"id":"a","label":"</script><script>x</script>"},{"id":"b","label":"<!-- c -->"}]}',
29
+ '```', '',
30
+ ].join('\n'));
31
+ const config = /<crtr-options slot-id="esc"><script type="application\/json">([\s\S]*?)<\/script>/.exec(html)?.[1];
32
+ assert.ok(config !== undefined, `no options config in payload:\n${html}`);
33
+ // The mutation this catches: dropping the `<` escape for a plain JSON.stringify.
34
+ assert.ok(!config.includes('<'), `config text still carries a raw "<": ${config}`);
35
+ assert.equal(JSON.parse(config).options[0].label, '</script><script>x</script>');
36
+ });
37
+ test('an authored html slot id is mirrored to the slot-id the runtime reads', () => {
38
+ const html = render('page.html', [
39
+ '<!doctype html><html><head><meta name="crtr-page-title" content="Sign-off"></head><body>',
40
+ '<crtr-pages><crtr-page label="Window">',
41
+ '<crtr-options id="window"><script type="application/json">{"mode":"single","options":[{"id":"sat","label":"Saturday"}]}</script></crtr-options>',
42
+ '</crtr-page></crtr-pages>',
43
+ '</body></html>',
44
+ ].join('\n'));
45
+ // Without this, every element mounts and renders and no response ever
46
+ // reaches the host, because `src/pages` keys responses by `slot-id`.
47
+ assert.match(html, /<crtr-options id="window" slot-id="window">/);
48
+ assert.match(html, /<crtr-page label="Window">/);
49
+ });
@@ -0,0 +1 @@
1
+ export {};