@north-light/crouter 0.3.179 → 0.3.181

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 (137) hide show
  1. package/dist/api/client.d.ts +27 -2
  2. package/dist/api/client.js +41 -1
  3. package/dist/api/dto/broker.d.ts +32 -0
  4. package/dist/api/dto/crons.d.ts +17 -0
  5. package/dist/api/dto/human.d.ts +1 -7
  6. package/dist/api/dto/inbox.d.ts +71 -1
  7. package/dist/api/dto/inbox.js +9 -1
  8. package/dist/api/dto/memory.d.ts +17 -0
  9. package/dist/api/dto/memory.js +6 -0
  10. package/dist/api/dto/messages.d.ts +5 -0
  11. package/dist/api/dto/reviews.d.ts +8 -4
  12. package/dist/api/index.d.ts +1 -0
  13. package/dist/api/index.js +1 -0
  14. package/dist/api/routes.d.ts +5 -0
  15. package/dist/api/routes.js +8 -1
  16. package/dist/build-root.d.ts +7 -0
  17. package/dist/build-root.js +21 -0
  18. package/dist/builtin-memory/insights/init.md +48 -3
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/__tests__/insights-active-init.test.ts +98 -0
  20. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +7 -50
  21. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +16 -1
  22. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +34 -0
  23. package/dist/cli.js +1 -2
  24. package/dist/clients/attach/__tests__/context-message.test.js +5 -2
  25. package/dist/clients/attach/assets/README.md +7 -0
  26. package/dist/clients/attach/assets/whip-06.mp3 +0 -0
  27. package/dist/clients/attach/assets/whip-crack.mp3 +0 -0
  28. package/dist/clients/attach/assets/whip-snap.mp3 +0 -0
  29. package/dist/clients/attach/chrome/canvas-panels.d.ts +7 -1
  30. package/dist/clients/attach/chrome/canvas-panels.js +20 -3
  31. package/dist/clients/attach/chrome/review-wait.d.ts +6 -0
  32. package/dist/clients/attach/chrome/review-wait.js +22 -0
  33. package/dist/clients/attach/chrome/roster.js +23 -2
  34. package/dist/clients/attach/chrome/widgets.js +1 -1
  35. package/dist/clients/attach/input/controller.js +4 -3
  36. package/dist/clients/attach/overlays/mcp.js +3 -1
  37. package/dist/clients/attach/render/chat-view.js +1 -1
  38. package/dist/clients/attach/session/whip.d.ts +1 -0
  39. package/dist/clients/attach/session/whip.js +26 -0
  40. package/dist/clients/attach/slash/dispatch.js +2 -0
  41. package/dist/clients/attach/viewer.js +578 -573
  42. package/dist/clients/inbox/review/document-surface.d.ts +1 -1
  43. package/dist/clients/inbox/review/document-surface.js +4 -4
  44. package/dist/clients/inbox/review/launch.js +16 -4
  45. package/dist/clients/inbox/review/review-client.d.ts +9 -4
  46. package/dist/clients/inbox/review/review-client.js +3 -0
  47. package/dist/commands/cron.js +30 -8
  48. package/dist/commands/human/prompts.d.ts +7 -2
  49. package/dist/commands/human/prompts.js +15 -10
  50. package/dist/commands/human.js +1 -2
  51. package/dist/commands/memory/find.js +11 -8
  52. package/dist/commands/memory/read.js +111 -11
  53. package/dist/commands/memory/write.js +1 -1
  54. package/dist/commands/memory.js +1 -1
  55. package/dist/commands/pkg/market-manage.d.ts +13 -0
  56. package/dist/commands/pkg/market-manage.js +39 -33
  57. package/dist/commands/pkg/plugin-inspect.js +4 -3
  58. package/dist/commands/pkg/plugin-manage.js +12 -11
  59. package/dist/commands/surface/node/focus.js +1 -2
  60. package/dist/commands/sys/doctor.js +4 -4
  61. package/dist/commands/sys/setup-core.d.ts +14 -7
  62. package/dist/commands/sys/setup-core.js +66 -11
  63. package/dist/commands/sys/setup-wizard.js +2 -2
  64. package/dist/commands/sys/setup.js +1 -1
  65. package/dist/core/__tests__/cron-held-settlement.test.d.ts +1 -0
  66. package/dist/core/__tests__/cron-held-settlement.test.js +222 -0
  67. package/dist/core/__tests__/helpers/harness.js +1 -2
  68. package/dist/core/__tests__/phase4-review-store.test.js +1 -0
  69. package/dist/core/__tests__/serial/command-plugins.test.js +88 -1
  70. package/dist/core/__tests__/session-model.test.js +5 -3
  71. package/dist/core/bootstrap.d.ts +0 -4
  72. package/dist/core/bootstrap.js +1 -55
  73. package/dist/core/canvas/crons.d.ts +54 -2
  74. package/dist/core/canvas/crons.js +48 -4
  75. package/dist/core/canvas/db.js +23 -0
  76. package/dist/core/command-manifests/manifest.d.ts +11 -0
  77. package/dist/core/command-manifests/manifest.js +45 -4
  78. package/dist/core/command-manifests/schema.d.ts +1 -1
  79. package/dist/core/command-plugins/bundle.d.ts +1 -0
  80. package/dist/core/command-plugins/bundle.js +3 -3
  81. package/dist/core/command-plugins/discovery.d.ts +5 -2
  82. package/dist/core/command-plugins/discovery.js +5 -5
  83. package/dist/core/command-plugins/help-addenda.d.ts +12 -0
  84. package/dist/core/command-plugins/help-addenda.js +30 -0
  85. package/dist/core/command.js +25 -2
  86. package/dist/core/config.js +0 -1
  87. package/dist/core/human/convention.d.ts +0 -1
  88. package/dist/core/human/convention.js +0 -6
  89. package/dist/core/keybindings/inbox.d.ts +6 -8
  90. package/dist/core/keybindings/inbox.js +6 -15
  91. package/dist/core/keybindings/index.d.ts +1 -1
  92. package/dist/core/keybindings/index.js +1 -1
  93. package/dist/core/memory/doc-link-grammar.js +4 -1
  94. package/dist/core/memory-resolver.d.ts +28 -4
  95. package/dist/core/memory-resolver.js +51 -39
  96. package/dist/core/review/stage.js +1 -0
  97. package/dist/core/review/store.d.ts +5 -0
  98. package/dist/core/review/store.js +10 -0
  99. package/dist/core/review/types.d.ts +4 -0
  100. package/dist/core/runtime/broker/event-projection.d.ts +8 -1
  101. package/dist/core/runtime/broker/event-projection.js +25 -1
  102. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  103. package/dist/core/runtime/broker/frame-dispatch.js +50 -8
  104. package/dist/core/runtime/broker/message-ledger.d.ts +53 -0
  105. package/dist/core/runtime/broker/message-ledger.js +143 -0
  106. package/dist/core/runtime/broker/rebind.js +14 -0
  107. package/dist/core/runtime/broker-protocol.d.ts +46 -1
  108. package/dist/core/runtime/broker.js +11 -2
  109. package/dist/core/runtime/interactive-deliver.d.ts +5 -2
  110. package/dist/core/runtime/interactive-deliver.js +6 -3
  111. package/dist/core/runtime/shell-expansion.d.ts +32 -0
  112. package/dist/core/runtime/shell-expansion.js +102 -0
  113. package/dist/core/session-model/session-state.d.ts +9 -4
  114. package/dist/core/session-model/session-state.js +5 -1
  115. package/dist/daemon/api/handlers/broker-ops.js +8 -0
  116. package/dist/daemon/api/handlers/crons.js +14 -1
  117. package/dist/daemon/api/handlers/inbox.js +346 -2
  118. package/dist/daemon/api/handlers/memory.d.ts +2 -0
  119. package/dist/daemon/api/handlers/memory.js +48 -0
  120. package/dist/daemon/api/handlers/messages.js +7 -1
  121. package/dist/daemon/api/handlers/reviews.js +7 -5
  122. package/dist/daemon/api/map.js +3 -0
  123. package/dist/daemon/api/server.js +2 -0
  124. package/dist/daemon/cron-run.js +71 -3
  125. package/dist/daemon/crtrd.js +3 -0
  126. package/dist/daemon/reconcilers/pending-review-submit.d.ts +7 -0
  127. package/dist/daemon/reconcilers/pending-review-submit.js +35 -0
  128. package/dist/daemon/review/companion.d.ts +8 -0
  129. package/dist/daemon/review/companion.js +35 -0
  130. package/dist/daemon/review/deliver.js +2 -1
  131. package/dist/daemon/review/finish.d.ts +29 -2
  132. package/dist/daemon/review/finish.js +75 -2
  133. package/dist/shared/generated-context.d.ts +3 -4
  134. package/dist/shared/generated-context.js +24 -6
  135. package/dist/types.d.ts +0 -1
  136. package/package.json +1 -1
  137. package/runtime.lock.json +2 -2
@@ -1,12 +1,86 @@
1
- // Ticket-inbox listing for the attached terminal viewer.
1
+ // Ticket inbox handlers — crtrd `/v1/human/inbox` (Northlight crouter-inbox
2
+ // v1, inbox-contract.md §A). This public ticket-serving resource enumerates,
3
+ // reads, responds to, and cancels tickets while preserving its product DTOs.
4
+ //
5
+ // Every ticket is addressed on the wire by an opaque, stable id: lowercase
6
+ // SHA-256 hex of `canonicalRoot + "\0" + ticketBasename`. The wire never exposes
7
+ // a ticket directory, review file/output, claim owner/heartbeat, or other host
8
+ // path or process metadata. The daemon's shared ticket finish path owns claims,
9
+ // terminal publication, and bridge delivery.
2
10
  import { createHash } from 'node:crypto';
3
- import { realpathSync } from 'node:fs';
11
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
12
+ import { basename, resolve } from 'node:path';
13
+ import { ApiError } from '../../../api/index.js';
14
+ import { readJsonOrNull } from '../../../core/fs-utils.js';
15
+ import { deckPath, reviewPath } from '../../../core/human/convention.js';
16
+ import { parseDeck } from '../../../core/human/deck-schema.js';
4
17
  import { ticketsRoot } from '../../../core/human/root.js';
5
18
  import { scanInbox } from '../../../core/human/scan.js';
19
+ import { readTicketResult } from '../../../core/human/tickets.js';
20
+ import { getReviewByBridge } from '../../../core/review/store.js';
21
+ import { ReviewOperationError } from '../../../core/review/types.js';
22
+ import { cancelHumanTicket, resolveDeckTicket } from '../../human/finish.js';
23
+ import { cancelReview } from '../../review/finish.js';
6
24
  import { filterTerminalReviewTickets } from '../../../core/review/ticket-filter.js';
25
+ const DEFAULT_CANCEL_REASON = 'Canceled from the inbox.';
26
+ const CANCEL_REASON_MAX = 1000;
27
+ const TICKET_ID_PATTERN = /^[a-f0-9]{64}$/;
28
+ // ===========================================================================
29
+ // Opaque ticket id resolution
30
+ // ===========================================================================
31
+ /** Opaque ids hash the canonical derived tickets root and ticket basename. */
7
32
  function ticketHash(canonicalRoot, ticketBasename) {
8
33
  return createHash('sha256').update(`${canonicalRoot}\0${ticketBasename}`, 'utf8').digest('hex');
9
34
  }
35
+ /** Resolve an opaque id against the single tickets root. Includes terminal
36
+ * tickets so a stale client receives the stable already-resolved response. */
37
+ function resolveTicket(ticketId) {
38
+ let canonicalRoot;
39
+ try {
40
+ canonicalRoot = realpathSync(ticketsRoot());
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(canonicalRoot);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ for (const entry of entries) {
53
+ if (ticketHash(canonicalRoot, entry) !== ticketId)
54
+ continue;
55
+ const candidate = resolve(canonicalRoot, entry);
56
+ let canonicalDir;
57
+ try {
58
+ if (!statSync(candidate).isDirectory())
59
+ continue;
60
+ canonicalDir = realpathSync(candidate);
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ if (resolve(canonicalDir, '..') !== canonicalRoot || basename(canonicalDir) !== entry)
66
+ continue;
67
+ if (existsSync(deckPath(canonicalDir)))
68
+ return { nodeId: entry, dir: canonicalDir, kind: 'deck' };
69
+ if (existsSync(reviewPath(canonicalDir)))
70
+ return { nodeId: entry, dir: canonicalDir, kind: 'review' };
71
+ }
72
+ return null;
73
+ }
74
+ function requireTicketId(params) {
75
+ const ticketId = params['ticket_id'];
76
+ if (ticketId === undefined || !TICKET_ID_PATTERN.test(ticketId)) {
77
+ throw new ApiError(400, 'invalid_request', 'invalid ticket id');
78
+ }
79
+ return ticketId;
80
+ }
81
+ // ===========================================================================
82
+ // Projections — humanloop canonical shapes → wire DTOs
83
+ // ===========================================================================
10
84
  function toDeckSourceDTO(source) {
11
85
  const dto = {};
12
86
  if (source.sessionName !== undefined)
@@ -15,6 +89,44 @@ function toDeckSourceDTO(source) {
15
89
  dto.askedBy = source.askedBy;
16
90
  if (source.blockedSince !== undefined)
17
91
  dto.blockedSince = source.blockedSince;
92
+ // The originating canvas node id — machine attribution a consumer uses to
93
+ // relate a ticket to the node that raised it. A node id is opaque canvas
94
+ // identity, not a host path, so it is safe on this wire.
95
+ if (source.nodeId !== undefined)
96
+ dto.nodeId = source.nodeId;
97
+ return dto;
98
+ }
99
+ function toOptionDTO(option) {
100
+ const dto = { id: option.id, label: option.label };
101
+ if (option.description !== undefined)
102
+ dto.description = option.description;
103
+ return dto;
104
+ }
105
+ function toInteractionDTO(interaction) {
106
+ const dto = {
107
+ id: interaction.id,
108
+ title: interaction.title,
109
+ subtitle: interaction.subtitle,
110
+ options: interaction.options.map(toOptionDTO),
111
+ };
112
+ if (interaction.body !== undefined)
113
+ dto.body = interaction.body;
114
+ if (interaction.multiSelect !== undefined)
115
+ dto.multiSelect = interaction.multiSelect;
116
+ if (interaction.allowFreetext !== undefined)
117
+ dto.allowFreetext = interaction.allowFreetext;
118
+ if (interaction.freetextLabel !== undefined)
119
+ dto.freetextLabel = interaction.freetextLabel;
120
+ if (interaction.kind !== undefined)
121
+ dto.kind = interaction.kind;
122
+ if (interaction.preAnswered !== undefined)
123
+ dto.preAnswered = interaction.preAnswered;
124
+ return dto;
125
+ }
126
+ function toDeckDTO(deck) {
127
+ const dto = { title: deck.title, interactions: deck.interactions.map(toInteractionDTO) };
128
+ if (deck.source !== undefined)
129
+ dto.source = toDeckSourceDTO(deck.source);
18
130
  return dto;
19
131
  }
20
132
  function toTicketSummaryDTO(item) {
@@ -43,14 +155,246 @@ function toTicketSummaryDTO(item) {
43
155
  dto.interaction_kind = item.interactionKind;
44
156
  return dto;
45
157
  }
158
+ // ===========================================================================
159
+ // Request-body validation — structural only. Semantic response↔deck
160
+ // validation (option ids, single/multi-select shape, freetext permission,
161
+ // option comments, required notify acknowledgement) belongs to the ticket
162
+ // store via the daemon finish path; a structural violation here is
163
+ // `invalid_request`, a semantic rejection there is `invalid_ticket_response`.
164
+ // ===========================================================================
165
+ function isPlainObject(value) {
166
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
167
+ }
168
+ function requireNoExtraKeys(obj, allowed) {
169
+ for (const key of Object.keys(obj)) {
170
+ if (!allowed.includes(key))
171
+ throw new ApiError(400, 'invalid_request', `unexpected field: ${key}`);
172
+ }
173
+ }
174
+ const RESPONSE_FIELDS = ['id', 'selectedOptionId', 'selectedOptionIds', 'freetext', 'optionComments'];
175
+ function validateInteractionResponse(raw) {
176
+ if (!isPlainObject(raw))
177
+ throw new ApiError(400, 'invalid_request', 'each response must be an object');
178
+ requireNoExtraKeys(raw, RESPONSE_FIELDS);
179
+ const { id, selectedOptionId, selectedOptionIds, freetext, optionComments } = raw;
180
+ if (typeof id !== 'string' || id === '') {
181
+ throw new ApiError(400, 'invalid_request', 'response.id must be a nonempty string');
182
+ }
183
+ const response = { id };
184
+ if (selectedOptionId !== undefined) {
185
+ if (typeof selectedOptionId !== 'string') {
186
+ throw new ApiError(400, 'invalid_request', 'response.selectedOptionId must be a string');
187
+ }
188
+ response.selectedOptionId = selectedOptionId;
189
+ }
190
+ if (selectedOptionIds !== undefined) {
191
+ if (!Array.isArray(selectedOptionIds) || !selectedOptionIds.every((v) => typeof v === 'string')) {
192
+ throw new ApiError(400, 'invalid_request', 'response.selectedOptionIds must be a string array');
193
+ }
194
+ response.selectedOptionIds = selectedOptionIds;
195
+ }
196
+ if (freetext !== undefined) {
197
+ if (typeof freetext !== 'string')
198
+ throw new ApiError(400, 'invalid_request', 'response.freetext must be a string');
199
+ response.freetext = freetext;
200
+ }
201
+ if (optionComments !== undefined) {
202
+ if (!isPlainObject(optionComments) || !Object.values(optionComments).every((v) => typeof v === 'string')) {
203
+ throw new ApiError(400, 'invalid_request', 'response.optionComments must be a string map');
204
+ }
205
+ response.optionComments = optionComments;
206
+ }
207
+ return response;
208
+ }
209
+ function validateRespondRequest(raw) {
210
+ if (!isPlainObject(raw))
211
+ throw new ApiError(400, 'invalid_request', 'respond body is required');
212
+ requireNoExtraKeys(raw, ['responses']);
213
+ if (!Array.isArray(raw['responses']))
214
+ throw new ApiError(400, 'invalid_request', '`responses` must be an array');
215
+ return { responses: raw['responses'].map(validateInteractionResponse) };
216
+ }
217
+ function validateCancelRequest(raw) {
218
+ if (raw === undefined)
219
+ return {};
220
+ if (!isPlainObject(raw))
221
+ throw new ApiError(400, 'invalid_request', 'cancel body must be an object');
222
+ requireNoExtraKeys(raw, ['reason']);
223
+ if (raw['reason'] === undefined)
224
+ return {};
225
+ const reason = raw['reason'];
226
+ if (typeof reason !== 'string')
227
+ throw new ApiError(400, 'invalid_request', '`reason` must be a string');
228
+ if (reason.trim() === '' || reason.length > CANCEL_REASON_MAX) {
229
+ throw new ApiError(400, 'invalid_request', '`reason` must be nonempty after trim and at most 1000 characters');
230
+ }
231
+ return { reason };
232
+ }
233
+ // ===========================================================================
234
+ // GET /v1/human/inbox — enumerate
235
+ // ===========================================================================
46
236
  function handleList() {
47
237
  const items = filterTerminalReviewTickets(scanInbox());
238
+ // `blocked_since` is an RFC 3339 timestamp that may carry a non-`Z` offset;
239
+ // two offsets are not lexically comparable (`+14:00` can sort after `Z`
240
+ // while naming an earlier instant), so sort by parsed instant and use the
241
+ // ticket id only to break an exact tie.
48
242
  const tickets = items.map(toTicketSummaryDTO).sort((a, b) => {
49
243
  const byTime = Date.parse(b.blocked_since) - Date.parse(a.blocked_since);
50
244
  return byTime !== 0 ? byTime : a.ticket_id.localeCompare(b.ticket_id);
51
245
  });
52
246
  return { status: 200, body: { tickets } };
53
247
  }
248
+ // ===========================================================================
249
+ // GET /v1/human/inbox/:ticket_id — read a pending deck
250
+ // ===========================================================================
251
+ function handleGetDeck(ctx) {
252
+ const ticketId = requireTicketId(ctx.params);
253
+ const resolved = resolveTicket(ticketId);
254
+ if (resolved === null)
255
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
256
+ if (resolved.kind === 'review') {
257
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets have no v1 read operation');
258
+ }
259
+ if (readTicketResult(resolved.dir) !== null) {
260
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
261
+ }
262
+ // A malformed stored ticket is omitted from enumeration by `scanInbox`; if
263
+ // independently reached during a race (or a ticket is mutated on disk after
264
+ // enumeration), `parseDeck` throws a plain Error whose message can carry a
265
+ // `bodyPath` value or another host filesystem path (e.g. "bodyPath '/etc/
266
+ // passwd' escapes the deck directory"). That message must never reach the
267
+ // wire, so it is never allowed to fall through to the router's generic
268
+ // mapper unsanitized — humanloop validation itself is never weakened to
269
+ // serve unvalidated content, only its failure message is sanitized.
270
+ let deck;
271
+ try {
272
+ deck = parseDeck(deckPath(resolved.dir));
273
+ }
274
+ catch {
275
+ throw new ApiError(500, 'internal', 'the ticket could not be read');
276
+ }
277
+ return { status: 200, body: { ticket_id: ticketId, kind: 'deck', deck: toDeckDTO(deck) } };
278
+ }
279
+ // ===========================================================================
280
+ // POST /v1/human/inbox/:ticket_id/respond — submit
281
+ // ===========================================================================
282
+ // The exhaustive set of messages the ticket store's response validator throws
283
+ // for a genuine response-shape
284
+ // rejection — duplicate/unknown interaction id, invalid single/multi-select
285
+ // shape, disallowed freetext, invalid option comments, or a missing required
286
+ // notify acknowledgement. None of these ever carry a filesystem path (only
287
+ // client-supplied interaction ids), so their message is safe to forward
288
+ // verbatim as `invalid_ticket_response`. Any other failure — a malformed
289
+ // stored deck, a lock/dispatch/filesystem error — is not a response-shape
290
+ // problem and must not be mislabeled or leak its cause; it is sanitized to
291
+ // `500 internal`.
292
+ const RESPONSE_VALIDATION_MESSAGE_PATTERNS = [
293
+ /^response does not match a unique deck interaction: /,
294
+ /^invalid single-select response for /,
295
+ /^invalid multi-select response for /,
296
+ /^freetext is not allowed for /,
297
+ /^invalid option comments for /,
298
+ /^notification requires an explicit acknowledgement: /,
299
+ ];
300
+ function isResponseValidationError(err) {
301
+ return err instanceof Error && RESPONSE_VALIDATION_MESSAGE_PATTERNS.some((pattern) => pattern.test(err.message));
302
+ }
303
+ function coerceSingleSelect(responses, dir) {
304
+ const deck = readJsonOrNull(deckPath(dir));
305
+ if (deck === null)
306
+ return responses;
307
+ const multiSelect = new Map(deck.interactions.map((interaction) => [interaction.id, interaction.multiSelect === true]));
308
+ return responses.map((response) => {
309
+ if (multiSelect.get(response.id) === true || response.selectedOptionId !== undefined || response.selectedOptionIds?.length !== 1)
310
+ return response;
311
+ const { selectedOptionIds: _selectedOptionIds, ...rest } = response;
312
+ return { ...rest, selectedOptionId: response.selectedOptionIds[0] };
313
+ });
314
+ }
315
+ async function handleRespond(ctx) {
316
+ const ticketId = requireTicketId(ctx.params);
317
+ const request = validateRespondRequest(ctx.body);
318
+ const resolved = resolveTicket(ticketId);
319
+ if (resolved === null)
320
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
321
+ if (resolved.kind === 'review') {
322
+ throw new ApiError(409, 'ticket_kind_unsupported', 'review tickets submit through POST /v1/human/reviews/:id/submit');
323
+ }
324
+ if (readTicketResult(resolved.dir) !== null) {
325
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
326
+ }
327
+ try {
328
+ const result = await resolveDeckTicket(resolved.nodeId, coerceSingleSelect(request.responses, resolved.dir));
329
+ if (result.kind !== 'deck')
330
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
331
+ return { status: 200, body: result };
332
+ }
333
+ catch (error) {
334
+ // The daemon's terminal errors are public protocol outcomes: preserve both
335
+ // their code and message so Northlight can distinguish conflict from a
336
+ // recorded-but-undelivered answer.
337
+ if (error instanceof ApiError)
338
+ throw error;
339
+ if (isResponseValidationError(error)) {
340
+ throw new ApiError(400, 'invalid_ticket_response', error.message);
341
+ }
342
+ throw new ApiError(500, 'internal', 'the ticket could not be finalized');
343
+ }
344
+ }
345
+ // ===========================================================================
346
+ // POST /v1/human/inbox/:ticket_id/cancel — cancel
347
+ // ===========================================================================
348
+ async function handleCancel(ctx) {
349
+ const ticketId = requireTicketId(ctx.params);
350
+ const request = validateCancelRequest(ctx.body);
351
+ const resolved = resolveTicket(ticketId);
352
+ if (resolved === null)
353
+ throw new ApiError(404, 'ticket_not_found', 'no matching ticket');
354
+ try {
355
+ if (resolved.kind === 'review') {
356
+ const review = getReviewByBridge(resolved.nodeId);
357
+ if (review !== null) {
358
+ if (review.state === 'approved') {
359
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
360
+ }
361
+ const finished = await cancelReview(review.review_id, {
362
+ reason: request.reason ?? DEFAULT_CANCEL_REASON,
363
+ actor: 'human',
364
+ });
365
+ if (finished.outcome === 'already_settled') {
366
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
367
+ }
368
+ if (finished.ticket_result?.kind !== 'canceled') {
369
+ throw new Error('canonical review cancellation did not produce a ticket result');
370
+ }
371
+ return { status: 200, body: finished.ticket_result };
372
+ }
373
+ }
374
+ const result = await cancelHumanTicket(resolved.nodeId, {
375
+ reason: request.reason ?? DEFAULT_CANCEL_REASON,
376
+ actor: 'human',
377
+ });
378
+ if (result.kind !== 'canceled')
379
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
380
+ return { status: 200, body: result };
381
+ }
382
+ catch (error) {
383
+ if (error instanceof ApiError)
384
+ throw error;
385
+ // Review errors are canonical lifecycle vocabulary, not ticket protocol.
386
+ // A concurrent terminal winner is the same public ticket conflict.
387
+ if (error instanceof ReviewOperationError) {
388
+ throw new ApiError(409, 'ticket_already_resolved', 'ticket was already resolved');
389
+ }
390
+ // A lock/filesystem/delivery failure is not a request-shape problem; do
391
+ // not leak its cause through the generic router mapper.
392
+ throw new ApiError(500, 'internal', 'the ticket could not be canceled');
393
+ }
394
+ }
54
395
  export const inboxRoutes = [
55
396
  { method: 'GET', pattern: '/v1/human/inbox', handler: handleList },
397
+ { method: 'GET', pattern: '/v1/human/inbox/:ticket_id', handler: handleGetDeck },
398
+ { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/respond', handler: handleRespond },
399
+ { method: 'POST', pattern: '/v1/human/inbox/:ticket_id/cancel', handler: handleCancel },
56
400
  ];
@@ -0,0 +1,2 @@
1
+ import type { RouteTable } from '../router.js';
2
+ export declare const memoryRoutes: RouteTable;
@@ -0,0 +1,48 @@
1
+ // Memory-document resolution handler — `GET /v1/memory/resolve?name=&node=`.
2
+ // Turns a `[[canonical/name]]` link written in a node's transcript into the
3
+ // absolute path of the document THAT node would read, so a client can peek it
4
+ // (`GET /v1/files/peek`) and render it.
5
+ //
6
+ // The node is required, not optional: crtrd serves every node from one process,
7
+ // so its own cwd and env name no node, and the same name legitimately resolves
8
+ // to different documents for two nodes (each has its own context store, project
9
+ // stack, and profile). Resolution therefore runs target-addressed —
10
+ // `resolveMemoryDocForTarget` with the node's cwd/profile/id off canvas.
11
+ import { getNode } from '../../../index.js';
12
+ import { notFound, usage } from '../../../core/errors.js';
13
+ import { resolveMemoryDocForTarget } from '../../../core/memory-resolver.js';
14
+ import { isDocLinkName } from '../../../core/memory/doc-link-grammar.js';
15
+ function handleResolve(ctx) {
16
+ const name = ctx.query.get('name');
17
+ if (name === null || name === '') {
18
+ throw usage('a memory resolve requires a `name` query param');
19
+ }
20
+ // The link grammar is the same one that produced the link (browser-safe, one
21
+ // source of truth): anything outside it is not a document name, and rejecting
22
+ // it here keeps a stray `[[…]]` from reaching the resolver's scope parsing.
23
+ if (!isDocLinkName(name)) {
24
+ throw usage('memory document name must be `/`-joined [A-Za-z0-9_-] segments', { received: name });
25
+ }
26
+ const nodeId = ctx.query.get('node');
27
+ if (nodeId === null || nodeId === '') {
28
+ throw usage('a memory resolve requires a `node` query param — resolution is per-node');
29
+ }
30
+ const meta = getNode(nodeId);
31
+ if (meta === null)
32
+ throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
33
+ const doc = resolveMemoryDocForTarget(name, {
34
+ cwd: meta.cwd,
35
+ profileId: meta.profile_id ?? null,
36
+ nodeId: meta.node_id,
37
+ });
38
+ const body = {
39
+ name: doc.name,
40
+ scope: doc.scope,
41
+ path: doc.path,
42
+ ...(doc.plugin === undefined ? {} : { plugin: doc.plugin }),
43
+ };
44
+ return { status: 200, body };
45
+ }
46
+ export const memoryRoutes = [
47
+ { method: 'GET', pattern: '/v1/memory/resolve', handler: handleResolve },
48
+ ];
@@ -59,6 +59,12 @@ function parseSendBody(body) {
59
59
  if (delivery !== undefined && delivery !== 'interactive') {
60
60
  throw usage(`invalid delivery: ${String(delivery)} (expected interactive)`);
61
61
  }
62
+ const messageId = b['message_id'];
63
+ if (messageId !== undefined && typeof messageId !== 'string') {
64
+ throw usage('message_id must be a string');
65
+ }
66
+ if (typeof messageId === 'string' && messageId.trim() !== '')
67
+ out.message_id = messageId;
62
68
  if (delivery === 'interactive') {
63
69
  // Interactive delivery is a live-conversation send: a plain immediate body,
64
70
  // nothing that only makes sense on the durable path.
@@ -158,7 +164,7 @@ async function handleMessage(ctx) {
158
164
  // straight there (append + revive, watcher delivers post-boot). ---
159
165
  if (req.delivery === 'interactive' && isBrokerLive(meta)) {
160
166
  try {
161
- await deliverLive(id, req.body);
167
+ await deliverLive(id, req.body, req.message_id);
162
168
  const body = {
163
169
  node_id: id,
164
170
  delivered: true,
@@ -172,11 +172,13 @@ async function handleSubmit(ctx) {
172
172
  if (violations.length > 0 || reviewId === undefined)
173
173
  invalidRequest(violations);
174
174
  const finished = await submitReviewApproval(reviewId);
175
- const body = {
176
- review: toReviewResponseDTO(finished.record),
177
- result: toFeedbackResultDTO(finished.result),
178
- outcome: finished.outcome,
179
- };
175
+ const body = finished.outcome === 'awaiting_companion'
176
+ ? { review: toReviewResponseDTO(finished.record), outcome: finished.outcome }
177
+ : {
178
+ review: toReviewResponseDTO(finished.record),
179
+ result: toFeedbackResultDTO(finished.result),
180
+ outcome: finished.outcome,
181
+ };
180
182
  return { status: 200, body };
181
183
  }
182
184
  async function handleCancel(ctx) {
@@ -228,6 +228,7 @@ export function toCronDTO(c, lastRun) {
228
228
  sink: sinkDisplay(c.sink),
229
229
  tier: c.tier,
230
230
  state: c.state,
231
+ held: c.held,
231
232
  run_state: c.run_state,
232
233
  last_run: lastRun === undefined || lastRun === null
233
234
  ? null
@@ -279,6 +280,8 @@ export function toReviewDTO(row, extras = {}) {
279
280
  };
280
281
  if (row.opened_at !== null)
281
282
  dto.opened_at = row.opened_at;
283
+ if (row.submit_requested_at !== null)
284
+ dto.submit_requested_at = row.submit_requested_at;
282
285
  if (row.approved_at !== null)
283
286
  dto.approved_at = row.approved_at;
284
287
  if (row.changed !== null)
@@ -27,6 +27,7 @@ import { focusRoutes } from './handlers/focus.js';
27
27
  import { healthRoutes } from './handlers/health.js';
28
28
  import { humanRoutes } from './handlers/human.js';
29
29
  import { inboxRoutes } from './handlers/inbox.js';
30
+ import { memoryRoutes } from './handlers/memory.js';
30
31
  import { messageRoutes } from './handlers/messages.js';
31
32
  import { modelAuthRoutes } from './handlers/modelauth.js';
32
33
  import { nodeRoutes } from './handlers/nodes.js';
@@ -52,6 +53,7 @@ function buildRouter() {
52
53
  .registerAll(attachRoutes)
53
54
  .registerAll(canvasRoutes)
54
55
  .registerAll(fileRoutes)
56
+ .registerAll(memoryRoutes)
55
57
  .registerAll(focusRoutes)
56
58
  .registerAll(subscriptionRoutes)
57
59
  .registerAll(cronRoutes)
@@ -95,6 +95,21 @@ const EMPTY_STDOUT_SHA256 = createHash('sha256').update('').digest('hex');
95
95
  /** Lead of a `cron_runs.delivered` string written by the overlap=skip pass.
96
96
  * Matched (not just written) — see `latestRunIsSkip`. */
97
97
  const SKIP_MARKER = 'skipped (overlap=skip)';
98
+ /** EX_TEMPFAIL — the owed-gate disposition. A scheduled run exiting 75
99
+ * declares "this occurrence is owed but not currently eligible": the row is
100
+ * PARKED (held=1, occurrence not spent) instead of disposed — no failure, no
101
+ * escalation, no delivery, no on-change hash, no one-shot consumption. A poke
102
+ * (`POST /v1/crons/poke`) re-dues it now; otherwise a recurring row re-checks
103
+ * at its already-advanced natural slot and a held one-shot waits at its
104
+ * backstop (`expires_at`, else far future — poke-only). The gate itself stays
105
+ * in bash; the daemon only honors the exit code. */
106
+ const EXIT_HELD = 75;
107
+ /** `cron_runs.delivered` string for a held (exit-75) settlement. */
108
+ const HELD_DELIVERED = 'deferred (exit 75) — held for poke';
109
+ /** Backstop `fire_at` for a held one-shot without `--expires`: never due on
110
+ * the clock — the row fires only on a poke. Without this, a past-due parked
111
+ * one-shot would re-fire every tick forever. */
112
+ const HELD_ONE_SHOT_FAR_FUTURE = '9999-12-31T23:59:59.999Z';
98
113
  /** One id per daemon process instance — distinguishes THIS process's own
99
114
  * in-flight run leases from a stale 'running' row a prior (crashed/restarted)
100
115
  * daemon left behind. */
@@ -451,7 +466,9 @@ export function recoverStaleCronLeases(now, options = {}) {
451
466
  stderr_head: ctx.stderrHead,
452
467
  delivered: disposition.delivered,
453
468
  });
454
- releaseCronRunLease(c.cron_id);
469
+ // A recovered-timeout settlement took the ordinary disposition path
470
+ // (pause + escalation unless silent) — the gate is resolved, clear held.
471
+ releaseCronRunLease(c.cron_id, { kind: 'clear' });
455
472
  if (c.recur == null && !disposition.paused)
456
473
  consumeCron(c.cron_id);
457
474
  })();
@@ -720,6 +737,16 @@ export function executeCron(c, opts) {
720
737
  return;
721
738
  }
722
739
  const finishedAtMs = Date.now();
740
+ // The owed-gate branch (exit 75): a peer of the wasReplaced
741
+ // short-circuit — skip disposition entirely (no delivery, no
742
+ // escalation, no pause, no on-change hash, no one-shot consumption)
743
+ // and settle the row's held state with the lease release instead. The
744
+ // exclusions are deliberate: a manual `cron run` must not change
745
+ // scheduling state; a timeout-killed or launch-failed run is a real
746
+ // failure even if a stray 75 surfaces; a replaced or self-canceled
747
+ // run's existing short-circuits win.
748
+ const heldDeferred = !outOfBand && !timedOut && processError == null && code === EXIT_HELD && !wasReplaced && !wasSelfCanceled;
749
+ let heldSettle;
723
750
  let delivered;
724
751
  let paused = false;
725
752
  if (wasReplaced) {
@@ -727,6 +754,38 @@ export function executeCron(c, opts) {
727
754
  // fresh: record it, no disposition, no escalation.
728
755
  delivered = 'replaced (overlap=replace)';
729
756
  }
757
+ else if (heldDeferred) {
758
+ delivered = HELD_DELIVERED;
759
+ // Re-read the row: `last_poke_at` must be CURRENT — a poke landing
760
+ // while this gate check was in flight is exactly the race this
761
+ // check closes. (A row canceled mid-run is gone; recordCronRun and
762
+ // the lease release below both no-op.)
763
+ const fresh = getCron(c.cron_id);
764
+ if (fresh !== null) {
765
+ if (fresh.state === 'active' && fresh.last_poke_at !== null && startedAtIso < fresh.last_poke_at) {
766
+ // A poke arrived mid-run — its unpark statement deliberately
767
+ // skips leased rows — so re-due instead of park: the gate
768
+ // re-checks next tick instead of stalling until the backstop
769
+ // while the very eligibility it waits for is present. Only for
770
+ // a row still ACTIVE: a row paused mid-run parks below instead —
771
+ // the poke skips paused rows on purpose while its stamp lands on
772
+ // every row, and a re-due would leave a past-due UNHELD row that
773
+ // `cron resume` fires blind even though its gate never passed.
774
+ heldSettle = { kind: 'redue', nowIso: new Date(finishedAtMs).toISOString() };
775
+ }
776
+ else if (c.recur != null) {
777
+ // Recurring: the pre-run advance already wrote the natural next
778
+ // slot, which IS the backstop — park and touch nothing else.
779
+ heldSettle = { kind: 'park' };
780
+ }
781
+ else {
782
+ // One-shot: park at its backstop. With --expires, expiry deletes
783
+ // the row unfired at that instant (bounded wait); without, the
784
+ // far-future fire_at means poke-only.
785
+ heldSettle = { kind: 'park', fireAt: fresh.expires_at ?? HELD_ONE_SHOT_FAR_FUTURE };
786
+ }
787
+ }
788
+ }
730
789
  else {
731
790
  const disposition = await disposeSettledRun(c, {
732
791
  runId,
@@ -758,7 +817,13 @@ export function executeCron(c, opts) {
758
817
  delivered,
759
818
  };
760
819
  recordCronRun(record);
761
- if (!outOfBand && c.recur == null && !paused) {
820
+ if (heldDeferred) {
821
+ // Held: the occurrence is NOT spent — even a one-shot is retained
822
+ // (like a paused escalated one-shot is). Park/re-due and the lease
823
+ // release are one write.
824
+ releaseCronRunLease(c.cron_id, heldSettle);
825
+ }
826
+ else if (!outOfBand && c.recur == null && !paused) {
762
827
  // One-shot: the run is done, delete the row (its history cascades
763
828
  // with it — matching one-shot consumption). A failing
764
829
  // one-shot that PAUSED for escalation is kept, so `cron resume` can
@@ -767,7 +832,10 @@ export function executeCron(c, opts) {
767
832
  consumeCron(c.cron_id);
768
833
  }
769
834
  else {
770
- releaseCronRunLease(c.cron_id);
835
+ // An ordinary scheduled disposition settlement resolves the gate,
836
+ // so it unparks a held row; a replaced or out-of-band run leaves
837
+ // `held` untouched (the gate is still unresolved).
838
+ releaseCronRunLease(c.cron_id, wasReplaced || outOfBand ? undefined : { kind: 'clear' });
771
839
  }
772
840
  resolve(record);
773
841
  })().catch(reject);
@@ -57,6 +57,7 @@ import { DEFAULT_INTERVAL_MS } from './supervise-cadence.js';
57
57
  import { BrokerSupervisionReconciler } from './reconcilers/broker-supervision.js';
58
58
  import { ControllerDeathReconciler, DormantInboxReconciler } from './reconcilers/dormant-inbox.js';
59
59
  import { CronLaneReconciler } from './reconcilers/cron-lane.js';
60
+ import { PendingReviewSubmitReconciler } from './reconcilers/pending-review-submit.js';
60
61
  import { StorageMaintenanceReconciler } from './reconcilers/storage-maintenance.js';
61
62
  import { captureLivenessSnapshot, capturePidCommand, captureTeardownSnapshot, isPidAlive, killProcessTreePids, } from '../core/canvas/pid.js';
62
63
  import { bindFleet, boundFleet } from '../core/runtime/fleet.js';
@@ -311,6 +312,7 @@ const brokerSupervision = new BrokerSupervisionReconciler();
311
312
  const controllerDeath = new ControllerDeathReconciler();
312
313
  const dormantInbox = new DormantInboxReconciler();
313
314
  const cronLane = new CronLaneReconciler();
315
+ const pendingReviewSubmit = new PendingReviewSubmitReconciler();
314
316
  const storageMaintenance = new StorageMaintenanceReconciler();
315
317
  /** Supervision tick over the daemon's current live set. */
316
318
  export async function superviseTick(now = Date.now(), lifecycle = directTickLifecycle) {
@@ -334,6 +336,7 @@ export async function superviseTick(now = Date.now(), lifecycle = directTickLife
334
336
  controllerDeath.run(now, { rows });
335
337
  dormantInbox.run(now, { rows, fleet, hasBrokerCapacity });
336
338
  cronLane.run(now, { lifecycle, hasBrokerCapacity });
339
+ pendingReviewSubmit.run({ lifecycle });
337
340
  storageMaintenance.run(now);
338
341
  }
339
342
  /** Start the supervisor loop after winning the authoritative canvas claim.
@@ -0,0 +1,7 @@
1
+ import type { DetachedWorkLifecycle } from './broker-supervision.js';
2
+ export interface PendingReviewSubmitContext {
3
+ lifecycle: DetachedWorkLifecycle;
4
+ }
5
+ export declare class PendingReviewSubmitReconciler {
6
+ run(ctx: PendingReviewSubmitContext): void;
7
+ }