@crouton-kit/crouter 0.3.82 → 0.3.84

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 (94) 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 +21 -4
  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 +17 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +2 -3
  7. package/dist/clients/attach/attach-cmd.js +689 -689
  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 +57 -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 +15 -17
  22. package/dist/commands/node.js +5 -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 +85 -0
  26. package/dist/commands/sys/setup-core.d.ts +10 -0
  27. package/dist/commands/sys/setup-core.js +39 -1
  28. package/dist/commands/sys/setup.js +10 -1
  29. package/dist/core/__tests__/boot.test.js +141 -6
  30. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  31. package/dist/core/__tests__/human-deliver.test.js +332 -0
  32. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  33. package/dist/core/__tests__/pid-identity-match.test.js +145 -0
  34. package/dist/core/__tests__/tmux-surface.test.js +180 -2
  35. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  36. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  38. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  39. package/dist/core/canvas/attention.d.ts +16 -14
  40. package/dist/core/canvas/attention.js +34 -29
  41. package/dist/core/canvas/boot.d.ts +45 -0
  42. package/dist/core/canvas/boot.js +36 -3
  43. package/dist/core/canvas/browse/app.js +3 -3
  44. package/dist/core/canvas/canvas.d.ts +40 -0
  45. package/dist/core/canvas/canvas.js +65 -1
  46. package/dist/core/canvas/pid.d.ts +86 -17
  47. package/dist/core/canvas/pid.js +152 -37
  48. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  49. package/dist/core/canvas/remote-canvas-source.js +4 -2
  50. package/dist/core/canvas/render.d.ts +5 -5
  51. package/dist/core/canvas/render.js +14 -14
  52. package/dist/core/canvas/source.d.ts +2 -2
  53. package/dist/core/canvas/source.js +3 -3
  54. package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
  55. package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
  56. package/dist/core/keybindings/__tests__/resolve.test.js +2 -1
  57. package/dist/core/keybindings/catalog.d.ts +2 -2
  58. package/dist/core/keybindings/catalog.js +4 -2
  59. package/dist/core/keybindings/inbox.d.ts +21 -0
  60. package/dist/core/keybindings/inbox.js +35 -0
  61. package/dist/core/keybindings/index.d.ts +1 -0
  62. package/dist/core/keybindings/index.js +1 -0
  63. package/dist/core/preview-registry.js +23 -29
  64. package/dist/core/profiles/select.js +4 -4
  65. package/dist/core/runtime/pi-vendored.js +15 -1
  66. package/dist/core/runtime/placement.d.ts +1 -10
  67. package/dist/core/runtime/placement.js +43 -26
  68. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  69. package/dist/core/runtime/session-list-cache.js +92 -24
  70. package/dist/core/runtime/spawn.js +2 -1
  71. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  72. package/dist/core/runtime/tmux-chrome.js +1 -1
  73. package/dist/core/runtime/tmux.d.ts +1 -0
  74. package/dist/core/runtime/tmux.js +55 -1
  75. package/dist/core/spawn.d.ts +0 -59
  76. package/dist/core/spawn.js +5 -163
  77. package/dist/daemon/crtrd.js +36 -11
  78. package/dist/index.d.ts +2 -2
  79. package/dist/index.js +1 -1
  80. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  81. package/dist/web-client/index.html +1 -1
  82. package/dist/web-client/sw.js +1 -1
  83. package/package.json +2 -2
  84. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  85. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  86. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  87. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  88. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  89. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  90. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  91. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  92. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  93. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  94. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -1,79 +1,39 @@
1
- import { type DetachResult } from '../../core/spawn.js';
1
+ import type { CompletionHandler } from '@crouton-kit/humanloop';
2
2
  export declare const DECK_SCHEMA_HINT: string;
3
+ /**
4
+ * Crouter's private per-interaction record. Reduced to the delivery essentials:
5
+ * the mode `_deliver` dispatches on and the bridge node id it delivers back to.
6
+ * Humanloop owns every public request/result/output path (deck.json,
7
+ * response.json, review.json); crouter never records pane ids or output paths.
8
+ */
3
9
  export interface RunRecord {
4
10
  mode: 'ask' | 'notify' | 'review';
5
11
  job_id?: string;
6
- file?: string;
7
- output?: string;
8
- /** tmux pane id of the detached TUI, recorded so `human cancel` can kill it. */
9
- pane_id?: string;
10
- /** tmux pane id of a review's live termrender render pane, so cancel/finish can clear it. */
11
- render_pane_id?: string;
12
12
  }
13
13
  export declare function resolveMaxPanes(): number;
14
- export declare function pickPlacement(targetPane?: string): 'split-h' | 'new-window';
15
- /**
16
- * The tmux pane the humanloop TUI should open BESIDE so a PERSON actually sees
17
- * it. A prompt raised by a canvas node must NOT land in the backstage `crtr`
18
- * session (the holding ground for un-watched node panes) — it lands in the
19
- * session the user is watching that node's graph in:
20
- * 1. node prompt (CRTR_NODE_ID set) → the HIGHEST FOCUSED node of its graph
21
- * (`graphSurfaceTarget`, the focused node closest to the graph root): split
22
- * beside it, in the session/window the user already has it open in.
23
- * 2. nothing in the graph on screen (or a dead focus pane), or a bare-shell
24
- * prompt → the user's currently-attached pane (`attachedClientPane`).
25
- * 3. neither resolvable → undefined (tmux falls back to the caller pane).
26
- * The TUI lands in the right place but NEVER switches the user's session/window
27
- * (no switch-client / select-window; new-window opens `-d`). They see it when
28
- * they look at the node they are already watching.
29
- */
30
- export declare function resolveHumanTarget(): string | undefined;
31
- /**
32
- * Open the detached humanloop `_run` pane for an interaction dir, ROUTED to the
33
- * session the user is watching (`resolveHumanTarget`). The single spawn path
34
- * behind ask/review (`spawnHumanJob`) and notify. `detached: true` keeps
35
- * the user's view put — the prompt lands beside the watched node without
36
- * jumping their session/window. `jobId`, when given, is injected as CRTR_JOB_ID.
37
- */
38
- export declare function detachHumanTui(idir: string, cwd: string, jobId?: string): DetachResult;
39
- export declare function runCmd(dir: string): string;
40
- export declare function followUpResult(_jobId: string): string;
41
- export declare function followUpDrain(_jobId: string): string;
42
14
  /**
43
- * Road sign for a spawned `human review`. It is a non-blocking kickoff, so the
44
- * text steers the caller to stop rather than wait, verify, or re-present: the
45
- * pane is already live and tracks the file, and the comments arrive via the
46
- * inbox/wake when the human submits.
47
- */
48
- export declare function followUpReview(_jobId: string): string;
49
- /**
50
- * Spawn the detached `_run` pane that drives the humanloop TUI for this node.
51
- * Returns whether the pane spawned and the follow_up road sign. Degrades to the
52
- * inbox-drain follow_up when not in tmux / spawn fails — kickoffs are
53
- * intentionally non-fatal off-tmux.
15
+ * The direct-exec completion handler humanloop runs when a crouter ticket
16
+ * resolves, fed the completion event on stdin. The argv is absolute and
17
+ * PATH/cwd-independent never a `crtr` shell string and, critically, must
18
+ * actually EXECUTE in both crouter contexts:
19
+ *
20
+ * - Built (`dist/cli.js` present): `node <abs>/dist/cli.js human _deliver`.
21
+ * - Dev/tsx (only `src/cli.ts` on disk): plain `node` cannot run the `.ts`
22
+ * entry its mandatory `.js`-extension ESM imports have no `.js` sibling on
23
+ * disk, so Node's type-stripping fails at the first relative import. So we
24
+ * register tsx's ESM loader first (`node --import <abs tsx/esm> src/cli.ts …`),
25
+ * with the tsx entry resolved to an absolute file URL so the handler stays
26
+ * cwd-independent when humanloop invokes it. tsx is necessarily present here
27
+ * because crouter itself is running under it.
54
28
  *
55
- * Completion routing needs no bookkeeping here: the human node was created
56
- * under the asking node as its parent (spawnNode auto-subscribes the parent),
57
- * so the `pushFinal` the `_run` worker emits — for ask AND review —
58
- * fans the answer straight into the asking node's inbox. The pane id is recorded
59
- * on run.json (not returned) so `human cancel` can later kill the TUI.
29
+ * Throws loudly when neither entry exists rather than registering an argv that
30
+ * humanloop would retry forever.
60
31
  */
61
- export declare function spawnHumanJob(jobId: string, idir: string, cwd: string): {
62
- spawned: boolean;
63
- follow_up: string;
64
- };
32
+ export declare function crouterDeliverHandler(): CompletionHandler;
65
33
  /**
66
- * Best-effort kill of a humanloop worker pane. SAFETY-CRITICAL: a malformed or
67
- * empty `-t` target makes tmux fall back to the CALLER's current pane, so a bad
68
- * paneId could kill the agent's own pi pane (and, if it is the last pane, the
69
- * whole session). This refuses to kill anything that is not provably the worker:
70
- *
71
- * 1. paneId must be a real tmux pane id (`%<n>`) — never an empty/odd string.
72
- * 2. The pane's start command must contain `verify` (the interaction dir, which
73
- * humanloop bakes into the worker's `CRTR_HUMAN_DIR=... crtr human _run`
74
- * launch). A shell (`zsh -l`) or the agent's pi never matches, so we can
75
- * only ever kill the exact worker we spawned for this job.
76
- *
77
- * Returns true only when a matching pane was found and killed. Never throws.
34
+ * Register the per-cwd interaction root with humanloop as owner `crouter`,
35
+ * attaching the `_deliver` completion handler. Idempotent for owner `crouter`
36
+ * (refreshes the handler), and creates/canonicalizes the root. Returns the
37
+ * canonical root path humanloop submissions must be rooted at.
78
38
  */
79
- export declare function killPane(paneId: string, verify: string): boolean;
39
+ export declare function registerCrouterRoot(cwd: string): string;
@@ -1,9 +1,10 @@
1
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ import { existsSync } from 'node:fs';
1
5
  import { readConfig } from '../../core/config.js';
2
- import { spawnSync } from 'node:child_process';
3
- import { join } from 'node:path';
4
- import { atomicWriteJson, readJson } from '@crouton-kit/humanloop';
5
- import { countPanesInWindow, spawnAndDetach, shellQuote, attachedClientPane, paneAlive, } from '../../core/spawn.js';
6
- import { graphSurfaceTarget } from '../../core/runtime/placement.js';
6
+ import { interactionsRoot } from '../../core/artifact.js';
7
+ import { registerInboxRoot } from '@crouton-kit/humanloop';
7
8
  export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
8
9
  'source?:{sessionName?,askedBy?,blockedSince?}, interactions:[{id, ' +
9
10
  'title (short ≤~4-word topic — the thing being decided, not the decision), ' +
@@ -16,127 +17,43 @@ export const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {tit
16
17
  export function resolveMaxPanes() {
17
18
  return readConfig('user').max_panes_per_window;
18
19
  }
19
- export function pickPlacement(targetPane) {
20
- return countPanesInWindow(targetPane) >= resolveMaxPanes() ? 'new-window' : 'split-h';
21
- }
22
- /**
23
- * The tmux pane the humanloop TUI should open BESIDE so a PERSON actually sees
24
- * it. A prompt raised by a canvas node must NOT land in the backstage `crtr`
25
- * session (the holding ground for un-watched node panes) — it lands in the
26
- * session the user is watching that node's graph in:
27
- * 1. node prompt (CRTR_NODE_ID set) → the HIGHEST FOCUSED node of its graph
28
- * (`graphSurfaceTarget`, the focused node closest to the graph root): split
29
- * beside it, in the session/window the user already has it open in.
30
- * 2. nothing in the graph on screen (or a dead focus pane), or a bare-shell
31
- * prompt → the user's currently-attached pane (`attachedClientPane`).
32
- * 3. neither resolvable → undefined (tmux falls back to the caller pane).
33
- * The TUI lands in the right place but NEVER switches the user's session/window
34
- * (no switch-client / select-window; new-window opens `-d`). They see it when
35
- * they look at the node they are already watching.
36
- */
37
- export function resolveHumanTarget() {
38
- const nodeId = process.env['CRTR_NODE_ID'];
39
- if (nodeId !== undefined && nodeId !== '') {
40
- const f = graphSurfaceTarget(nodeId);
41
- if (f !== null && f.pane !== null && paneAlive(f.pane))
42
- return f.pane;
43
- }
44
- return attachedClientPane() ?? undefined;
45
- }
46
20
  /**
47
- * Open the detached humanloop `_run` pane for an interaction dir, ROUTED to the
48
- * session the user is watching (`resolveHumanTarget`). The single spawn path
49
- * behind ask/review (`spawnHumanJob`) and notify. `detached: true` keeps
50
- * the user's view put the prompt lands beside the watched node without
51
- * jumping their session/window. `jobId`, when given, is injected as CRTR_JOB_ID.
52
- */
53
- export function detachHumanTui(idir, cwd, jobId) {
54
- const targetPane = resolveHumanTarget();
55
- return spawnAndDetach({
56
- command: runCmd(idir),
57
- cwd,
58
- ...(jobId !== undefined ? { jobId } : {}),
59
- placement: pickPlacement(targetPane),
60
- killAfterSeconds: 0,
61
- detached: true,
62
- ...(targetPane !== undefined ? { targetPane } : {}),
63
- });
64
- }
65
- export function runCmd(dir) {
66
- return `CRTR_HUMAN_DIR=${shellQuote(dir)} crtr human _run`;
67
- }
68
- export function followUpResult(_jobId) {
69
- return "The human's answer is delivered to your inbox when they respond — no need to poll.";
70
- }
71
- export function followUpDrain(_jobId) {
72
- return ('Not in tmux: a human must drain it — run `crtr human inbox` (or re-run ' +
73
- 'inside tmux). The answer then arrives in your inbox.');
74
- }
75
- /**
76
- * Road sign for a spawned `human review`. It is a non-blocking kickoff, so the
77
- * text steers the caller to stop rather than wait, verify, or re-present: the
78
- * pane is already live and tracks the file, and the comments arrive via the
79
- * inbox/wake when the human submits.
80
- */
81
- export function followUpReview(_jobId) {
82
- return ("The document is live on the human's screen for anchored, line-by-line " +
83
- 'review. The pane tracks the file — edit the .md in place and it re-renders ' +
84
- 'on save, so never cancel and re-present just to show a change. Do not poll, ' +
85
- 'verify it opened, or background this call; end your turn. The human reviews ' +
86
- 'on their own time, and their comments (with any line edits) arrive in your ' +
87
- 'inbox and wake you when they submit.');
88
- }
89
- /**
90
- * Spawn the detached `_run` pane that drives the humanloop TUI for this node.
91
- * Returns whether the pane spawned and the follow_up road sign. Degrades to the
92
- * inbox-drain follow_up when not in tmux / spawn fails — kickoffs are
93
- * intentionally non-fatal off-tmux.
21
+ * The direct-exec completion handler humanloop runs when a crouter ticket
22
+ * resolves, fed the completion event on stdin. The argv is absolute and
23
+ * PATH/cwd-independent — never a `crtr` shell string and, critically, must
24
+ * actually EXECUTE in both crouter contexts:
25
+ *
26
+ * - Built (`dist/cli.js` present): `node <abs>/dist/cli.js human _deliver`.
27
+ * - Dev/tsx (only `src/cli.ts` on disk): plain `node` cannot run the `.ts`
28
+ * entry its mandatory `.js`-extension ESM imports have no `.js` sibling on
29
+ * disk, so Node's type-stripping fails at the first relative import. So we
30
+ * register tsx's ESM loader first (`node --import <abs tsx/esm> src/cli.ts …`),
31
+ * with the tsx entry resolved to an absolute file URL so the handler stays
32
+ * cwd-independent when humanloop invokes it. tsx is necessarily present here
33
+ * because crouter itself is running under it.
94
34
  *
95
- * Completion routing needs no bookkeeping here: the human node was created
96
- * under the asking node as its parent (spawnNode auto-subscribes the parent),
97
- * so the `pushFinal` the `_run` worker emits — for ask AND review —
98
- * fans the answer straight into the asking node's inbox. The pane id is recorded
99
- * on run.json (not returned) so `human cancel` can later kill the TUI.
35
+ * Throws loudly when neither entry exists rather than registering an argv that
36
+ * humanloop would retry forever.
100
37
  */
101
- export function spawnHumanJob(jobId, idir, cwd) {
102
- const spawn = detachHumanTui(idir, cwd, jobId);
103
- if (spawn.status !== 'spawned') {
104
- return { spawned: false, follow_up: followUpDrain(jobId) };
38
+ export function crouterDeliverHandler() {
39
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/commands/human | src/commands/human
40
+ const distEntry = join(here, '..', '..', 'cli.js');
41
+ if (existsSync(distEntry)) {
42
+ return { command: process.execPath, args: [distEntry, 'human', '_deliver'] };
105
43
  }
106
- // Record the pane id on run.json so `human cancel` can later kill the TUI.
107
- // run.json was already written by the caller; merge the pane id in place.
108
- if (spawn.paneId !== undefined) {
109
- const rcPath = join(idir, 'run.json');
110
- const rc = readJson(rcPath);
111
- if (rc !== null)
112
- atomicWriteJson(rcPath, { ...rc, pane_id: spawn.paneId });
44
+ const tsEntry = join(here, '..', '..', 'cli.ts');
45
+ if (existsSync(tsEntry)) {
46
+ const tsxLoader = pathToFileURL(createRequire(import.meta.url).resolve('tsx/esm')).href;
47
+ return { command: process.execPath, args: ['--import', tsxLoader, tsEntry, 'human', '_deliver'] };
113
48
  }
114
- return { spawned: true, follow_up: followUpResult(jobId) };
49
+ throw new Error(`crouter completion handler: no runnable CLI entry near ${here} (neither cli.js nor cli.ts exists)`);
115
50
  }
116
51
  /**
117
- * Best-effort kill of a humanloop worker pane. SAFETY-CRITICAL: a malformed or
118
- * empty `-t` target makes tmux fall back to the CALLER's current pane, so a bad
119
- * paneId could kill the agent's own pi pane (and, if it is the last pane, the
120
- * whole session). This refuses to kill anything that is not provably the worker:
121
- *
122
- * 1. paneId must be a real tmux pane id (`%<n>`) — never an empty/odd string.
123
- * 2. The pane's start command must contain `verify` (the interaction dir, which
124
- * humanloop bakes into the worker's `CRTR_HUMAN_DIR=... crtr human _run`
125
- * launch). A shell (`zsh -l`) or the agent's pi never matches, so we can
126
- * only ever kill the exact worker we spawned for this job.
127
- *
128
- * Returns true only when a matching pane was found and killed. Never throws.
52
+ * Register the per-cwd interaction root with humanloop as owner `crouter`,
53
+ * attaching the `_deliver` completion handler. Idempotent for owner `crouter`
54
+ * (refreshes the handler), and creates/canonicalizes the root. Returns the
55
+ * canonical root path humanloop submissions must be rooted at.
129
56
  */
130
- export function killPane(paneId, verify) {
131
- if (!/^%\d+$/.test(paneId))
132
- return false;
133
- const probe = spawnSync('tmux', ['display-message', '-p', '-t', paneId, '#{pane_start_command}'], {
134
- encoding: 'utf8',
135
- });
136
- // Pane is gone (status !== 0) → nothing to kill. Pane exists but its launch
137
- // command doesn't carry our interaction dir → it is NOT our worker; refuse.
138
- if (probe.status !== 0 || !probe.stdout.includes(verify))
139
- return false;
140
- const r = spawnSync('tmux', ['kill-pane', '-t', paneId], { encoding: 'utf8' });
141
- return r.status === 0;
57
+ export function registerCrouterRoot(cwd) {
58
+ return registerInboxRoot({ root: interactionsRoot(cwd), owner: 'crouter', handler: crouterDeliverHandler() }).root;
142
59
  }
@@ -1,31 +1,30 @@
1
- // `crtr human` subtree — in-process humanloop bridge.
1
+ // `crtr human` subtree — humanloop producer + completion bridge.
2
2
  //
3
- // Kickoff leaves (ask/review) create a kind:'human' node under the
4
- // asking node, write deck.json/run.json into the per-cwd interaction dir, spawn
5
- // a detached `crtr human _run` pane, and return immediately. The human's answer
6
- // is pushed as the node's final report, which fans out to the asking node's
7
- // inbox no polling surface. notify/show create no node. _run runs the blocking
8
- // humanloop call at the pane TTY and pushes the result itself.
9
- //
10
- // TTY safety: kickoff/TUI leaves are argv-only, so the spawned pane's TTY stays
11
- // free for humanloop's raw-mode input. Control params travel via CRTR_HUMAN_DIR
12
- // (set inline in the spawned command) + run.json, never stdin. The noninteractive
13
- // resolve leaf is the only stdin-taking child and does not spawn a TUI pane.
3
+ // Kickoff leaves (ask/review) create a terminal kind:'human' bridge node under
4
+ // the asking node, register the per-cwd interaction root with humanloop (owner
5
+ // 'crouter', `_deliver` handler), submit the deck/review, and return at once
6
+ // nothing opens on screen. When the human answers from humanloop's inbox popup
7
+ // (the inbox-toggle keybinding, or `hl inbox open`), humanloop invokes the
8
+ // registered `_deliver` handler, which pushFinals
9
+ // the answer as the bridge node's final report, fanning it into the asking
10
+ // node's inbox. notify enqueues a durable deck with no node; show is passive
11
+ // display. Humanloop owns the queue UI, claims, and atomic finalization.
14
12
  import { defineBranch } from '../core/command.js';
15
13
  import { humanAsk, humanReview, humanNotify, humanShow } from './human/prompts.js';
16
- import { humanInbox, humanList, humanDeck, humanResolve, humanCancel, humanRun } from './human/queue.js';
14
+ import { humanList, humanDeck, humanResolve, humanCancel, humanDeliver } from './human/queue.js';
15
+ import { inboxOpenInstruction } from '../core/keybindings/index.js';
17
16
  export function registerHuman() {
18
17
  return defineBranch({
19
18
  name: 'human',
20
19
  rootEntry: {
21
20
  concept: 'human-in-the-loop decisions, document review, and live display',
22
- desc: 'ask, review, notify, show, cancel, inbox, list',
21
+ desc: 'ask, review, notify, show, cancel, list',
23
22
  useWhen: 'you have a question for the user, want their feedback, or are presenting them with options or a choice — always reach for human instead of guessing or assuming, or laying a choice out as prose, when a person can decide; and whenever the user wants to review a spec, plan, or requirements document, this is the command for anchored line-by-line comments.'
24
23
  },
25
24
  help: {
26
25
  name: 'human',
27
26
  summary: 'human-in-the-loop decisions, document review, and live display',
28
- model: "Every body and displayed file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one. ask and review are kickoffs: they create a kind:'human' node under you and return instantly, never blocking — the human's response is pushed to your inbox when they answer, so keep working (or just end your turn) and you'll be woken with it. ask covers everything from a yes/no sign-off gate (two options) to an open-ended judgment call; review puts a .md live on the human's screen for anchored comments and tracks the file, so edit in place rather than canceling and re-presenting. notify and show create no node.",
27
+ model: `Every body and displayed file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see \`termrender doc -h\` for the directive set before authoring one. ask and review are kickoffs: they create a kind:'human' bridge node under you and return instantly, never blocking. Nothing opens on screen — the ticket is queued in the humanloop inbox; the human opens it — ${inboxOpenInstruction()} — on their own time, and their response is pushed to your inbox when they answer, so keep working (or just end your turn) and you'll be woken with it. ask covers everything from a yes/no sign-off gate (two options) to an open-ended judgment call; review queues a .md for anchored comments and the inbox previews the live file, so edit in place rather than canceling and re-presenting. notify enqueues a durable acknowledgement with no node; show is passive live display.`,
29
28
  },
30
29
  children: [
31
30
  humanAsk,
@@ -33,11 +32,10 @@ export function registerHuman() {
33
32
  humanNotify,
34
33
  humanShow,
35
34
  humanCancel,
36
- humanInbox,
37
35
  humanList,
38
36
  humanDeck,
39
37
  humanResolve,
40
- humanRun,
38
+ humanDeliver,
41
39
  ],
42
40
  });
43
41
  }
@@ -15,6 +15,7 @@ import { writeYieldMessage, readGoal } from '../core/runtime/kickoff.js';
15
15
  import { reviveNode } from '../core/runtime/revive.js';
16
16
  import { parseOutputSchemaValue, writeOutputSchema } from '../core/runtime/structured-output.js';
17
17
  import { newNodeId } from '../core/runtime/nodes.js';
18
+ import { inboxPopupHint } from '../core/keybindings/index.js';
18
19
  import { readRoadmap, hasRoadmap, seedRoadmap } from '../core/runtime/roadmap.js';
19
20
  import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
20
21
  import { nodeSnapshotLeaf } from './node-snapshot.js';
@@ -617,6 +618,7 @@ const nodeFocus = defineLeaf({
617
618
  params: [
618
619
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
619
620
  { kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Always open a NEW viewer SIDE-BY-SIDE with your current pane instead of moving/reusing the node\'s existing viewer. Two agents on screen at once (F4).' },
621
+ { kind: 'flag', name: 'in-place', type: 'bool', required: false, constraint: 'Re-exec the --pane pane ONTO this node, REPLACING whatever viewer it showed (a swap in place, not a split beside). Backs the attach viewer\'s subagent scroll+swap (↓ into the reports roster, Enter dives into the child right in your pane). MUST be driven from OUTSIDE that pane\'s process tree (e.g. `tmux run-shell`) — respawn-pane -k kills whatever runs in it. Mutually exclusive with --new-pane.' },
620
622
  { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to focus beside (default: caller TMUX_PANE). Used by the canvas browser popup to focus back into the originating pane.' },
621
623
  ],
622
624
  output: [
@@ -626,7 +628,7 @@ const nodeFocus = defineLeaf({
626
628
  { name: 'in_place', type: 'boolean', required: true, constraint: 'True when an existing viewer was navigated to (no new pane); false when a fresh viewer pane was opened or moved beside you.' },
627
629
  ],
628
630
  outputKind: 'object',
629
- effects: ['Ensures the node\'s broker engine is alive (revives it if dormant) and opens/moves its one viewer pane (a surface attach view) beside the caller, or navigates to it if a live viewer already sits in the caller\'s session.', 'With --new-pane: always splits a fresh viewer beside the caller (two agents on screen) instead of reusing the existing viewer.'],
631
+ effects: ['Ensures the node\'s broker engine is alive (revives it if dormant) and opens/moves its one viewer pane (a surface attach view) beside the caller, or navigates to it if a live viewer already sits in the caller\'s session.', 'With --new-pane: always splits a fresh viewer beside the caller (two agents on screen) instead of reusing the existing viewer.', 'With --in-place: re-execs the --pane pane onto the node, replacing its current viewer (a swap in place) rather than opening beside; the replaced node\'s broker keeps running headless.'],
630
632
  },
631
633
  run: async (input) => {
632
634
  const id = input['node'];
@@ -641,7 +643,7 @@ const nodeFocus = defineLeaf({
641
643
  throw new InputError({
642
644
  error: 'not_focusable',
643
645
  message: `node ${id} is a human-ask (kind:human), not a conversation — it has no pi session to focus.`,
644
- next: `The pending question is already on the human's screen; see it with \`crtr human list\` / \`crtr human inbox\`, or retract it with \`crtr human cancel ${id}\`.`,
646
+ next: `The pending question is queued for the human (${inboxPopupHint()} opens the inbox popup); see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
645
647
  });
646
648
  }
647
649
  // Placement owns the whole act: ensure the target's broker engine is alive
@@ -651,6 +653,7 @@ const nodeFocus = defineLeaf({
651
653
  const res = await placementFocus(id, {
652
654
  pane: input['pane'],
653
655
  newPane: input['newPane'] === true,
656
+ inPlace: input['inPlace'] === true,
654
657
  revive: (nid) => reviveNode(nid, { resume: true }),
655
658
  });
656
659
  return { focused: res.focused, session: res.session, revived: res.revived, in_place: res.inPlace };
@@ -7,7 +7,7 @@ import { defineBranch } from '../core/command.js';
7
7
  import { registerView } from './view.js';
8
8
  import { registerWorkspace } from './workspace.js';
9
9
  import { registerWeb } from '../clients/web/web-cmd.js';
10
- import { registerAttach } from '../clients/attach/attach-cmd.js';
10
+ import { registerAttach } from '../clients/attach/attach-def.js';
11
11
  import { tmuxSpreadLeaf } from './surface-tmux-spread.js';
12
12
  function namespaceForSurface(node) {
13
13
  node.help.name = `surface ${node.help.name}`;
@@ -55,7 +55,7 @@ test('setup keybinding draft keeps the last valid cell value on syntax, collisio
55
55
  assert.deepEqual(draft.overrides(), {});
56
56
  const collision = draft.edit('crtr.attach.graph.toggle', 'alt+m');
57
57
  assert.equal(collision.ok, false);
58
- assert.match(collision.diagnostics[0].message, /crtr\.mode\.cycle/);
58
+ assert.match(collision.diagnostics[0].message, /crtr\.attach\.model-ladder\.next/);
59
59
  assert.deepEqual(draft.overrides(), {});
60
60
  const unsafe = draft.unbind('crtr.setup.keybindings.save');
61
61
  assert.equal(unsafe.ok, false);
@@ -0,0 +1,85 @@
1
+ // Run with: node --import tsx/esm --test src/commands/sys/__tests__/setup-front-door.test.ts
2
+ //
3
+ // M1 — `crtr sys setup` front-door reconciliation. Setup registers known nodes'
4
+ // interaction roots to owner 'crouter' so a resolved humanloop ticket can call
5
+ // back into the asking node (best-effort). The inbox-toggle tmux key is a
6
+ // crouter keybinding installed by `installTmuxBindings()`, not reconciled here.
7
+ // The humanloop registry is isolated with a temp XDG_STATE_HOME (see
8
+ // human-deliver.test.ts); the canvas is isolated with a temp CRTR_HOME + HOME.
9
+ import { test, before, beforeEach, after } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { mkdtempSync, realpathSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { listInboxRoots } from '@crouton-kit/humanloop';
15
+ import { spawnNode } from '../../../core/runtime/nodes.js';
16
+ import { closeDb } from '../../../core/canvas/index.js';
17
+ import { reconcileInboxFrontDoor } from '../setup-core.js';
18
+ import { sysSetupLeaf } from '../setup.js';
19
+ let home;
20
+ let stateHome;
21
+ let priorHome;
22
+ before(() => {
23
+ priorHome = process.env['HOME'];
24
+ });
25
+ beforeEach(() => {
26
+ closeDb();
27
+ home = mkdtempSync(join(tmpdir(), 'crtr-setup-home-'));
28
+ stateHome = mkdtempSync(join(tmpdir(), 'crtr-setup-state-'));
29
+ process.env['CRTR_HOME'] = home;
30
+ process.env['HOME'] = home;
31
+ process.env['XDG_STATE_HOME'] = stateHome;
32
+ delete process.env['CRTR_NODE_ID'];
33
+ });
34
+ after(() => {
35
+ closeDb();
36
+ if (priorHome !== undefined)
37
+ process.env['HOME'] = priorHome;
38
+ else
39
+ delete process.env['HOME'];
40
+ delete process.env['CRTR_HOME'];
41
+ delete process.env['XDG_STATE_HOME'];
42
+ });
43
+ test('reconcile registers each known node cwd as an owner-crouter interaction root', () => {
44
+ // Two nodes sharing one cwd + one on another cwd → two distinct roots.
45
+ const otherCwd = mkdtempSync(join(tmpdir(), 'crtr-setup-cwd-'));
46
+ spawnNode({ kind: 'general', parent: null, cwd: home, name: 'a' });
47
+ spawnNode({ kind: 'general', parent: null, cwd: home, name: 'b' });
48
+ spawnNode({ kind: 'general', parent: null, cwd: otherCwd, name: 'c' });
49
+ const { interactionRootsRegistered } = reconcileInboxFrontDoor();
50
+ // Deduped by cwd: two cwds → two registrations.
51
+ assert.equal(interactionRootsRegistered, 2, 'one registration per distinct node cwd');
52
+ const crouterRoots = listInboxRoots().filter((r) => r.owner === 'crouter');
53
+ assert.ok(crouterRoots.length >= 2, 'both interaction roots landed in the humanloop registry as owner crouter');
54
+ });
55
+ test('invoking `crtr sys setup` (non-TTY leaf run) reconciles the front door', async () => {
56
+ // Exercise the real control flow — sysSetupLeaf.run() → runSetup() →
57
+ // reconcileInboxFrontDoor — not the helper directly. With no TTY the leaf
58
+ // prints static instructions and returns, but reconciliation must still have
59
+ // happened on the way through.
60
+ spawnNode({ kind: 'general', parent: null, cwd: home, name: 'a' });
61
+ const originalIsTTY = process.stdout.isTTY;
62
+ const originalWrite = process.stdout.write.bind(process.stdout);
63
+ // Force the non-TTY (instructions-print) branch and swallow its output.
64
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
65
+ process.stdout.write = () => true;
66
+ try {
67
+ await sysSetupLeaf.run({});
68
+ }
69
+ finally {
70
+ process.stdout.write = originalWrite;
71
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
72
+ }
73
+ const homeReal = realpathSync(home);
74
+ const crouterRoots = listInboxRoots().filter((r) => r.owner === 'crouter');
75
+ // registerCrouterRoot maps the node cwd to its workspace interactions dir
76
+ // under that cwd, so the registered root is rooted at the node's home.
77
+ assert.ok(crouterRoots.some((r) => r.root.startsWith(homeReal)), 'the leaf invocation registered the node cwd as an owner-crouter interaction root');
78
+ });
79
+ test('reconcile is idempotent for owner crouter — re-running does not error and keeps the roots', () => {
80
+ spawnNode({ kind: 'general', parent: null, cwd: home, name: 'a' });
81
+ reconcileInboxFrontDoor();
82
+ const { interactionRootsRegistered } = reconcileInboxFrontDoor();
83
+ assert.equal(interactionRootsRegistered, 1);
84
+ assert.ok(listInboxRoots().some((r) => r.owner === 'crouter'));
85
+ });
@@ -51,6 +51,7 @@ export interface SetupExecutionSummary {
51
51
  exaKeyPath: string;
52
52
  composioKeyWritten: boolean;
53
53
  composioKeyPath: string;
54
+ interactionRootsRegistered: number;
54
55
  }
55
56
  export interface SetupInitialState {
56
57
  manifest: SetupPackageManifestEntry[];
@@ -122,6 +123,14 @@ export declare function buildSetupStaticInstructions(opts: {
122
123
  export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath' | 'composioKeyPath'>): string;
123
124
  export declare function writeExaKey(exaKeyPath: string, exaKey: string): Promise<void>;
124
125
  export declare function writeComposioKey(composioKeyPath: string, composioKey: string): Promise<void>;
126
+ /** Register each known node's interaction root to owner `crouter` so a resolved
127
+ * ticket can call back into `human _deliver`. Best-effort: setup must never
128
+ * fail because the canvas is unreadable. The inbox-toggle tmux key is NOT
129
+ * reconciled here — it is a crouter keybinding installed by `installTmuxBindings()`
130
+ * alongside every other binding. */
131
+ export declare function reconcileInboxFrontDoor(): {
132
+ interactionRootsRegistered: number;
133
+ };
125
134
  export declare function summarizeSetupExecution(result: {
126
135
  installedPackages: string[];
127
136
  skippedPackages: string[];
@@ -131,6 +140,7 @@ export declare function summarizeSetupExecution(result: {
131
140
  exaKeyPath: string;
132
141
  composioKeyWritten: boolean;
133
142
  composioKeyPath: string;
143
+ interactionRootsRegistered: number;
134
144
  }): SetupExecutionSummary;
135
145
  export declare function formatSetupExecutionSummary(summary: SetupExecutionSummary): string;
136
146
  export declare function executeSetupSubmission(submission: SetupSubmission): Promise<SetupExecutionSummary>;
@@ -4,10 +4,12 @@ import { readFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
- import { BINDING_CATALOG, normalizeGesture, resolveKeybindings, } from '../../core/keybindings/index.js';
7
+ import { BINDING_CATALOG, normalizeGesture, inboxShortcut, resolveKeybindings, } from '../../core/keybindings/index.js';
8
8
  import { builtinPiPackageDir } from '../../core/scope.js';
9
9
  import { readJsonIfExists } from '../../core/fs-utils.js';
10
10
  import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
11
+ import { listNodes } from '../../core/canvas/index.js';
12
+ import { registerCrouterRoot } from '../human/shared.js';
11
13
  function isKeybindingsObject(value) {
12
14
  return value !== null && typeof value === 'object' && !Array.isArray(value);
13
15
  }
@@ -422,6 +424,8 @@ export function renderSetupStaticInstructions(state) {
422
424
  '',
423
425
  instructions.exaLine,
424
426
  instructions.composioLine,
427
+ '',
428
+ `Inbox popup: the inbox-toggle key (${inboxShortcut() ?? 'disabled'}) is a crouter keybinding — view or rebind it in the \`crtr sys setup\` keybindings tab; it is installed on the same tmux server as every other crouter binding. \`crtr sys setup\` also registers known nodes' interaction roots (best-effort).`,
425
429
  instructions.footer,
426
430
  ].join('\n') + '\n';
427
431
  }
@@ -447,6 +451,33 @@ export async function writeComposioKey(composioKeyPath, composioKey) {
447
451
  await writeFile(composioKeyPath, `${composioKey}\n`, 'utf8');
448
452
  await chmod(composioKeyPath, 0o600).catch(() => undefined);
449
453
  }
454
+ /** Register each known node's interaction root to owner `crouter` so a resolved
455
+ * ticket can call back into `human _deliver`. Best-effort: setup must never
456
+ * fail because the canvas is unreadable. The inbox-toggle tmux key is NOT
457
+ * reconciled here — it is a crouter keybinding installed by `installTmuxBindings()`
458
+ * alongside every other binding. */
459
+ export function reconcileInboxFrontDoor() {
460
+ let interactionRootsRegistered = 0;
461
+ try {
462
+ const seen = new Set();
463
+ for (const node of listNodes()) {
464
+ if (seen.has(node.cwd))
465
+ continue;
466
+ seen.add(node.cwd);
467
+ try {
468
+ registerCrouterRoot(node.cwd);
469
+ interactionRootsRegistered += 1;
470
+ }
471
+ catch {
472
+ /* skip a cwd humanloop can't canonicalize/create */
473
+ }
474
+ }
475
+ }
476
+ catch {
477
+ /* best-effort: canvas unavailable */
478
+ }
479
+ return { interactionRootsRegistered };
480
+ }
450
481
  export function summarizeSetupExecution(result) {
451
482
  return {
452
483
  installed: [...result.installedPackages, ...result.installedSystemDeps],
@@ -455,6 +486,7 @@ export function summarizeSetupExecution(result) {
455
486
  exaKeyPath: result.exaKeyPath,
456
487
  composioKeyWritten: result.composioKeyWritten,
457
488
  composioKeyPath: result.composioKeyPath,
489
+ interactionRootsRegistered: result.interactionRootsRegistered,
458
490
  };
459
491
  }
460
492
  export function formatSetupExecutionSummary(summary) {
@@ -464,6 +496,7 @@ export function formatSetupExecutionSummary(summary) {
464
496
  summary.skipped.length > 0 ? `skipped: ${summary.skipped.join(', ')}` : 'skipped: none',
465
497
  summary.exaKeyWritten ? `exa key written: ${summary.exaKeyPath}` : 'exa key written: no',
466
498
  summary.composioKeyWritten ? `composio key written: ${summary.composioKeyPath}` : 'composio key written: no',
499
+ `interaction roots registered: ${summary.interactionRootsRegistered}`,
467
500
  ].join('\n') + '\n';
468
501
  }
469
502
  export async function executeSetupSubmission(submission) {
@@ -499,6 +532,10 @@ export async function executeSetupSubmission(submission) {
499
532
  if (composioKeyWritten) {
500
533
  await writeComposioKey(submission.composioKeyPath, submission.composioKey);
501
534
  }
535
+ // Front-door reconciliation: register known nodes' interaction roots for
536
+ // `human _deliver` callback. Best-effort. The inbox-toggle key is a crouter
537
+ // keybinding installed by `installTmuxBindings()`, not reconciled here.
538
+ const { interactionRootsRegistered } = reconcileInboxFrontDoor();
502
539
  return summarizeSetupExecution({
503
540
  installedPackages,
504
541
  skippedPackages,
@@ -508,5 +545,6 @@ export async function executeSetupSubmission(submission) {
508
545
  exaKeyPath: submission.exaKeyPath,
509
546
  composioKeyWritten,
510
547
  composioKeyPath: submission.composioKeyPath,
548
+ interactionRootsRegistered,
511
549
  });
512
550
  }