@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
@@ -1,44 +1,61 @@
1
+ import { isResponseBearingSlot } from './page-schema.js';
2
+ function labelsOf(items, ids, labelKey) {
3
+ const byId = new Map((Array.isArray(items) ? items : [])
4
+ .filter((item) => typeof item === 'object' && item !== null)
5
+ .map((item) => [String(item['id']), typeof item[labelKey] === 'string' ? item[labelKey] : String(item['id'])]));
6
+ return (Array.isArray(ids) ? ids : []).map((id) => byId.get(String(id)) ?? String(id));
7
+ }
8
+ function pickedWithFreetext(picked, freetext) {
9
+ const text = typeof freetext === 'string' && freetext !== '' ? freetext : undefined;
10
+ const selection = picked.length > 0 ? picked.join(', ') : undefined;
11
+ if (selection !== undefined && text !== undefined)
12
+ return `${selection} — ${text}`;
13
+ return selection ?? text ?? '(no selection)';
14
+ }
15
+ function commentSuffix(response) {
16
+ const comments = response['comments'];
17
+ const count = Array.isArray(comments) ? comments.length : 0;
18
+ return count === 0 ? '' : ` (${count} comment${count === 1 ? '' : 's'})`;
19
+ }
20
+ function describeSlotResponse(slot, response) {
21
+ const value = response;
22
+ const config = slot.config;
23
+ switch (slot.kind) {
24
+ case 'options':
25
+ return `${pickedWithFreetext(labelsOf(config['options'], value['selectedOptionIds'], 'label'), value['freetext'])}${commentSuffix(value)}`;
26
+ case 'text': {
27
+ const text = typeof value['text'] === 'string' ? value['text'].replace(/\s+/g, ' ').trim() : '';
28
+ return `${text === '' ? '(empty)' : text}${commentSuffix(value)}`;
29
+ }
30
+ case 'table': {
31
+ const rows = (Array.isArray(value['selectedRowIds']) ? value['selectedRowIds'] : []).map(String);
32
+ const columns = labelsOf(config['columns'], value['selectedColumnIds'], 'label');
33
+ const parts = [...(rows.length > 0 ? [`rows ${rows.join(', ')}`] : []), ...(columns.length > 0 ? [`columns ${columns.join(', ')}`] : [])];
34
+ return `${parts.length === 0 ? '(no selection)' : parts.join('; ')}${commentSuffix(value)}`;
35
+ }
36
+ case 'cards': {
37
+ const picked = labelsOf(config['cards'], value['selectedCardIds'], 'title');
38
+ return `${picked.length === 0 ? '(no selection)' : picked.join(', ')}${commentSuffix(value)}`;
39
+ }
40
+ default:
41
+ // A product kind crtr does not render: its opaque response is the summary.
42
+ return JSON.stringify(value);
43
+ }
44
+ }
1
45
  /**
2
- * Deterministic, no-LLM resolution summary — one line per answered
3
- * interaction: `"<title>: <option label>[ — <freetext>]"`. Shared by `ask()`
4
- * (envelope summary) and `writeResponse` (persisted into response.json at write
5
- * time).
46
+ * Deterministic, no-LLM page resolution summary — one line per answered slot in
47
+ * document order: `"<slot id>: <answer>"`. An acknowledgement (`responses: {}`)
48
+ * summarizes as the empty string.
6
49
  */
7
- export function buildSummary(deck, responses) {
8
- const byId = new Map(responses.map((r) => [r.id, r]));
50
+ export function buildPageSummary(manifest, responses) {
9
51
  const lines = [];
10
- for (const it of deck.interactions) {
11
- const r = byId.get(it.id);
12
- if (r === undefined)
52
+ for (const slot of manifest.slots) {
53
+ if (!isResponseBearingSlot(slot) || slot.id === undefined)
13
54
  continue;
14
- const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
15
- let picked;
16
- if (r.selectedOptionIds !== undefined) {
17
- const oc = r.optionComments;
18
- const parts = r.selectedOptionIds
19
- .map((id) => it.options.find((o) => o.id === id))
20
- .filter((o) => o !== undefined)
21
- .map((o) => {
22
- const note = oc !== undefined ? oc[o.id] : undefined;
23
- return typeof note === 'string' && note.length > 0
24
- ? `${o.label} ("${note}")`
25
- : o.label;
26
- });
27
- picked = parts.length > 0 ? parts.join(', ') : undefined;
28
- }
29
- else if (r.selectedOptionId !== undefined) {
30
- picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
31
- }
32
- let val;
33
- if (picked !== undefined && ft !== undefined)
34
- val = `${picked} — ${ft}`;
35
- else if (picked !== undefined)
36
- val = picked;
37
- else if (ft !== undefined)
38
- val = ft;
39
- else
40
- val = '(skipped)';
41
- lines.push(`${it.title}: ${val}`);
55
+ const response = responses[slot.id];
56
+ if (response === undefined)
57
+ continue;
58
+ lines.push(`${slot.id}: ${describeSlotResponse(slot, response)}`);
42
59
  }
43
60
  return lines.join('\n');
44
61
  }
@@ -1,14 +1,43 @@
1
- import type { Deck, DeckTicketResult, ReviewDescriptor, TicketResult } from './types.js';
1
+ import type { ReviewDescriptor, TicketResult } from './types.js';
2
+ import { type PageDialect } from './page.js';
3
+ import { type PageManifest, type PageResponses, type PageSource } from './page-schema.js';
2
4
  /** Strict decoder for the only canonical final marker. */
3
5
  export declare function readTicketResult(dir: string): TicketResult | null;
4
- export interface SubmitDeckOptions {
6
+ export interface SubmitPageOptions {
5
7
  dir: string;
6
- deck: Deck;
8
+ /** The authored page source; copied into the ticket directory as its `document`. */
9
+ sourceFile: string;
10
+ dialect: PageDialect;
11
+ source?: PageSource;
12
+ productKinds?: string[];
7
13
  }
8
- export declare function submitDeck(opts: SubmitDeckOptions): {
14
+ /** One validated page, ready to publish into a ticket directory. */
15
+ export interface PreparedPage {
16
+ dialect: PageDialect;
17
+ /** The authored source, verbatim, as the ticket's `document`. */
18
+ document: string;
19
+ manifest: PageManifest;
20
+ }
21
+ /** Validate one authored page source into publishable form. Reads only: a page
22
+ * that fails authoring leaves nothing behind, so a caller that must create a
23
+ * bridge node prepares first and creates it only once the page is known good.
24
+ * The source is read exactly once, so the published document is the same bytes
25
+ * the manifest was derived from even if the file changes underneath. */
26
+ export declare function preparePage(opts: Omit<SubmitPageOptions, 'dir'>): PreparedPage;
27
+ /** Publish one prepared page ticket: the authored source plus its manifest, exclusively. */
28
+ export declare function publishPage(dir: string, prepared: PreparedPage): {
9
29
  id: string;
10
30
  dir: string;
11
- kind: 'deck';
31
+ kind: 'page';
32
+ manifest: PageManifest;
33
+ };
34
+ /** Validate and publish one page ticket in a single step, for callers that own
35
+ * the ticket directory before they have a page. */
36
+ export declare function submitPage(opts: SubmitPageOptions): {
37
+ id: string;
38
+ dir: string;
39
+ kind: 'page';
40
+ manifest: PageManifest;
12
41
  };
13
42
  export interface SubmitReviewOptions {
14
43
  dir: string;
@@ -23,17 +52,38 @@ export declare function submitReview(opts: SubmitReviewOptions): {
23
52
  dir: string;
24
53
  kind: 'review';
25
54
  };
26
- /** Validate a deck resolution against its canonical ticket before claim takeover. */
27
- export declare function validateDeckTicketResponses(dir: string, responses: unknown): DeckTicketResult['responses'];
28
- export declare function completeDeck(dir: string, responses: DeckTicketResult['responses'], claimToken: string, completedAt?: string): {
55
+ /** Validate a page resolution against its canonical ticket before claim takeover. */
56
+ export declare function validatePageTicketResponses(dir: string, responses: unknown, productKinds?: string[]): PageResponses;
57
+ /** The three terminal states of a page ticket, derived from its published result. */
58
+ export declare function pageTicketState(dir: string): 'pending' | 'resolved' | 'canceled';
59
+ /** Autosave partial page work. Every supplied response is validated; omitted slots are permitted. */
60
+ export declare function writePageProgress(dir: string, responses: unknown, productKinds?: string[]): PageResponses;
61
+ /** Saved partial work, or null when none exists. Malformed progress is an error, never silently discarded. */
62
+ export declare function readPageProgress(dir: string, productKinds?: string[]): PageResponses | null;
63
+ export interface PageTicket {
64
+ dir: string;
65
+ id: string;
66
+ manifest: PageManifest;
67
+ /** The authored source document, verbatim. */
68
+ document: string;
69
+ state: 'pending' | 'resolved' | 'canceled';
70
+ progress: PageResponses | null;
71
+ }
72
+ /** Read one addressable page ticket — pending, resolved or canceled alike. */
73
+ export declare function readPageTicket(dir: string, productKinds?: string[]): PageTicket;
74
+ export interface CompletePageOptions {
75
+ productKinds?: string[];
76
+ completedAt?: string;
77
+ }
78
+ export declare function completePage(dir: string, responses: unknown, claimToken: string, opts?: CompletePageOptions): {
29
79
  won: boolean;
30
80
  result: TicketResult;
31
81
  };
32
- /** Atomically take a ticket's claim over, complete it, and release the takeover claim. */
33
- export declare function takeoverAndCompleteDeck(dir: string, responses: DeckTicketResult['responses'], owner: {
82
+ /** Atomically take a page ticket's claim over, complete it, and release the takeover claim. */
83
+ export declare function takeoverAndCompletePage(dir: string, responses: unknown, owner: {
34
84
  host: string;
35
85
  pid: number;
36
- }): {
86
+ }, opts?: CompletePageOptions): {
37
87
  won: boolean;
38
88
  result: TicketResult;
39
89
  };
@@ -2,13 +2,14 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFil
2
2
  import { basename, isAbsolute, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { z } from 'zod';
5
- import { buildSummary } from './summary.js';
6
- import { atomicWriteJson, clearProgress, claimPath, deckPath, progressPath, publishJsonExclusive, requireTicket, responsePath, reviewPath } from './convention.js';
7
- import { validateDeck, validateReviewDescriptor, validateReviewProjection, resolveDeckBodyPaths } from './deck-schema.js';
5
+ import { buildPageSummary } from './summary.js';
6
+ import { atomicWriteJson, clearProgress, claimPath, pageManifestPath, pagePath, progressPath, publishJsonExclusive, requireTicket, responsePath, reviewPath } from './convention.js';
7
+ import { validateReviewDescriptor, validateReviewProjection } from './review-schema.js';
8
+ import { derivePageManifestFromDocument, parsePage, PageAuthoringError } from './page.js';
9
+ import { validatePageResponses, validatePartialPageResponses } from './page-schema.js';
8
10
  import { signalInboxActivity } from './root.js';
9
11
  import { readTicketClaim, releaseClaimLocked, withTicketLock } from './claim.js';
10
12
  import { checkReviewableFile } from './visible-paths.js';
11
- const responseSchema = z.object({ id: z.string().min(1), selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), optionComments: z.record(z.string(), z.string()).optional() }).strict();
12
13
  const feedbackCommentSchema = z.object({ id: z.string().min(1), line: z.number().int().positive(), endLine: z.number().int().positive(), quote: z.string().optional(), colStart: z.number().int().nonnegative().optional(), colEnd: z.number().int().nonnegative().optional(), lineText: z.string(), comment: z.string().min(1), createdAt: z.string().min(1) }).strict();
13
14
  const iso = z.string().datetime({ offset: true });
14
15
  const feedbackSchema = z.object({ file: z.string().min(1).refine(isAbsolute, 'file must be absolute').refine((file) => resolve(file) === file, 'file must be canonical'), submitted: z.literal(true), approved: z.boolean(), comments: z.array(feedbackCommentSchema), submittedAt: iso, savedAt: iso, reviewId: z.string().optional(), changed: z.boolean().optional(), baselineSha256: z.string().optional(), approvedSha256: z.string().optional(), companionNodeId: z.string().optional(), commentsTotal: z.number().int().nonnegative().optional(), commentsUnresolved: z.number().int().nonnegative().optional() }).strict().superRefine((result, ctx) => {
@@ -21,7 +22,9 @@ const feedbackSchema = z.object({ file: z.string().min(1).refine(isAbsolute, 'fi
21
22
  ctx.addIssue({ code: 'custom', message: 'colEnd must exceed colStart', path: ['comments', index, 'colEnd'] });
22
23
  });
23
24
  });
24
- const deckResultSchema = z.object({ schema: z.literal('humanloop.response/v2'), kind: z.literal('deck'), responses: z.array(responseSchema), summary: z.string(), completedAt: iso }).strict();
25
+ const slotResponseSchema = z.record(z.string(), z.unknown());
26
+ const pageResultSchema = z.object({ schema: z.literal('crtr.page-response/v1'), kind: z.literal('page'), responses: z.record(z.string(), slotResponseSchema), summary: z.string(), completedAt: iso }).strict();
27
+ const pageProgressSchema = z.object({ kind: z.literal('page'), responses: z.record(z.string(), slotResponseSchema), savedAt: iso }).strict();
25
28
  const reviewResultSchema = z.object({ schema: z.literal('humanloop.review-response/v1'), kind: z.literal('review'), result: feedbackSchema, completedAt: iso }).strict();
26
29
  const canceledResultSchema = z.object({ schema: z.literal('humanloop.cancel/v1'), kind: z.literal('canceled'), canceledAt: iso, reason: z.string().optional(), actor: z.string().optional() }).strict();
27
30
  /** Strict decoder for the only canonical final marker. */
@@ -33,9 +36,9 @@ export function readTicketResult(dir) {
33
36
  catch {
34
37
  return null;
35
38
  }
36
- const parsed = deckResultSchema.safeParse(raw);
37
- if (parsed.success)
38
- return parsed.data;
39
+ const page = pageResultSchema.safeParse(raw);
40
+ if (page.success)
41
+ return page.data;
39
42
  const review = reviewResultSchema.safeParse(raw);
40
43
  if (review.success)
41
44
  return review.data;
@@ -43,24 +46,39 @@ export function readTicketResult(dir) {
43
46
  return canceled.success ? canceled.data : null;
44
47
  }
45
48
  function hasTicketProtocolState(dir) {
46
- return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir)].some(existsSync);
49
+ return [pageManifestPath(dir), pagePath(dir, 'md'), pagePath(dir, 'html'), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir)].some(existsSync);
47
50
  }
48
51
  function publishRequest(path, value) {
49
52
  if (!publishJsonExclusive(path, value))
50
53
  throw new Error(`ticket request already exists: ${path}`);
51
54
  }
52
- export function submitDeck(opts) {
53
- // Validate shape before mutating the bridge node's canvas directory.
54
- validateDeck(opts.deck);
55
- mkdirSync(opts.dir, { recursive: true, mode: 0o700 });
56
- const dir = realpathSync(opts.dir);
57
- if (hasTicketProtocolState(dir))
58
- throw new Error(`ticket protocol state already exists: ${dir}`);
59
- const deck = validateDeck(resolveDeckBodyPaths(opts.deck, dir));
60
- const stamped = { ...deck, source: { ...(deck.source ?? {}), blockedSince: deck.source?.blockedSince ?? new Date().toISOString() } };
61
- publishRequest(deckPath(dir), stamped);
55
+ /** Validate one authored page source into publishable form. Reads only: a page
56
+ * that fails authoring leaves nothing behind, so a caller that must create a
57
+ * bridge node prepares first and creates it only once the page is known good.
58
+ * The source is read exactly once, so the published document is the same bytes
59
+ * the manifest was derived from even if the file changes underneath. */
60
+ export function preparePage(opts) {
61
+ if (!existsSync(opts.sourceFile))
62
+ throw new PageAuthoringError(`page source file does not exist: ${opts.sourceFile}`);
63
+ const document = readFileSync(opts.sourceFile, 'utf8');
64
+ const stamped = { ...(opts.source ?? {}), blockedSince: opts.source?.blockedSince ?? new Date().toISOString() };
65
+ return { dialect: opts.dialect, document, manifest: derivePageManifestFromDocument(document, opts.dialect, opts.productKinds ?? [], stamped) };
66
+ }
67
+ /** Publish one prepared page ticket: the authored source plus its manifest, exclusively. */
68
+ export function publishPage(dir, prepared) {
69
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
70
+ const ticket = realpathSync(dir);
71
+ if (hasTicketProtocolState(ticket))
72
+ throw new Error(`ticket protocol state already exists: ${ticket}`);
73
+ writeFileSync(pagePath(ticket, prepared.dialect), prepared.document, { flag: 'wx', mode: 0o600 });
74
+ publishRequest(pageManifestPath(ticket), prepared.manifest);
62
75
  signalInboxActivity();
63
- return { id: basename(dir), dir, kind: 'deck' };
76
+ return { id: basename(ticket), dir: ticket, kind: 'page', manifest: prepared.manifest };
77
+ }
78
+ /** Validate and publish one page ticket in a single step, for callers that own
79
+ * the ticket directory before they have a page. */
80
+ export function submitPage(opts) {
81
+ return publishPage(opts.dir, preparePage(opts));
64
82
  }
65
83
  export function submitReview(opts) {
66
84
  // A review takes any reviewable text file: a .md artifact renders as a document,
@@ -83,39 +101,64 @@ export function submitReview(opts) {
83
101
  function exclusiveResult(dir, result) {
84
102
  return publishJsonExclusive(responsePath(dir), result);
85
103
  }
86
- function requireDeck(dir) { return validateDeck(JSON.parse(readFileSync(deckPath(dir), 'utf8'))); }
87
104
  function requireReview(dir) { return validateReviewDescriptor(JSON.parse(readFileSync(reviewPath(dir), 'utf8'))); }
88
- function validateDeckResponses(deck, raw) {
89
- const responses = z.array(responseSchema).parse(raw);
90
- const interactions = new Map(deck.interactions.map((interaction) => [interaction.id, interaction]));
91
- const seen = new Set();
92
- for (const response of responses) {
93
- const interaction = interactions.get(response.id);
94
- if (interaction === undefined || seen.has(response.id))
95
- throw new Error(`response does not match a unique deck interaction: ${response.id}`);
96
- seen.add(response.id);
97
- const optionIds = new Set(interaction.options.map((option) => option.id));
98
- if (response.selectedOptionId !== undefined && (!optionIds.has(response.selectedOptionId) || interaction.multiSelect === true))
99
- throw new Error(`invalid single-select response for ${response.id}`);
100
- if (response.selectedOptionIds !== undefined && (interaction.multiSelect !== true || response.selectedOptionIds.some((id) => !optionIds.has(id)) || new Set(response.selectedOptionIds).size !== response.selectedOptionIds.length))
101
- throw new Error(`invalid multi-select response for ${response.id}`);
102
- if (response.freetext !== undefined && interaction.allowFreetext !== true)
103
- throw new Error(`freetext is not allowed for ${response.id}`);
104
- if (response.optionComments !== undefined && (interaction.multiSelect !== true || Object.keys(response.optionComments).some((id) => !optionIds.has(id))))
105
- throw new Error(`invalid option comments for ${response.id}`);
105
+ function requirePage(dir, productKinds) {
106
+ if (!existsSync(pageManifestPath(dir)))
107
+ throw new Error(`not a page ticket: ${dir}`);
108
+ return parsePage(dir, productKinds);
109
+ }
110
+ /** Validate a page resolution against its canonical ticket before claim takeover. */
111
+ export function validatePageTicketResponses(dir, responses, productKinds = []) {
112
+ return validatePageResponses(requirePage(requireTicket(dir), productKinds), responses);
113
+ }
114
+ /** The three terminal states of a page ticket, derived from its published result. */
115
+ export function pageTicketState(dir) {
116
+ const result = readTicketResult(dir);
117
+ if (result === null)
118
+ return 'pending';
119
+ return result.kind === 'canceled' ? 'canceled' : 'resolved';
120
+ }
121
+ function readPageProgressFor(dir, manifest) {
122
+ let raw;
123
+ try {
124
+ raw = JSON.parse(readFileSync(progressPath(dir), 'utf8'));
106
125
  }
107
- for (const interaction of deck.interactions.filter((candidate) => candidate.kind === 'notify')) {
108
- const response = responses.find((candidate) => candidate.id === interaction.id);
109
- if (response === undefined || (response.selectedOptionId === undefined && (response.selectedOptionIds?.length ?? 0) === 0 && (response.freetext?.trim() ?? '') === '')) {
110
- throw new Error(`notification requires an explicit acknowledgement: ${interaction.id}`);
111
- }
126
+ catch (error) {
127
+ if (error.code === 'ENOENT')
128
+ return null;
129
+ throw new Error(`page progress is not valid JSON: ${progressPath(dir)}`);
112
130
  }
113
- return responses;
131
+ const parsed = pageProgressSchema.safeParse(raw);
132
+ if (!parsed.success)
133
+ throw new Error(`page progress is malformed: ${progressPath(dir)}`);
134
+ // Saved work is validated on read as well as on write: a page whose manifest
135
+ // changed under it must fail loudly rather than hydrate an answer it no longer has.
136
+ return validatePartialPageResponses(manifest, parsed.data.responses);
137
+ }
138
+ /** Autosave partial page work. Every supplied response is validated; omitted slots are permitted. */
139
+ export function writePageProgress(dir, responses, productKinds = []) {
140
+ const ticket = requireTicket(dir);
141
+ const validated = validatePartialPageResponses(requirePage(ticket, productKinds), responses);
142
+ atomicWriteJson(progressPath(ticket), { kind: 'page', responses: validated, savedAt: new Date().toISOString() });
143
+ return validated;
144
+ }
145
+ /** Saved partial work, or null when none exists. Malformed progress is an error, never silently discarded. */
146
+ export function readPageProgress(dir, productKinds = []) {
147
+ const ticket = requireTicket(dir);
148
+ return readPageProgressFor(ticket, requirePage(ticket, productKinds));
114
149
  }
115
- /** Validate a deck resolution against its canonical ticket before claim takeover. */
116
- export function validateDeckTicketResponses(dir, responses) {
150
+ /** Read one addressable page ticket — pending, resolved or canceled alike. */
151
+ export function readPageTicket(dir, productKinds = []) {
117
152
  const ticket = requireTicket(dir);
118
- return validateDeckResponses(requireDeck(ticket), responses);
153
+ const manifest = requirePage(ticket, productKinds);
154
+ return {
155
+ dir: ticket,
156
+ id: basename(ticket),
157
+ manifest,
158
+ document: readFileSync(pagePath(ticket, manifest.dialect), 'utf8'),
159
+ state: pageTicketState(ticket),
160
+ progress: readPageProgressFor(ticket, manifest),
161
+ };
119
162
  }
120
163
  function requireClaimOwnership(dir, token) {
121
164
  if (readTicketClaim(dir)?.token !== token)
@@ -123,18 +166,18 @@ function requireClaimOwnership(dir, token) {
123
166
  }
124
167
  function clearOwnedWork(dir, claimToken) { clearProgress(dir); releaseClaimLocked(dir, claimToken); }
125
168
  /** Caller holds `.ticket-lock`. */
126
- function completeDeckLocked(dir, responses, claimToken, completedAt = new Date().toISOString()) {
169
+ function completePageLocked(dir, responses, claimToken, opts) {
127
170
  requireClaimOwnership(dir, claimToken);
128
- const deck = requireDeck(dir);
129
- const parsedResponses = validateDeckResponses(deck, responses);
130
- const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
171
+ const manifest = requirePage(dir, opts.productKinds ?? []);
172
+ const parsedResponses = validatePageResponses(manifest, responses);
173
+ const result = { schema: 'crtr.page-response/v1', kind: 'page', responses: parsedResponses, summary: buildPageSummary(manifest, parsedResponses), completedAt: opts.completedAt ?? new Date().toISOString() };
131
174
  const won = exclusiveResult(dir, result);
132
175
  clearOwnedWork(dir, claimToken);
133
176
  return { won, result: won ? result : readTicketResult(dir) ?? result };
134
177
  }
135
- export function completeDeck(dir, responses, claimToken, completedAt) {
178
+ export function completePage(dir, responses, claimToken, opts = {}) {
136
179
  const ticket = requireTicket(dir);
137
- const completed = withTicketLock(ticket, () => completeDeckLocked(ticket, responses, claimToken, completedAt));
180
+ const completed = withTicketLock(ticket, () => completePageLocked(ticket, responses, claimToken, opts));
138
181
  if (completed.won)
139
182
  signalInboxActivity();
140
183
  return completed;
@@ -148,13 +191,13 @@ function takeoverClaim(dir, owner) {
148
191
  writeFileSync(claimPath(dir), `${JSON.stringify({ token, host: owner.host, pid: owner.pid, claimedAt: now, heartbeatAt: now })}\n`, { flag: 'wx', mode: 0o600 });
149
192
  return token;
150
193
  }
151
- /** Atomically take a ticket's claim over, complete it, and release the takeover claim. */
152
- export function takeoverAndCompleteDeck(dir, responses, owner) {
194
+ /** Atomically take a page ticket's claim over, complete it, and release the takeover claim. */
195
+ export function takeoverAndCompletePage(dir, responses, owner, opts = {}) {
153
196
  const ticket = requireTicket(dir);
154
197
  const completed = withTicketLock(ticket, () => {
155
198
  const token = takeoverClaim(ticket, owner);
156
199
  try {
157
- return completeDeckLocked(ticket, responses, token);
200
+ return completePageLocked(ticket, responses, token, opts);
158
201
  }
159
202
  finally {
160
203
  releaseClaimLocked(ticket, token);
@@ -1,51 +1,12 @@
1
- export declare const INTERACTION_KINDS: readonly ["notify", "decision", "context", "error", "review"];
2
- export type InteractionKind = (typeof INTERACTION_KINDS)[number];
3
- export interface InteractionOption {
4
- id: string;
5
- label: string;
6
- description?: string;
7
- /** Auto-assigned by the TUI (assignShortcuts), never author-settable. */
8
- shortcut?: string;
9
- }
10
- export interface InteractionPreAnswer {
11
- selectedOptionId?: string;
12
- selectedOptionIds?: string[];
13
- freetext?: string;
14
- /** One-line marker shown in the answered chrome. */
15
- label?: string;
16
- }
17
- export interface Interaction {
18
- id: string;
19
- title: string;
20
- subtitle: string;
21
- body?: string;
22
- bodyPath?: string;
23
- options: InteractionOption[];
24
- multiSelect?: boolean;
25
- allowFreetext?: boolean;
26
- freetextLabel?: string;
27
- kind?: InteractionKind;
28
- preAnswered?: InteractionPreAnswer;
29
- }
30
- export interface InteractionResponse {
31
- id: string;
32
- selectedOptionId?: string;
33
- selectedOptionIds?: string[];
34
- freetext?: string;
35
- optionComments?: Record<string, string>;
36
- }
37
- export interface DeckSource {
1
+ import type { PageResponses } from './page-schema.js';
2
+ /** Provenance stamped onto every ticket request — page and review alike. */
3
+ export interface TicketSource {
38
4
  sessionName?: string;
39
5
  askedBy?: string;
40
6
  blockedSince?: string;
41
7
  /** Originating canvas node id (CRTR_NODE_ID) when the ask was raised inside a crouter canvas node. */
42
8
  nodeId?: string;
43
9
  }
44
- export interface Deck {
45
- title: string;
46
- source?: DeckSource;
47
- interactions: Interaction[];
48
- }
49
10
  export interface FeedbackComment {
50
11
  id: string;
51
12
  line: number;
@@ -65,17 +26,6 @@ export interface FeedbackResult {
65
26
  submittedAt?: string;
66
27
  savedAt: string;
67
28
  }
68
- /**
69
- * Resolution contract returned by `ask`/`inbox`. On-disk `response.json` stays
70
- * `{ responses, completedAt }`; `responsePath` points at it.
71
- */
72
- export interface ResolutionEnvelope {
73
- summary: string;
74
- responsePath: string;
75
- schema: 'humanloop.response/v2';
76
- responses: InteractionResponse[];
77
- completedAt: string;
78
- }
79
29
  export interface ClaimSummary {
80
30
  owner: string;
81
31
  claimedAt: string;
@@ -87,34 +37,39 @@ interface TicketSummaryBase {
87
37
  title: string;
88
38
  subtitle: string;
89
39
  blockedSince: string;
90
- source: DeckSource;
40
+ source: TicketSource;
91
41
  claim?: ClaimSummary;
92
42
  }
93
- export interface DeckTicketSummary extends TicketSummaryBase {
94
- kind: 'deck';
95
- interactionKind?: InteractionKind;
96
- interactionCount: number;
97
- }
98
43
  export interface ReviewTicketSummary extends TicketSummaryBase {
99
44
  kind: 'review';
100
45
  file: string;
101
46
  output: string;
102
47
  }
48
+ /** Page triage fields: a client picks a transcript rendering without a second fetch. */
49
+ export interface PageTicketSummary extends TicketSummaryBase {
50
+ kind: 'page';
51
+ dialect: 'md' | 'html';
52
+ placement: 'inline' | 'panel';
53
+ steps: number;
54
+ slotKinds: string[];
55
+ awaitsResponse: boolean;
56
+ }
103
57
  /** The only pending-ticket shape scanners expose. */
104
- export type TicketSummary = DeckTicketSummary | ReviewTicketSummary;
58
+ export type TicketSummary = ReviewTicketSummary | PageTicketSummary;
105
59
  export interface ReviewDescriptor {
106
60
  schema: 'humanloop.review/v1';
107
61
  file: string;
108
62
  output: string;
109
63
  title: string;
110
64
  subtitle: string;
111
- source: DeckSource;
65
+ source: TicketSource;
112
66
  blockedSince: string;
113
67
  }
114
- export interface DeckTicketResult {
115
- schema: 'humanloop.response/v2';
116
- kind: 'deck';
117
- responses: InteractionResponse[];
68
+ export interface PageTicketResult {
69
+ schema: 'crtr.page-response/v1';
70
+ kind: 'page';
71
+ /** An acknowledgement is the empty map; there is no second shape. */
72
+ responses: PageResponses;
118
73
  summary: string;
119
74
  completedAt: string;
120
75
  }
@@ -132,5 +87,5 @@ export interface CanceledTicketResult {
132
87
  actor?: string;
133
88
  }
134
89
  /** The sole canonical response.json union. */
135
- export type TicketResult = DeckTicketResult | ReviewTicketResult | CanceledTicketResult;
90
+ export type TicketResult = PageTicketResult | ReviewTicketResult | CanceledTicketResult;
136
91
  export {};
@@ -1,5 +1 @@
1
- // ── v2 shapes (v1 schema dropped per cycle-16 user pivot — humanloop is v2-only) ──
2
- // Single source of truth for the interaction-kind enum: the Zod deck schema
3
- // (`deck-schema.ts`) and the CLI's JSON schema both derive their enum values
4
- // from this array so a new kind cannot drift between validation surfaces.
5
- export const INTERACTION_KINDS = ['notify', 'decision', 'context', 'error', 'review'];
1
+ export {};
@@ -4,6 +4,8 @@ export type InspectorSection = 'overview' | 'schedule' | 'jobs' | 'raw';
4
4
  export interface BashJobRow {
5
5
  job_id: string;
6
6
  command: string;
7
+ /** Human-readable label supplied with the bash call, or null when absent. */
8
+ purpose: string | null;
7
9
  started_at: string;
8
10
  elapsed_ms: number;
9
11
  log_path: string;
@@ -67,6 +69,9 @@ export declare function jobElapsedMs(job: BashJobRow, now?: number): number;
67
69
  export declare function jobElapsed(job: BashJobRow, now?: number): string;
68
70
  /** First line of a command, whitespace-collapsed and capped for a one-row cell. */
69
71
  export declare function commandCell(command: string, cap?: number): string;
72
+ /** A person-readable purpose leads when present; legacy jobs retain their
73
+ * command label. */
74
+ export declare function jobLabel(job: BashJobRow, cap?: number): string;
70
75
  export declare function relativeTime(iso: string | null, now?: number): string;
71
76
  /** How long ago an ISO timestamp was, e.g. `3d ago`. `—` when absent. */
72
77
  export declare function ago(iso: unknown, now?: number): string;
@@ -81,6 +81,11 @@ export function commandCell(command, cap = 60) {
81
81
  const oneLine = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
82
82
  return oneLine.length > cap ? `${oneLine.slice(0, cap - 1)}…` : oneLine;
83
83
  }
84
+ /** A person-readable purpose leads when present; legacy jobs retain their
85
+ * command label. */
86
+ export function jobLabel(job, cap = 60) {
87
+ return commandCell(job.purpose ?? job.command, cap);
88
+ }
84
89
  export function relativeTime(iso, now = Date.now()) {
85
90
  if (iso === null)
86
91
  return '—';
@@ -1,4 +1,4 @@
1
- import { jobElapsed, overviewGroups, relativeTime, scheduledItems, value } from './model.js';
1
+ import { jobElapsed, jobLabel, overviewGroups, relativeTime, scheduledItems, value } from './model.js';
2
2
  /** The piped path: same information and grouping as the TUI, minus the glyphs
3
3
  * and hue a pipe cannot carry. */
4
4
  export const inspectorText = {
@@ -36,7 +36,7 @@ export const inspectorText = {
36
36
  if (jobs.length === 0)
37
37
  lines.push(' nothing running');
38
38
  for (const job of jobs) {
39
- lines.push('', ` ${job.command.split('\n')[0]} · ${jobElapsed(job)}`, ` id ${job.job_id}`, ` started ${job.started_at}`, ` log ${job.log_path}`);
39
+ lines.push('', ` ${jobLabel(job)} · ${jobElapsed(job)}`, ` id ${job.job_id}`, ...(job.purpose === null ? [] : [` command ${job.command.split('\n')[0]}`]), ` started ${job.started_at}`, ` log ${job.log_path}`);
40
40
  for (const line of job.log_tail.slice(-5))
41
41
  lines.push(` | ${line}`);
42
42
  }
@@ -1,4 +1,4 @@
1
- import { ICON, commandCell, jobElapsed, jobElapsedMs, jobTone, overviewGroups, relativeTime, scheduledItems, shortPath, value, } from './model.js';
1
+ import { ICON, commandCell, jobElapsed, jobElapsedMs, jobLabel, jobTone, overviewGroups, relativeTime, scheduledItems, shortPath, value, } from './model.js';
2
2
  // ── Visual grammar ───────────────────────────────────────────────────────────
3
3
  //
4
4
  // Three primitives carry the whole screen: a TAB STRIP (where am I / where can
@@ -162,7 +162,14 @@ function jobItem(job, expanded, width) {
162
162
  lines.push([{ text: `${INDENT}${INDENT}${last === undefined ? 'no output yet' : logCell(last, width - 6)}`, style: { dim: true } }]);
163
163
  }
164
164
  else {
165
- for (const [label, text] of [['job', job.job_id], ['started', job.started_at], ['log', shortPath(job.log_path)], ['watch', `tail -f ${shortPath(job.log_path)}`]]) {
165
+ const details = [
166
+ ['job', job.job_id],
167
+ ...(job.purpose === null ? [] : [['command', commandCell(job.command, Math.max(20, width - 26))]]),
168
+ ['started', job.started_at],
169
+ ['log', shortPath(job.log_path)],
170
+ ['watch', `tail -f ${shortPath(job.log_path)}`],
171
+ ];
172
+ for (const [label, text] of details) {
166
173
  lines.push([{ text: `${INDENT}${INDENT}${label.padEnd(LABEL_COL)}`, style: LABEL_STYLE }, { text, style: label === 'watch' ? { fg: '36' } : undefined }]);
167
174
  }
168
175
  lines.push([]);
@@ -176,7 +183,7 @@ function jobItem(job, expanded, width) {
176
183
  return {
177
184
  spans: [
178
185
  { text: ` ${ICON.job} `, style: tone },
179
- { text: commandCell(job.command, Math.max(20, width - 26)), style: { bold: true } },
186
+ { text: jobLabel(job, Math.max(20, width - 26)), style: { bold: true } },
180
187
  ],
181
188
  right: [{ text: `${jobElapsed(job)} `, style: tone }],
182
189
  lines,