@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.
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -3
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +16 -44
- package/dist/clients/attach/attach-cmd.d.ts +1 -2
- package/dist/clients/attach/attach-cmd.js +960 -961
- package/dist/clients/attach/attach-def.d.ts +2 -0
- package/dist/clients/attach/attach-def.js +93 -0
- package/dist/clients/attach/canvas-panels.d.ts +10 -3
- package/dist/clients/attach/canvas-panels.js +42 -16
- package/dist/clients/attach/chat-view.d.ts +20 -1
- package/dist/clients/attach/chat-view.js +85 -2
- package/dist/commands/__tests__/human.test.js +7 -22
- package/dist/commands/attention.js +31 -30
- package/dist/commands/human/prompts.js +51 -38
- package/dist/commands/human/queue.d.ts +1 -28
- package/dist/commands/human/queue.js +156 -520
- package/dist/commands/human/shared.d.ts +28 -68
- package/dist/commands/human/shared.js +37 -120
- package/dist/commands/human.js +13 -17
- package/dist/commands/node.js +4 -2
- package/dist/commands/surface.js +1 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +98 -0
- package/dist/commands/sys/setup-core.d.ts +22 -0
- package/dist/commands/sys/setup-core.js +62 -0
- package/dist/commands/sys/setup.js +8 -1
- package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
- package/dist/core/__tests__/human-deliver.test.js +332 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
- package/dist/core/__tests__/pid-identity-match.test.js +83 -0
- package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/attention.test.js +126 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
- package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
- package/dist/core/canvas/attention.d.ts +16 -14
- package/dist/core/canvas/attention.js +34 -29
- package/dist/core/canvas/boot.d.ts +1 -0
- package/dist/core/canvas/boot.js +1 -1
- package/dist/core/canvas/browse/app.js +3 -3
- package/dist/core/canvas/pid.d.ts +43 -17
- package/dist/core/canvas/pid.js +104 -35
- package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
- package/dist/core/canvas/remote-canvas-source.js +4 -2
- package/dist/core/canvas/render.d.ts +5 -5
- package/dist/core/canvas/render.js +14 -14
- package/dist/core/canvas/source.d.ts +2 -2
- package/dist/core/canvas/source.js +3 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +3 -2
- package/dist/core/preview-registry.js +22 -29
- package/dist/core/profiles/select.js +4 -4
- package/dist/core/runtime/front-door.js +19 -6
- package/dist/core/runtime/package-health.d.ts +13 -0
- package/dist/core/runtime/package-health.js +18 -0
- package/dist/core/runtime/pi-vendored.js +15 -1
- package/dist/core/runtime/placement.d.ts +1 -10
- package/dist/core/runtime/placement.js +43 -26
- package/dist/core/runtime/resume.js +72 -4
- package/dist/core/runtime/session-list-cache.d.ts +22 -2
- package/dist/core/runtime/session-list-cache.js +92 -24
- package/dist/core/runtime/spawn.js +9 -0
- package/dist/core/spawn.d.ts +0 -59
- package/dist/core/spawn.js +5 -163
- package/dist/daemon/crtrd.js +7 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +2 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
- package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
- package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
- package/dist/core/__tests__/human-surface-target.test.js +0 -98
- package/dist/web-client/assets/index--md2ylfi.js +0 -78
- /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
- /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
|
@@ -1,79 +1,39 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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
|
|
62
|
-
spawned: boolean;
|
|
63
|
-
follow_up: string;
|
|
64
|
-
};
|
|
32
|
+
export declare function crouterDeliverHandler(): CompletionHandler;
|
|
65
33
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
*
|
|
96
|
-
*
|
|
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
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
131
|
-
|
|
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
|
}
|
package/dist/commands/human.js
CHANGED
|
@@ -1,31 +1,28 @@
|
|
|
1
|
-
// `crtr human` subtree —
|
|
1
|
+
// `crtr human` subtree — humanloop producer + completion bridge.
|
|
2
2
|
//
|
|
3
|
-
// Kickoff leaves (ask/review) create a kind:'human' node under
|
|
4
|
-
// asking node,
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
36
|
+
humanDeliver,
|
|
41
37
|
],
|
|
42
38
|
});
|
|
43
39
|
}
|
package/dist/commands/node.js
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/commands/surface.js
CHANGED
|
@@ -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-
|
|
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\.
|
|
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>;
|