@crouton-kit/crouter 0.3.82 → 0.3.83

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 (80) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
  2. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
  3. package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -3
  4. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
  5. package/dist/clients/attach/__tests__/crtr-output-render.test.js +16 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +1 -2
  7. package/dist/clients/attach/attach-cmd.js +685 -685
  8. package/dist/clients/attach/attach-def.d.ts +2 -0
  9. package/dist/clients/attach/attach-def.js +93 -0
  10. package/dist/clients/attach/canvas-panels.d.ts +10 -3
  11. package/dist/clients/attach/canvas-panels.js +42 -16
  12. package/dist/clients/attach/chat-view.d.ts +20 -1
  13. package/dist/clients/attach/chat-view.js +85 -2
  14. package/dist/commands/__tests__/human.test.js +7 -22
  15. package/dist/commands/attention.js +31 -30
  16. package/dist/commands/human/prompts.js +51 -38
  17. package/dist/commands/human/queue.d.ts +1 -28
  18. package/dist/commands/human/queue.js +156 -520
  19. package/dist/commands/human/shared.d.ts +28 -68
  20. package/dist/commands/human/shared.js +37 -120
  21. package/dist/commands/human.js +13 -17
  22. package/dist/commands/node.js +4 -2
  23. package/dist/commands/surface.js +1 -1
  24. package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
  25. package/dist/commands/sys/__tests__/setup-front-door.test.js +98 -0
  26. package/dist/commands/sys/setup-core.d.ts +22 -0
  27. package/dist/commands/sys/setup-core.js +62 -0
  28. package/dist/commands/sys/setup.js +8 -1
  29. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  30. package/dist/core/__tests__/human-deliver.test.js +332 -0
  31. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  32. package/dist/core/__tests__/pid-identity-match.test.js +83 -0
  33. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  34. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  35. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  36. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  37. package/dist/core/canvas/attention.d.ts +16 -14
  38. package/dist/core/canvas/attention.js +34 -29
  39. package/dist/core/canvas/boot.d.ts +1 -0
  40. package/dist/core/canvas/boot.js +1 -1
  41. package/dist/core/canvas/browse/app.js +3 -3
  42. package/dist/core/canvas/pid.d.ts +43 -17
  43. package/dist/core/canvas/pid.js +104 -35
  44. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  45. package/dist/core/canvas/remote-canvas-source.js +4 -2
  46. package/dist/core/canvas/render.d.ts +5 -5
  47. package/dist/core/canvas/render.js +14 -14
  48. package/dist/core/canvas/source.d.ts +2 -2
  49. package/dist/core/canvas/source.js +3 -3
  50. package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
  51. package/dist/core/keybindings/catalog.d.ts +2 -2
  52. package/dist/core/keybindings/catalog.js +3 -2
  53. package/dist/core/preview-registry.js +22 -29
  54. package/dist/core/profiles/select.js +4 -4
  55. package/dist/core/runtime/pi-vendored.js +15 -1
  56. package/dist/core/runtime/placement.d.ts +1 -10
  57. package/dist/core/runtime/placement.js +43 -26
  58. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  59. package/dist/core/runtime/session-list-cache.js +92 -24
  60. package/dist/core/runtime/spawn.js +9 -0
  61. package/dist/core/spawn.d.ts +0 -59
  62. package/dist/core/spawn.js +5 -163
  63. package/dist/daemon/crtrd.js +7 -6
  64. package/dist/index.d.ts +2 -2
  65. package/dist/index.js +1 -1
  66. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  67. package/dist/web-client/index.html +1 -1
  68. package/dist/web-client/sw.js +1 -1
  69. package/package.json +2 -2
  70. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  73. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  74. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  75. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  76. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  77. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  78. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  79. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  80. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -6,166 +6,21 @@ import { paginate } from '../../core/pagination.js';
6
6
  import { getNode, listNodes, subscribersOf } from '../../core/canvas/index.js';
7
7
  import { transition } from '../../core/runtime/lifecycle.js';
8
8
  import { appendInbox } from '../../core/feed/inbox.js';
9
- import { closeSync, existsSync, linkSync, openSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
10
- import { basename, join } from 'node:path';
11
- import { spawnSync } from 'node:child_process';
12
- import { openInbox, scanInbox, parseDeck, deckPath, responsePath, progressPath, isResolved, atomicWriteJson, ask, launchReview, readJson, display, } from '@crouton-kit/humanloop';
13
- import { killPane } from './shared.js';
14
- // ---------------------------------------------------------------------------
15
- // stranded-answer healing
16
- // ---------------------------------------------------------------------------
17
- /** A deterministic one-line-per-interaction summary, mirroring humanloop's
18
- * `<id>: <option>[ — <freetext>]` shape. Used when reconstructing a
19
- * ResolutionEnvelope from a bare on-disk response.json (which stores only
20
- * `{responses, completedAt}`), so a healed deliver-back reads like the live one. */
21
- function summarizeResponses(responses, deck) {
22
- const interactions = new Map((deck?.interactions ?? []).map((it) => [it.id, it]));
23
- return responses
24
- .map((r) => {
25
- const it = interactions.get(r.id);
26
- let picked = '';
27
- if (r.selectedOptionIds !== undefined) {
28
- picked = r.selectedOptionIds
29
- .map((id) => {
30
- const label = it?.options.find((o) => o.id === id)?.label ?? id;
31
- const note = r.optionComments?.[id];
32
- return typeof note === 'string' && note.trim() !== '' ? `${label} ("${note.trim()}")` : label;
33
- })
34
- .join(', ');
35
- }
36
- else if (r.selectedOptionId !== undefined) {
37
- picked = it?.options.find((o) => o.id === r.selectedOptionId)?.label ?? r.selectedOptionId;
38
- }
39
- const ft = typeof r.freetext === 'string' && r.freetext.trim() !== '' ? ` — ${r.freetext.trim()}` : '';
40
- return `${it?.title ?? r.id}: ${picked}${ft}`;
41
- })
42
- .join('\n');
43
- }
44
- /** Deliver-back + reap an interaction whose answer is on disk but was never
45
- * delivered. The detached `_run` worker's `pushFinal` is normally the SOLE
46
- * deliver-back + reap step; when it never ran (e.g. a broker asker pre-fix, or
47
- * a human draining via `crtr human inbox`, which writes response.json directly
48
- * and never calls pushFinal), the answer strands and the bridge node leaks.
49
- *
50
- * Idempotent and self-gating: a no-op unless the bridge node is still LIVE
51
- * (active|idle) AND its interaction is resolved on disk — so it never
52
- * double-delivers an interaction the `_run` worker already finalized (pushFinal
53
- * flips status=done). Reconstructs the same pushFinal body `_run` would have
54
- * emitted, per mode; a canceled-on-disk response reaps the node without
55
- * delivering a result (mirrors `human cancel`). Returns true iff it acted. */
56
- export async function finalizeResolvedInteraction(jobId, claim) {
57
- const firstNode = getNode(jobId);
58
- if (firstNode === null)
59
- return false;
60
- const idir = interactionDir(jobId, firstNode.cwd);
61
- const ownedClaim = claim ?? acquireInteractionClaim(idir, jobId, { allowResolved: true });
62
- if (ownedClaim === 'already_resolved' || ownedClaim === 'claimed')
63
- return false;
9
+ import { existsSync, realpathSync } from 'node:fs';
10
+ import { basename, join, resolve } from 'node:path';
11
+ import { scanInbox, parseDeck, deckPath, reviewPath, isResolved, readJson, readTicketResult, claimTicket, completeDeck, cancelTicket, listInboxRoots, } from '@crouton-kit/humanloop';
12
+ /** Canonicalize a completion-event path the same way humanloop's registry does
13
+ * (realpath-resolved), falling back to a plain `resolve()` when the path no
14
+ * longer exists on disk, so the `_deliver` containment/registration checks
15
+ * compare like against like regardless of symlinked path components. */
16
+ function canonicalPath(p) {
64
17
  try {
65
- const node = getNode(jobId);
66
- if (node === null)
67
- return false;
68
- if (node.status !== 'active' && node.status !== 'idle')
69
- return false;
70
- if (!isResolved(idir))
71
- return false;
72
- const rc = readJson(join(idir, 'run.json'));
73
- if (rc === null)
74
- return false;
75
- const resp = readJson(responsePath(idir));
76
- if (resp === null)
77
- return false;
78
- // Canceled out-of-band (a raw canceled response.json, not via `human cancel`):
79
- // there is no answer to deliver — just reap the node and tell waiting
80
- // subscribers no answer is coming, the same quiet deferred note `human cancel`
81
- // emits. (`finalize` is legal from active|idle; the status guard above holds.)
82
- if (resp['canceled'] === true) {
83
- transition(jobId, 'finalize');
84
- const note = typeof resp['reason'] === 'string' && resp['reason'] !== '' ? ` — ${resp['reason']}` : '';
85
- for (const sub of subscribersOf(jobId)) {
86
- appendInbox(sub.node_id, {
87
- from: jobId,
88
- tier: 'deferred',
89
- kind: 'message',
90
- label: `human interaction ${jobId} canceled — no answer is coming${note}`,
91
- data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
92
- });
93
- }
94
- return true;
95
- }
96
- const responses = resp['responses'] ?? [];
97
- const completedAt = resp['completedAt'] ?? new Date().toISOString();
98
- let deck;
99
- try {
100
- deck = parseDeck(deckPath(idir));
101
- }
102
- catch {
103
- deck = undefined;
104
- }
105
- const summary = summarizeResponses(responses, deck);
106
- // ask (and any other answered deck): the full ResolutionEnvelope shape.
107
- const env = {
108
- summary,
109
- responsePath: responsePath(idir),
110
- schema: 'humanloop.response/v2',
111
- responses,
112
- completedAt,
113
- };
114
- await pushFinal(jobId, JSON.stringify(env));
115
- return true;
18
+ return realpathSync(p);
116
19
  }
117
- finally {
118
- if (claim === undefined)
119
- ownedClaim.release();
20
+ catch {
21
+ return resolve(p);
120
22
  }
121
23
  }
122
- /** Sweep every interaction under the cwd's interactions root and deliver-back +
123
- * reap any that are answered-but-undelivered (see finalizeResolvedInteraction).
124
- * Interaction dir names ARE the bridge node ids. Returns how many it healed. */
125
- export async function healStrandedInteractions(cwd) {
126
- const root = interactionsRoot(cwd);
127
- if (!existsSync(root))
128
- return 0;
129
- let healed = 0;
130
- for (const ent of readdirSync(root, { withFileTypes: true })) {
131
- if (!ent.isDirectory())
132
- continue;
133
- if (await finalizeResolvedInteraction(ent.name))
134
- healed++;
135
- }
136
- return healed;
137
- }
138
- // ---------------------------------------------------------------------------
139
- // inbox (human-invoked, blocking)
140
- // ---------------------------------------------------------------------------
141
- export const humanInbox = defineLeaf({
142
- name: 'inbox',
143
- description: 'interactively drain pending interactions',
144
- whenToUse: 'a human is clearing the queue at their terminal',
145
- help: {
146
- name: 'human inbox',
147
- summary: 'interactively drain pending interactions at your own terminal',
148
- params: [],
149
- inputNote: 'No input. Run this at a human terminal — it blocks until the backlog is drained or you quit.',
150
- output: [
151
- { name: 'drained', type: 'boolean', required: true, constraint: 'True once the loop returns.' },
152
- { name: 'delivered', type: 'integer', required: true, constraint: 'How many answered-but-undelivered interactions were delivered back to their askers and reaped.' },
153
- ],
154
- outputKind: 'object',
155
- effects: [
156
- 'Resolves pending interactions in the per-project interactions root via the TUI.',
157
- 'Delivers any answered-but-undelivered interaction back to its asking node and reaps the bridge node (the deliver-back the detached _run worker would have done).',
158
- ],
159
- },
160
- run: async () => {
161
- await openInbox({ roots: [interactionsRoot(process.cwd())] });
162
- // humanloop's openInbox() writes response.json but never calls pushFinal — so a
163
- // deck drained here would strand (no answer-back, leaked bridge node). Heal
164
- // every resolved-but-live interaction: deliver its answer to the asker + reap.
165
- const delivered = await healStrandedInteractions(process.cwd());
166
- return { drained: true, delivered };
167
- },
168
- });
169
24
  function allInteractionRoots() {
170
25
  const roots = new Set();
171
26
  roots.add(interactionsRoot(process.cwd()));
@@ -191,8 +46,8 @@ function resolveConversation(asking, nodes) {
191
46
  }
192
47
  return cur;
193
48
  }
194
- function askingNodeFor(jobId, deck, dir, nodes) {
195
- const stamped = deck?.source?.nodeId;
49
+ function askingNodeFor(jobId, dir, source, nodes) {
50
+ const stamped = source?.nodeId;
196
51
  if (stamped !== undefined && stamped !== '') {
197
52
  const found = nodes.find((n) => n.node_id === stamped);
198
53
  if (found !== undefined)
@@ -211,22 +66,25 @@ function askingNodeFor(jobId, deck, dir, nodes) {
211
66
  }
212
67
  return bridge !== null ? { node_id: bridge.node_id, name: bridge.name, cwd: bridge.cwd, parent: bridge.parent ?? null } : undefined;
213
68
  }
214
- function summarizeDeckItem(item, deck, nodes) {
215
- const jobId = basename(item.dir);
69
+ function summarizeTicket(summary, nodes) {
70
+ const jobId = basename(summary.dir);
71
+ // A bare-shell ticket with no canvas node is intentionally omitted from the
72
+ // canvas-scoped list; the popup still shows it globally.
216
73
  if (getNode(jobId) === null)
217
74
  return null;
218
- const asking = askingNodeFor(jobId, deck, item.dir, nodes);
75
+ const asking = askingNodeFor(jobId, summary.dir, summary.source, nodes);
219
76
  const conversation = resolveConversation(asking, nodes);
77
+ const interactionCount = summary.kind === 'deck' ? readJson(deckPath(summary.dir))?.interactions.length : undefined;
220
78
  return {
221
- id: item.id,
79
+ id: summary.id,
222
80
  job_id: jobId,
223
- dir: item.dir,
224
- title: item.title !== undefined ? item.title : deck?.title ?? null,
225
- kind: item.kind !== undefined ? item.kind : deck?.interactions[0]?.kind ?? null,
226
- blocked_since: item.blockedSince,
81
+ dir: summary.dir,
82
+ title: summary.title,
83
+ kind: summary.kind === 'deck' ? summary.interactionKind ?? null : 'review',
84
+ blocked_since: summary.blockedSince,
227
85
  ...(asking !== undefined ? { asking_node_id: asking.node_id, asking_node_name: asking.name } : {}),
228
86
  ...(conversation !== undefined ? { conversation_id: conversation.node_id, conversation_title: conversation.name } : {}),
229
- ...(deck !== null ? { interaction_count: deck.interactions.length } : {}),
87
+ ...(interactionCount !== undefined ? { interaction_count: interactionCount } : {}),
230
88
  };
231
89
  }
232
90
  function interactionDirForJob(jobId) {
@@ -245,7 +103,7 @@ export const humanList = defineLeaf({
245
103
  whenToUse: 'discovering what is blocked on a human',
246
104
  help: {
247
105
  name: 'human list',
248
- summary: 'paginated list of pending, unclaimed interactions, oldest first',
106
+ summary: 'paginated list of pending interactions, oldest first',
249
107
  params: [
250
108
  { kind: 'flag', name: 'limit', type: 'int', required: false, default: 20, constraint: 'Default 20, max 100.' },
251
109
  { kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: "Opaque token from a previous response's next_cursor. Omit on first call." },
@@ -265,8 +123,8 @@ export const humanList = defineLeaf({
265
123
  const nodes = nodeRefs();
266
124
  const raw = scanInbox(allInteractionRoots());
267
125
  const items = raw
268
- .flatMap((i) => {
269
- const item = summarizeDeckItem(i, readJson(deckPath(i.dir)), nodes);
126
+ .flatMap((s) => {
127
+ const item = summarizeTicket(s, nodes);
270
128
  return item === null ? [] : [item];
271
129
  })
272
130
  .sort((a, b) => {
@@ -315,7 +173,7 @@ export const humanDeck = defineLeaf({
315
173
  throw new InputError({ error: 'not_found', message: `no deck for interaction: ${jobId}`, field: 'job_id', next: 'Pass a pending job_id from `crtr human list`.' });
316
174
  }
317
175
  const nodes = nodeRefs();
318
- const asking = askingNodeFor(jobId, deck, dir, nodes);
176
+ const asking = askingNodeFor(jobId, dir, deck.source, nodes);
319
177
  const conversation = resolveConversation(asking, nodes);
320
178
  const first = deck.interactions[0];
321
179
  return {
@@ -337,155 +195,6 @@ export const humanDeck = defineLeaf({
337
195
  };
338
196
  },
339
197
  });
340
- function isErrno(e, code) {
341
- return typeof e === 'object' && e !== null && 'code' in e && e.code === code;
342
- }
343
- function pidAlive(pid) {
344
- try {
345
- process.kill(pid, 0);
346
- return true;
347
- }
348
- catch (e) {
349
- if (isErrno(e, 'ESRCH'))
350
- return false;
351
- return true;
352
- }
353
- }
354
- function interactionLockPath(dir) {
355
- return join(dir, 'resolve.lock');
356
- }
357
- function releaseClaim(dir, token) {
358
- const lockFile = interactionLockPath(dir);
359
- const cur = readJson(lockFile);
360
- if (cur?.token === token) {
361
- try {
362
- unlinkSync(lockFile);
363
- }
364
- catch (e) {
365
- if (!isErrno(e, 'ENOENT'))
366
- throw e;
367
- }
368
- }
369
- const progressFile = progressPath(dir);
370
- const progress = readJson(progressFile);
371
- if (progress?.claimToken !== token)
372
- return;
373
- try {
374
- unlinkSync(progressFile);
375
- }
376
- catch (e) {
377
- if (!isErrno(e, 'ENOENT'))
378
- throw e;
379
- }
380
- }
381
- export function acquireInteractionClaim(dir, jobId, opts = {}) {
382
- const allowResolved = opts.allowResolved === true;
383
- const markProgress = opts.markProgress === true;
384
- const respectProgress = opts.respectProgress === true;
385
- const lockFile = interactionLockPath(dir);
386
- const progressFile = progressPath(dir);
387
- const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
388
- for (;;) {
389
- if (!allowResolved && isResolved(dir))
390
- return 'already_resolved';
391
- if (!allowResolved && respectProgress && existsSync(progressFile) && !existsSync(lockFile)) {
392
- const progress = readJson(progressFile);
393
- if (progress?.claim === 'crtr human resolve') {
394
- try {
395
- unlinkSync(progressFile);
396
- }
397
- catch (e) {
398
- if (!isErrno(e, 'ENOENT'))
399
- return 'claimed';
400
- }
401
- }
402
- else {
403
- return 'claimed';
404
- }
405
- }
406
- let fd = null;
407
- try {
408
- fd = openSync(lockFile, 'wx');
409
- writeFileSync(fd, JSON.stringify({ pid: process.pid, token, jobId, claimedAt: new Date().toISOString() }, null, 2));
410
- closeSync(fd);
411
- fd = null;
412
- if (markProgress) {
413
- atomicWriteJson(progressFile, { partial: true, claim: 'crtr human resolve', claimToken: token, jobId, claimedAt: new Date().toISOString() });
414
- }
415
- if (!allowResolved && isResolved(dir)) {
416
- releaseClaim(dir, token);
417
- return 'already_resolved';
418
- }
419
- return { release: () => releaseClaim(dir, token) };
420
- }
421
- catch (e) {
422
- if (fd !== null)
423
- closeSync(fd);
424
- if (isErrno(e, 'EEXIST')) {
425
- const owner = readJson(lockFile);
426
- if (typeof owner?.pid === 'number' && !pidAlive(owner.pid)) {
427
- try {
428
- unlinkSync(lockFile);
429
- }
430
- catch (unlinkErr) {
431
- if (!isErrno(unlinkErr, 'ENOENT'))
432
- return 'claimed';
433
- }
434
- continue;
435
- }
436
- return 'claimed';
437
- }
438
- throw e;
439
- }
440
- }
441
- }
442
- function writeResponseOnce(dir, responses, completedAt) {
443
- const finalPath = responsePath(dir);
444
- const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
445
- writeFileSync(tmpPath, JSON.stringify({ responses, completedAt }, null, 2), { flag: 'wx' });
446
- try {
447
- linkSync(tmpPath, finalPath);
448
- return 'written';
449
- }
450
- catch (e) {
451
- if (isErrno(e, 'EEXIST'))
452
- return 'already_resolved';
453
- throw e;
454
- }
455
- finally {
456
- try {
457
- unlinkSync(tmpPath);
458
- }
459
- catch (e) {
460
- if (!isErrno(e, 'ENOENT'))
461
- throw e;
462
- }
463
- }
464
- }
465
- function validateResolveResponses(deck, responses) {
466
- const byId = new Map(deck.interactions.map((it) => [it.id, it]));
467
- for (const response of responses) {
468
- const interaction = byId.get(response.id);
469
- if (interaction === undefined) {
470
- throw new InputError({ error: 'invalid_field', message: `unknown interaction_id: ${response.id}`, field: 'responses', next: 'Use interaction ids from `crtr human deck <job_id> --json`.' });
471
- }
472
- if (interaction.multiSelect === true) {
473
- if (response.selectedOptionId !== undefined) {
474
- throw new InputError({ error: 'invalid_field', message: `interaction ${response.id} is multi-select; use selected_option_ids`, field: 'responses', next: 'Use selected_option_ids for multi-select interactions.' });
475
- }
476
- }
477
- else if (response.selectedOptionIds !== undefined && response.selectedOptionIds.length > 1) {
478
- throw new InputError({ error: 'invalid_field', message: `interaction ${response.id} is single-select`, field: 'responses', next: 'Use selected_option_id or one selected_option_ids value for single-select interactions.' });
479
- }
480
- const selected = response.selectedOptionIds ?? (response.selectedOptionId !== undefined ? [response.selectedOptionId] : []);
481
- const optionIds = new Set(interaction.options.map((o) => o.id));
482
- for (const id of selected) {
483
- if (!optionIds.has(id)) {
484
- throw new InputError({ error: 'invalid_field', message: `unknown option id for ${response.id}: ${id}`, field: 'responses', next: 'Use option ids from `crtr human deck <job_id> --json`.' });
485
- }
486
- }
487
- }
488
- }
489
198
  function parseResolveBody(raw) {
490
199
  let parsed;
491
200
  try {
@@ -497,81 +206,68 @@ function parseResolveBody(raw) {
497
206
  if (!Array.isArray(parsed.responses)) {
498
207
  throw new InputError({ error: 'invalid_field', message: 'responses must be an array', field: 'responses', next: 'Pass {"responses":[{"interaction_id":"...","selected_option_ids":["..."]}]}.' });
499
208
  }
500
- return {
501
- responses: parsed.responses.map((r) => {
502
- const id = r.interaction_id ?? r.id;
503
- if (id === undefined || id === '') {
504
- throw new InputError({ error: 'invalid_field', message: 'each response needs interaction_id', field: 'responses', next: 'Include interaction_id for every response.' });
505
- }
506
- const selectedOptionIds = r.selected_option_ids ?? r.selectedOptionIds;
507
- const selectedOptionId = r.selected_option_id ?? r.selectedOptionId;
508
- return {
509
- id,
510
- ...(selectedOptionIds !== undefined ? { selectedOptionIds } : {}),
511
- ...(selectedOptionId !== undefined ? { selectedOptionId } : {}),
512
- ...(r.freetext !== undefined ? { freetext: r.freetext } : {}),
513
- ...(r.optionComments !== undefined ? { optionComments: r.optionComments } : {}),
514
- };
515
- }),
516
- completedAt: parsed.completed_at ?? parsed.completedAt ?? new Date().toISOString(),
517
- };
209
+ return parsed.responses.map((r) => {
210
+ const id = r.interaction_id ?? r.id;
211
+ if (id === undefined || id === '') {
212
+ throw new InputError({ error: 'invalid_field', message: 'each response needs interaction_id', field: 'responses', next: 'Include interaction_id for every response.' });
213
+ }
214
+ const selectedOptionIds = r.selected_option_ids ?? r.selectedOptionIds;
215
+ const selectedOptionId = r.selected_option_id ?? r.selectedOptionId;
216
+ return {
217
+ id,
218
+ ...(selectedOptionIds !== undefined ? { selectedOptionIds } : {}),
219
+ ...(selectedOptionId !== undefined ? { selectedOptionId } : {}),
220
+ ...(r.freetext !== undefined ? { freetext: r.freetext } : {}),
221
+ ...(r.optionComments !== undefined ? { optionComments: r.optionComments } : {}),
222
+ };
223
+ });
518
224
  }
519
225
  export const humanResolve = defineLeaf({
520
226
  name: 'resolve',
521
227
  description: 'resolve one humanloop deck from stdin answers',
522
- whenToUse: 'a browser or local tool is submitting answers for a pending human interaction; this writes through the crouter/humanloop finalize path',
228
+ whenToUse: 'a browser or local tool is submitting answers for a pending human interaction; this delegates to the humanloop finalize/completion path',
523
229
  tier: 'hidden',
524
230
  help: {
525
231
  name: 'human resolve',
526
- summary: 'write deck answers atomically and deliver them back to the asking node',
232
+ summary: 'claim a pending deck through humanloop, finalize it with the given answers, and let the completion boundary deliver back to the asking node',
527
233
  params: [
528
234
  { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'job_id from human ask/review/list.' },
529
- { kind: 'stdin', name: 'body', required: true, constraint: 'JSON {responses:[{interaction_id, selected_option_ids?, freetext?}], completed_at?}.' },
235
+ { kind: 'stdin', name: 'body', required: true, constraint: 'JSON {responses:[{interaction_id, selected_option_ids?, freetext?}]}.' },
530
236
  ],
531
237
  output: [
532
- { name: 'resolved', type: 'boolean', required: true, constraint: 'True when this call wrote and delivered the answer.' },
238
+ { name: 'resolved', type: 'boolean', required: true, constraint: 'True when this call won and finalized the deck. Delivery to the asking node happens asynchronously at humanloop\'s completion boundary and may lag or fail independently of this result.' },
533
239
  { name: 'job_id', type: 'string', required: true, constraint: 'Interaction job id.' },
534
- { name: 'delivered', type: 'boolean', required: false, constraint: 'True when delivered through crouter push/finalize.' },
535
240
  { name: 'reason', type: 'string', required: false, constraint: 'already_resolved or claimed when resolved is false.' },
536
241
  ],
537
242
  outputKind: 'object',
538
- effects: ['Claims the interaction with a local resolve.lock (and progress.json so other humanloop inbox scans skip it), writes response.json with an exclusive no-clobber create, then calls crouter finalize/deliver-back. Does not write response.json from browser/server code.'],
243
+ effects: ['Claims the ticket via humanloop, finalizes response.json through humanloop\'s exclusive publish, and lets humanloop invoke crouter\'s registered completion handler. Never writes response.json itself.'],
539
244
  },
540
245
  run: async (input) => {
541
246
  const jobId = input['job_id'];
542
247
  const { dir } = interactionDirForJob(jobId);
543
- const { responses, completedAt } = parseResolveBody(input['body']);
544
- try {
545
- parseDeck(deckPath(dir));
546
- }
547
- catch {
248
+ const responses = parseResolveBody(input['body']);
249
+ if (!existsSync(deckPath(dir))) {
548
250
  throw new InputError({ error: 'not_found', message: `no deck for interaction: ${jobId}`, field: 'job_id', next: 'Pass a pending job_id from `crtr human list`.' });
549
251
  }
550
- const claim = acquireInteractionClaim(dir, jobId, { markProgress: true, respectProgress: true });
551
- if (claim === 'already_resolved')
252
+ if (isResolved(dir))
552
253
  return { resolved: false, job_id: jobId, reason: 'already_resolved' };
553
- if (claim === 'claimed')
554
- return { resolved: false, job_id: jobId, reason: 'claimed' };
254
+ const claim = claimTicket(dir);
255
+ if (claim === null) {
256
+ return { resolved: false, job_id: jobId, reason: isResolved(dir) ? 'already_resolved' : 'claimed' };
257
+ }
258
+ let won;
555
259
  try {
556
- let deck;
557
- try {
558
- deck = parseDeck(deckPath(dir));
559
- }
560
- catch {
561
- throw new InputError({ error: 'not_found', message: `no deck for interaction: ${jobId}`, field: 'job_id', next: 'Pass a pending job_id from `crtr human list`.' });
562
- }
563
- validateResolveResponses(deck, responses);
564
- const writeResult = writeResponseOnce(dir, responses, completedAt);
565
- if (writeResult === 'already_resolved')
566
- return { resolved: false, job_id: jobId, reason: 'already_resolved' };
567
- const delivered = await finalizeResolvedInteraction(jobId, claim);
568
- if (!delivered)
569
- return { resolved: false, job_id: jobId, reason: 'already_resolved' };
570
- return { resolved: true, job_id: jobId, delivered };
260
+ ({ won } = await completeDeck(dir, responses, claim.token));
571
261
  }
572
- finally {
573
- claim.release();
262
+ catch (e) {
263
+ throw new InputError({ error: 'invalid_field', message: String(e), field: 'responses', next: 'Use interaction/option ids from `crtr human deck <job_id> --json`.' });
574
264
  }
265
+ if (!won)
266
+ return { resolved: false, job_id: jobId, reason: 'already_resolved' };
267
+ // Finalization won. Delivery back to the asking node is humanloop's
268
+ // completion-boundary responsibility (may lag or fail → delivery-error.json),
269
+ // so we do NOT claim delivery here — only that the deck was finalized.
270
+ return { resolved: true, job_id: jobId };
575
271
  },
576
272
  });
577
273
  // ---------------------------------------------------------------------------
@@ -583,8 +279,8 @@ export const humanCancel = defineLeaf({
583
279
  whenToUse: 'a question went stale before the human answered',
584
280
  help: {
585
281
  name: 'human cancel',
586
- summary: 'retract a pending ask/review you posed — kills its TUI pane, drops it from the human queue, and retires the node. Reach for this the moment a question goes stale (you answered it yourself, the situation changed) so a human is not left resolving a prompt whose answer no longer matters',
587
- guide: 'Pass the job_id returned by `human ask`/`review`. Best-effort and idempotent: if the human already answered, or it was already canceled, it reports canceled:false with reason "already_resolved" and changes nothing. The agent that posed the deck is almost always the one canceling it, so the caller is never messaged — only OTHER subscribers (e.g. the asking node when a human dismisses the prompt) get a quiet deferred note that no answer is coming. Canceling a review kills its live on-screen pane and delivers no comments — the same quiet deferred note covers it.',
282
+ summary: 'retract a pending ask/review you posed — drops it from the human inbox and retires the bridge node. Reach for this the moment a question goes stale (you answered it yourself, the situation changed) so a human is not left resolving a prompt whose answer no longer matters',
283
+ guide: 'Pass the job_id returned by `human ask`/`review`. Best-effort and idempotent: if the human already answered, or it was already canceled, it reports canceled:false with reason "already_resolved" and changes nothing. Cancellation races the human\'s submit through humanloop\'s exclusive final publish; whichever wins is authoritative. The agent that posed the deck is almost always the one canceling it, so the caller is never messaged — only OTHER subscribers get a quiet deferred note that no answer is coming.',
588
284
  params: [
589
285
  { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Node id of the interaction to cancel — the job_id returned by ask/review.' },
590
286
  { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Optional short note delivered to subscribers explaining why it was retracted.' },
@@ -596,9 +292,8 @@ export const humanCancel = defineLeaf({
596
292
  ],
597
293
  outputKind: 'object',
598
294
  effects: [
599
- "Kills the detached TUI pane (if any) so the prompt leaves the human's screen.",
600
- 'Writes a canceled response.json so the interaction drops out of `human list`/`inbox`.',
601
- 'Marks the node done and, only for subscribers other than the caller, drops a deferred note that no answer is coming.',
295
+ 'Races a canceled response.json through humanloop so the interaction drops out of the human inbox.',
296
+ 'On a winning cancellation, humanloop invokes crouter\'s completion handler, which retires the bridge node and drops a deferred no-answer note to waiting subscribers other than the caller.',
602
297
  ],
603
298
  },
604
299
  run: async (input) => {
@@ -616,169 +311,110 @@ export const humanCancel = defineLeaf({
616
311
  // Resolve the interaction dir from the node's RECORDED cwd: interaction dirs
617
312
  // are keyed by the asking process's cwd, which may differ from the caller's.
618
313
  const idir = interactionDir(jobId, node.cwd);
619
- // Nothing live to cancel: the human already answered, or it was retired.
620
- // 'canceled' is in the guard too so transition('finalize') below — legal only
621
- // from active|idle can never throw on an already-canceled (but unresolved)
622
- // interaction node.
623
- if (node.status === 'done' || node.status === 'dead' || node.status === 'canceled' || isResolved(idir)) {
314
+ // Nothing live to cancel: already answered/canceled/retired, or the ticket
315
+ // never carried a request descriptor humanloop can cancel.
316
+ const hasDescriptor = existsSync(deckPath(idir)) || existsSync(reviewPath(idir));
317
+ if (node.status === 'done' || node.status === 'dead' || node.status === 'canceled' || isResolved(idir) || !hasDescriptor) {
624
318
  return { canceled: false, job_id: jobId, reason: 'already_resolved' };
625
319
  }
626
- // (1) Kill the detached TUI pane so the prompt (or a review's live doc) leaves
627
- // the human's screen. Pass `idir` so killPane only fires when the target
628
- // pane is provably the worker we spawned for THIS job (its launch command
629
- // carries CRTR_HUMAN_DIR=idir) never the agent's own pane or a shell.
630
- const rc = readJson(join(idir, 'run.json'));
631
- if (rc?.pane_id !== undefined && rc.pane_id !== '')
632
- killPane(rc.pane_id, idir);
633
- // A review also opened a live termrender render pane beside the editor —
634
- // kill it too or it outlives the canceled job. Verified against the
635
- // reviewed file path, which termrender bakes into the pane's start command
636
- // (`termrender doc watch ... <file>`), so this can only ever hit our pane.
637
- if (rc?.render_pane_id !== undefined && rc.render_pane_id !== '' && typeof rc.file === 'string' && rc.file !== '') {
638
- killPane(rc.render_pane_id, rc.file);
639
- }
640
- // (2) Drop it from the human queue: a response.json marks the dir resolved,
641
- // so scanInbox (human list/inbox) skips it.
642
- if (existsSync(idir)) {
643
- atomicWriteJson(responsePath(idir), {
644
- canceled: true,
645
- canceledAt: new Date().toISOString(),
646
- ...(reason !== undefined && reason !== '' ? { reason } : {}),
647
- });
648
- }
649
- // (3) Retire the node. We do NOT push a -final.md report: a cancel must not
650
- // masquerade as a human-submitted result. Subscribers get the quiet
651
- // deferred 'no answer is coming' note below instead.
652
- transition(jobId, 'finalize');
653
- // Almost always the asking agent cancels its OWN deck — it already knows, so
654
- // never message the caller. Only a third-party cancel (a human, an
655
- // orchestrator) leaves a genuinely-waiting asker uninformed; give them a
656
- // quiet deferred note (informational, never nudges) so nobody waits forever.
657
- const caller = process.env['CRTR_NODE_ID'] ?? 'human';
658
- const note = reason !== undefined && reason !== '' ? ` — ${reason}` : '';
659
- for (const sub of subscribersOf(jobId)) {
660
- if (sub.node_id === caller)
661
- continue; // don't ping whoever issued the cancel
662
- appendInbox(sub.node_id, {
663
- from: caller,
664
- tier: 'deferred',
665
- kind: 'message',
666
- label: `human interaction ${jobId} canceled — no answer is coming${note}`,
667
- data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
668
- });
320
+ const actor = process.env['CRTR_NODE_ID'] ?? 'human';
321
+ const { status } = await cancelTicket(idir, { ...(reason !== undefined && reason !== '' ? { reason } : {}), actor });
322
+ if (status === 'already_resolved') {
323
+ return { canceled: false, job_id: jobId, reason: 'already_resolved' };
669
324
  }
325
+ // The winning cancellation dispatched completion → `_deliver` synchronously
326
+ // retired the bridge and notified subscribers. Nothing to do here.
670
327
  return { canceled: true, job_id: jobId };
671
328
  },
672
329
  render: (r) => r['canceled'] === true
673
- ? `Canceled human interaction ${r['job_id']} — its TUI pane is closed and subscribers were notified no answer is coming.`
330
+ ? `Canceled human interaction ${r['job_id']} — it left the human inbox and subscribers were notified no answer is coming.`
674
331
  : `Nothing to cancel for ${r['job_id']} — ${r['reason'] ?? 'nothing to cancel'}.`,
675
332
  });
676
333
  // ---------------------------------------------------------------------------
677
- // _run (hidden worker; not listed in branch help)
334
+ // _deliver (hidden completion handler; not listed in branch help)
678
335
  // ---------------------------------------------------------------------------
679
- export const humanRun = defineLeaf({
680
- name: '_run',
336
+ /** Build the ResolutionEnvelope crouter delivers back for an answered deck,
337
+ * from the canonical deck result. */
338
+ function envelopeFromDeckResult(responses, summary, completedAt, respPath) {
339
+ return { summary, responsePath: respPath, schema: 'humanloop.response/v2', responses, completedAt };
340
+ }
341
+ export const humanDeliver = defineLeaf({
342
+ name: '_deliver',
681
343
  tier: 'hidden',
682
344
  help: {
683
- name: 'human _run',
684
- summary: 'internal: the detached worker that runs the blocking humanloop call at the pane TTY',
685
- params: [],
686
- inputNote: 'Internal; invoked by the spawned pane via CRTR_HUMAN_DIR + run.json. Not for manual use.',
687
- output: [{ name: 'none', type: 'void', required: false, constraint: 'No stdout; writes the job result file directly.' }],
345
+ name: 'human _deliver',
346
+ summary: 'internal: the registered humanloop completion handler turns a resolved/canceled ticket into the existing pushFinal delivery and bridge finalization',
347
+ params: [
348
+ { kind: 'stdin', name: 'body', required: true, constraint: 'A humanloop.completion/v1 event JSON on stdin. Not for manual use.' },
349
+ ],
350
+ output: [{ name: 'delivered', type: 'boolean', required: true, constraint: 'True once the bridge is delivered/retired (or already terminal).' }],
688
351
  outputKind: 'object',
689
- effects: ['Runs the blocking humanloop call; for tracked modes pushes the result as the node\'s final report (fans out to the asking node\'s inbox).'],
352
+ effects: ['Reads the completion event, verifies root registration/containment, then pushFinals the answer (ask/review) or retires the bridge (canceled). Idempotent: replay after a crash is safe.'],
690
353
  },
691
- run: async () => {
692
- const dir = process.env['CRTR_HUMAN_DIR'];
693
- if (dir === undefined || dir === '') {
694
- process.exit(1);
695
- }
354
+ run: async (input) => {
355
+ const event = JSON.parse(input['body']);
356
+ if (event.schema !== 'humanloop.completion/v1') {
357
+ throw new InputError({ error: 'invalid_field', message: 'not a humanloop.completion/v1 event', field: 'stdin', next: 'Only humanloop invokes this handler.' });
358
+ }
359
+ // Canonicalize both paths through realpathSync before comparing. Humanloop's
360
+ // registry stores every root REALPATH-resolved, but its dispatchCompletion
361
+ // only `resolve()`s the dir into the event (not realpath) and crouter's own
362
+ // `human cancel` hands it a CRTR_HOME-derived dir that is likewise not
363
+ // realpath'd. So on any host whose interaction path has a symlinked component
364
+ // (macOS `/tmp`→`/private/tmp`, `/var`→`/private/var`), a plain `resolve()`
365
+ // comparison mismatches the registry's realpath and the containment/registration
366
+ // checks below spuriously reject a legitimate completion. Realpath'ing both
367
+ // sides makes the trust boundary robust to whichever caller produced the event.
368
+ const root = canonicalPath(event.root);
369
+ const dir = canonicalPath(event.dir);
370
+ // Containment: the interaction dir must be a direct child of the event root,
371
+ // and that root must be registered to crouter.
372
+ if (resolve(dir, '..') !== root) {
373
+ throw new InputError({ error: 'invalid_field', message: 'interaction dir is not a direct child of the event root', field: 'stdin', next: 'Only humanloop invokes this handler.' });
374
+ }
375
+ const registered = listInboxRoots().some((r) => r.root === root && r.owner === 'crouter');
376
+ if (!registered) {
377
+ throw new InputError({ error: 'invalid_field', message: `root is not registered to crouter: ${root}`, field: 'stdin', next: 'Only humanloop invokes this handler.' });
378
+ }
379
+ const jobId = basename(dir);
696
380
  const rc = readJson(join(dir, 'run.json'));
697
- if (rc === null) {
698
- process.exit(1);
381
+ // notify: a durable deck with no bridge node and no answer delivery.
382
+ if (rc?.mode === 'notify')
383
+ return { delivered: true };
384
+ const result = readTicketResult(event.responsePath);
385
+ if (result === null) {
386
+ throw new InputError({ error: 'not_found', message: 'no canonical result to deliver', field: 'stdin', next: 'Only humanloop invokes this handler.' });
699
387
  }
700
- try {
701
- if (rc.mode === 'ask' || rc.mode === 'notify') {
702
- const deck = parseDeck(deckPath(dir));
703
- if (rc.mode === 'ask') {
704
- const claim = acquireInteractionClaim(dir, rc.job_id);
705
- if (claim === 'already_resolved') {
706
- await finalizeResolvedInteraction(rc.job_id);
707
- return;
708
- }
709
- if (claim === 'claimed')
710
- return;
711
- try {
712
- await ask(deck, { dir });
713
- // Pass the held claim: finalize re-acquires the same exclusive
714
- // resolve.lock, which THIS worker still holds — without the claim it
715
- // would see its own live lock, return 'claimed', and never deliver
716
- // the answer (stranding the asker). The held claim makes finalize
717
- // run under our lock; _run's finally still owns the release.
718
- await finalizeResolvedInteraction(rc.job_id, claim);
719
- }
720
- finally {
721
- claim.release();
722
- }
723
- }
724
- else {
725
- await ask(deck, { dir });
726
- }
727
- // notify: no job — nothing to write
728
- }
729
- else if (rc.mode === 'review') {
730
- // The _run worker is already its own dedicated tmux pane with a TTY, so
731
- // run nvim directly in it (noTmux) instead of letting launchReview
732
- // split off a SECOND pane and sit polling.
733
- //
734
- // BUG REGRESSION (raw-markdown review): the nvim buffer must stay the
735
- // RAW source — anchored comments hang off source line numbers — so the
736
- // termrender render the help promises (panels/callouts/mermaid) lives
737
- // in its OWN live pane opened beside this worker. `display` spawns the
738
- // managed termrender binary in watch mode, so it re-renders on every
739
- // save exactly like the nvim buffer reloads. Best-effort: off-tmux or
740
- // renderer-unavailable degrades to editor-only. The pane id is merged
741
- // into run.json so `human cancel` can kill it; the finally clears it on
742
- // every exit of the editor (submit, quit, or failure).
743
- let renderPane;
744
- try {
745
- renderPane = display(rc.file, { window: 'split' }).paneId;
746
- }
747
- catch {
748
- /* render pane is best-effort; the review itself must not die */
749
- }
750
- // Dock the render pane BESIDE this worker pane. tmux resolves an
751
- // untargeted split-window against the attached client's current window
752
- // (which wins over $TMUX_PANE), so the pane can land in whatever window
753
- // the user happens to be looking at — away from the editor. move-pane
754
- // with explicit src/dst is deterministic: raw source and rendered doc
755
- // always sit side by side.
756
- const selfPane = process.env['TMUX_PANE'];
757
- if (renderPane !== undefined && selfPane !== undefined && selfPane !== '') {
758
- spawnSync('tmux', ['move-pane', '-h', '-s', renderPane, '-t', selfPane], { stdio: 'ignore' });
759
- }
760
- if (renderPane !== undefined) {
761
- const rcPath = join(dir, 'run.json');
762
- const cur = readJson(rcPath);
763
- if (cur !== null)
764
- atomicWriteJson(rcPath, { ...cur, render_pane_id: renderPane });
765
- }
766
- try {
767
- const res = await launchReview(rc.file, {
768
- output: rc.output,
769
- });
770
- await pushFinal(rc.job_id, JSON.stringify(res));
771
- }
772
- finally {
773
- if (renderPane !== undefined)
774
- killPane(renderPane, rc.file);
775
- }
776
- }
388
+ const node = getNode(jobId);
389
+ // Replay-safe: an already-terminal (or reaped) bridge needs no further work.
390
+ if (node === null || node.status === 'done' || node.status === 'dead' || node.status === 'canceled') {
391
+ return { delivered: true };
777
392
  }
778
- catch (e) {
779
- if (rc.job_id !== undefined) {
780
- await pushFinal(rc.job_id, JSON.stringify({ error: 'human_run_failed', message: String(e) }));
393
+ if (result.kind === 'canceled') {
394
+ transition(jobId, 'finalize');
395
+ const note = result.reason !== undefined && result.reason !== '' ? ` — ${result.reason}` : '';
396
+ const actor = result.actor;
397
+ for (const sub of subscribersOf(jobId)) {
398
+ if (actor !== undefined && sub.node_id === actor)
399
+ continue;
400
+ appendInbox(sub.node_id, {
401
+ from: jobId,
402
+ tier: 'deferred',
403
+ kind: 'message',
404
+ label: `human interaction ${jobId} canceled — no answer is coming${note}`,
405
+ data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
406
+ });
781
407
  }
408
+ return { delivered: true };
409
+ }
410
+ if (result.kind === 'review') {
411
+ const feedback = result.result;
412
+ await pushFinal(jobId, JSON.stringify(feedback));
413
+ return { delivered: true };
782
414
  }
415
+ // deck
416
+ const env = envelopeFromDeckResult(result.responses, result.summary, result.completedAt, event.responsePath);
417
+ await pushFinal(jobId, JSON.stringify(env));
418
+ return { delivered: true };
783
419
  },
784
420
  });