@crouton-kit/crouter 0.3.81 → 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 (84) 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 +960 -961
  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/front-door.js +19 -6
  56. package/dist/core/runtime/package-health.d.ts +13 -0
  57. package/dist/core/runtime/package-health.js +18 -0
  58. package/dist/core/runtime/pi-vendored.js +15 -1
  59. package/dist/core/runtime/placement.d.ts +1 -10
  60. package/dist/core/runtime/placement.js +43 -26
  61. package/dist/core/runtime/resume.js +72 -4
  62. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  63. package/dist/core/runtime/session-list-cache.js +92 -24
  64. package/dist/core/runtime/spawn.js +9 -0
  65. package/dist/core/spawn.d.ts +0 -59
  66. package/dist/core/spawn.js +5 -163
  67. package/dist/daemon/crtrd.js +7 -6
  68. package/dist/index.d.ts +2 -2
  69. package/dist/index.js +1 -1
  70. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  71. package/dist/web-client/index.html +1 -1
  72. package/dist/web-client/sw.js +1 -1
  73. package/package.json +2 -2
  74. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  75. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  76. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  77. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  78. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  79. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  80. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  81. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  82. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  83. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  84. /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,28 @@
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
+ // (⌥I), humanloop invokes the registered `_deliver` handler, which pushFinals
8
+ // the answer as the bridge node's final report, fanning it into the asking
9
+ // node's inbox. notify enqueues a durable deck with no node; show is passive
10
+ // display. Humanloop owns the queue UI, claims, and atomic finalization.
14
11
  import { defineBranch } from '../core/command.js';
15
12
  import { humanAsk, humanReview, humanNotify, humanShow } from './human/prompts.js';
16
- import { humanInbox, humanList, humanDeck, humanResolve, humanCancel, humanRun } from './human/queue.js';
13
+ import { humanList, humanDeck, humanResolve, humanCancel, humanDeliver } from './human/queue.js';
17
14
  export function registerHuman() {
18
15
  return defineBranch({
19
16
  name: 'human',
20
17
  rootEntry: {
21
18
  concept: 'human-in-the-loop decisions, document review, and live display',
22
- desc: 'ask, review, notify, show, cancel, inbox, list',
19
+ desc: 'ask, review, notify, show, cancel, list',
23
20
  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
21
  },
25
22
  help: {
26
23
  name: 'human',
27
24
  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.",
25
+ 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 with ⌥I (or `hl inbox open`) 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
26
  },
30
27
  children: [
31
28
  humanAsk,
@@ -33,11 +30,10 @@ export function registerHuman() {
33
30
  humanNotify,
34
31
  humanShow,
35
32
  humanCancel,
36
- humanInbox,
37
33
  humanList,
38
34
  humanDeck,
39
35
  humanResolve,
40
- humanRun,
36
+ humanDeliver,
41
37
  ],
42
38
  });
43
39
  }
@@ -617,6 +617,7 @@ const nodeFocus = defineLeaf({
617
617
  params: [
618
618
  { kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
619
619
  { 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).' },
620
+ { 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
621
  { 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
622
  ],
622
623
  output: [
@@ -626,7 +627,7 @@ const nodeFocus = defineLeaf({
626
627
  { 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
628
  ],
628
629
  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.'],
630
+ 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
631
  },
631
632
  run: async (input) => {
632
633
  const id = input['node'];
@@ -641,7 +642,7 @@ const nodeFocus = defineLeaf({
641
642
  throw new InputError({
642
643
  error: 'not_focusable',
643
644
  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}\`.`,
645
+ next: `The pending question is queued for the human (⌥I opens the inbox popup); see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
645
646
  });
646
647
  }
647
648
  // Placement owns the whole act: ensure the target's broker engine is alive
@@ -651,6 +652,7 @@ const nodeFocus = defineLeaf({
651
652
  const res = await placementFocus(id, {
652
653
  pane: input['pane'],
653
654
  newPane: input['newPane'] === true,
655
+ inPlace: input['inPlace'] === true,
654
656
  revive: (nid) => reviveNode(nid, { resume: true }),
655
657
  });
656
658
  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,98 @@
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 installs the
4
+ // collision-safe ⌥I inbox popup binding and registers known nodes' interaction
5
+ // roots to owner 'crouter' so a resolved humanloop ticket can call back into the
6
+ // asking node. Both are best-effort. The humanloop registry is isolated with a
7
+ // temp XDG_STATE_HOME (see human-deliver.test.ts); the canvas is isolated with a
8
+ // 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, formatInboxBindingLine } 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 { binding, 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
+ // The binding is best-effort: with or without a tmux server the state is one
55
+ // of the known values and never throws.
56
+ assert.ok(['installed', 'collision', 'unbound', 'unavailable'].includes(binding.state), `unexpected binding state: ${binding.state}`);
57
+ });
58
+ test('invoking `crtr sys setup` (non-TTY leaf run) reconciles the front door', async () => {
59
+ // Exercise the real control flow — sysSetupLeaf.run() → runSetup() →
60
+ // reconcileInboxFrontDoor — not the helper directly. With no TTY the leaf
61
+ // prints static instructions and returns, but reconciliation must still have
62
+ // happened on the way through.
63
+ spawnNode({ kind: 'general', parent: null, cwd: home, name: 'a' });
64
+ const originalIsTTY = process.stdout.isTTY;
65
+ const originalWrite = process.stdout.write.bind(process.stdout);
66
+ // Force the non-TTY (instructions-print) branch and swallow its output.
67
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
68
+ process.stdout.write = () => true;
69
+ try {
70
+ await sysSetupLeaf.run({});
71
+ }
72
+ finally {
73
+ process.stdout.write = originalWrite;
74
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
75
+ }
76
+ const homeReal = realpathSync(home);
77
+ const crouterRoots = listInboxRoots().filter((r) => r.owner === 'crouter');
78
+ // registerCrouterRoot maps the node cwd to its workspace interactions dir
79
+ // under that cwd, so the registered root is rooted at the node's home.
80
+ assert.ok(crouterRoots.some((r) => r.root.startsWith(homeReal)), 'the leaf invocation registered the node cwd as an owner-crouter interaction root');
81
+ });
82
+ test('reconcile is idempotent for owner crouter — re-running does not error and keeps the roots', () => {
83
+ spawnNode({ kind: 'general', parent: null, cwd: home, name: 'a' });
84
+ reconcileInboxFrontDoor();
85
+ const { interactionRootsRegistered } = reconcileInboxFrontDoor();
86
+ assert.equal(interactionRootsRegistered, 1);
87
+ assert.ok(listInboxRoots().some((r) => r.owner === 'crouter'));
88
+ });
89
+ test('the summary binding line surfaces the collision remedy', () => {
90
+ const collision = { state: 'collision', key: 'M-i', isDefault: true };
91
+ const line = formatInboxBindingLine(collision);
92
+ assert.match(line, /NOT installed/);
93
+ assert.match(line, /hl inbox bind --key/);
94
+ const installed = { state: 'installed', key: 'M-i', isDefault: true };
95
+ assert.match(formatInboxBindingLine(installed), /\u2325I \(M-i\).*installed/);
96
+ const unavailable = { state: 'unavailable', key: '', isDefault: false };
97
+ assert.match(formatInboxBindingLine(unavailable), /unavailable/);
98
+ });
@@ -44,6 +44,13 @@ export interface SetupSubmission {
44
44
  composioKey: string;
45
45
  composioKeyPath: string;
46
46
  }
47
+ /** Outcome of reconciling humanloop's collision-safe ⌥I inbox popup binding
48
+ * during setup. `unavailable` means the installer threw (no tmux server, etc). */
49
+ export interface SetupInboxBindingResult {
50
+ state: 'installed' | 'collision' | 'unbound' | 'unavailable';
51
+ key: string;
52
+ isDefault: boolean;
53
+ }
47
54
  export interface SetupExecutionSummary {
48
55
  installed: string[];
49
56
  skipped: string[];
@@ -51,6 +58,8 @@ export interface SetupExecutionSummary {
51
58
  exaKeyPath: string;
52
59
  composioKeyWritten: boolean;
53
60
  composioKeyPath: string;
61
+ inboxBinding: SetupInboxBindingResult;
62
+ interactionRootsRegistered: number;
54
63
  }
55
64
  export interface SetupInitialState {
56
65
  manifest: SetupPackageManifestEntry[];
@@ -122,6 +131,16 @@ export declare function buildSetupStaticInstructions(opts: {
122
131
  export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath' | 'composioKeyPath'>): string;
123
132
  export declare function writeExaKey(exaKeyPath: string, exaKey: string): Promise<void>;
124
133
  export declare function writeComposioKey(composioKeyPath: string, composioKey: string): Promise<void>;
134
+ /** Reconcile humanloop's collision-safe ⌥I inbox popup binding on the current
135
+ * tmux server, and register each known node's interaction root to owner
136
+ * `crouter` so a resolved ticket can call back into `human _deliver`. Both are
137
+ * best-effort: setup must never fail because tmux is absent or the canvas is
138
+ * unreadable. Crouter calls humanloop's installer — it never authors its own
139
+ * tmux command. */
140
+ export declare function reconcileInboxFrontDoor(): {
141
+ binding: SetupInboxBindingResult;
142
+ interactionRootsRegistered: number;
143
+ };
125
144
  export declare function summarizeSetupExecution(result: {
126
145
  installedPackages: string[];
127
146
  skippedPackages: string[];
@@ -131,6 +150,9 @@ export declare function summarizeSetupExecution(result: {
131
150
  exaKeyPath: string;
132
151
  composioKeyWritten: boolean;
133
152
  composioKeyPath: string;
153
+ inboxBinding: SetupInboxBindingResult;
154
+ interactionRootsRegistered: number;
134
155
  }): SetupExecutionSummary;
156
+ export declare function formatInboxBindingLine(binding: SetupInboxBindingResult): string;
135
157
  export declare function formatSetupExecutionSummary(summary: SetupExecutionSummary): string;
136
158
  export declare function executeSetupSubmission(submission: SetupSubmission): Promise<SetupExecutionSummary>;