@north-light/crouter 0.3.274 → 0.3.276

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 (35) hide show
  1. package/dist/builtin-memory/02-turn-lifecycle/01-terminal.md +1 -1
  2. package/dist/clients/attach/viewer.js +459 -459
  3. package/dist/commands/cron.js +3 -3
  4. package/dist/commands/node/create.js +3 -1
  5. package/dist/commands/node-worktree.js +7 -7
  6. package/dist/commands/pkg/plugin-manage.js +19 -8
  7. package/dist/commands/push.js +1 -1
  8. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  9. package/dist/core/__tests__/helpers/harness.js +12 -10
  10. package/dist/core/__tests__/integration/worktree-land.test.js +7 -2
  11. package/dist/core/__tests__/seam/broker-provider-retry.test.js +3 -16
  12. package/dist/core/canvas/browse/render.js +82 -67
  13. package/dist/core/canvas/status-glyph.d.ts +4 -3
  14. package/dist/core/canvas/status-glyph.js +17 -9
  15. package/dist/core/canvas/types.d.ts +18 -0
  16. package/dist/core/command-plugins/bundle.d.ts +7 -0
  17. package/dist/core/command-plugins/bundle.js +18 -4
  18. package/dist/core/git.d.ts +5 -1
  19. package/dist/core/git.js +18 -4
  20. package/dist/core/runtime/spawn.d.ts +2 -0
  21. package/dist/core/runtime/spawn.js +1 -1
  22. package/dist/core/runtime/surface-bg.d.ts +6 -4
  23. package/dist/core/runtime/surface-bg.js +39 -22
  24. package/dist/core/tui/draw.d.ts +2 -0
  25. package/dist/core/tui/draw.js +15 -0
  26. package/dist/core/worktree-sweep.js +28 -5
  27. package/dist/core/worktree.d.ts +1 -12
  28. package/dist/core/worktree.js +222 -10
  29. package/dist/daemon/api/handlers/crons.js +5 -4
  30. package/dist/daemon/api/handlers/messages.js +5 -0
  31. package/dist/daemon/api/handlers/nodes.js +2 -0
  32. package/dist/daemon/api/handlers/reports.js +11 -7
  33. package/dist/types.d.ts +1 -1
  34. package/package.json +1 -1
  35. package/runtime.lock.json +2 -2
@@ -62,6 +62,8 @@ export interface SpawnChildOpts {
62
62
  worktree?: boolean;
63
63
  /** Local branch the managed worktree is cut from and later landed onto. */
64
64
  worktreeBase?: string;
65
+ /** Invocation-selected repository from which to cut a managed worktree. */
66
+ worktreeCwd?: string;
65
67
  /** Preallocated node id, used when a managed worktree must be named before birth. */
66
68
  nodeId?: string;
67
69
  /** Hidden ambient "current situation" text — NOT the visible prompt/body.
@@ -251,7 +251,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
251
251
  let spawnCwd = opts.cwd;
252
252
  try {
253
253
  if (wantsWorktree) {
254
- managedWorktree = createManagedWorktree(opts.cwd, nodeId, opts.worktreeBase);
254
+ managedWorktree = createManagedWorktree(opts.worktreeCwd ?? opts.cwd, nodeId, opts.worktreeBase);
255
255
  spawnCwd = managedWorktree.path;
256
256
  }
257
257
  // Spine: a managed child reports up to its spawner (has a manager); an
@@ -1,10 +1,12 @@
1
- /** The SGR escape that turns ON the distinct-surface background (theme
2
- * `selectedBg`), e.g. `\x1b[48;2;58;58;74m`. Pair with `\x1b[49m` to reset just
1
+ /** The SGR escape that turns ON the distinct-surface background (the theme's
2
+ * card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
3
3
  * the background. Throws if no process has initialized the theme — callers on a
4
4
  * render path (the attach overlay) always have, by construction. */
5
5
  export declare function surfaceBgAnsi(): string;
6
- /** tmux flags that frame a `display-menu` / `display-popup` on the distinct
7
- * surface background — a rounded border + the surface bg on the body and border.
6
+ /** tmux flags that frame a `display-menu` / `display-popup` as a distinct
7
+ * surface — a rounded border in the theme accent around a body painted with the
8
+ * theme's card plane. The accent border is what carries the "you are somewhere
9
+ * else" signal, because a card sits close to the page by design.
8
10
  * Empty when the theme is unavailable in this process (chrome stays default; the
9
11
  * attach viewer re-installs the menu with these once it has themed). One knob:
10
12
  * every crtr tmux float shares this single definition. */
@@ -3,10 +3,24 @@
3
3
  // crtr's overlays and popups must read as a SEPARATE surface from the normal one
4
4
  // (CTO ruling, insights/terminal-ui/inline-ui-placement #11): a popup whose background matches
5
5
  // the surface has invisible edges, so you can't tell where the float begins. We
6
- // paint them with the active pi theme's `selectedBg` role — theme-derived, so it
7
- // adapts to light/dark and is ONE centralized knob, not a scattered hardcode.
6
+ // paint them from the active pi theme — theme-derived, so it adapts to light/dark
7
+ // and is ONE centralized knob, not a scattered hardcode.
8
8
  //
9
- // pi 0.79 does not re-export `selectedBg` through its public API (the `.` entry
9
+ // WHICH ROLE: `userMessageBg`, the theme's CARD plane — not `selectedBg`. Every
10
+ // theme places its card one step off the page background (gruvbox-material
11
+ // #32302f vs #282828; pi's own dark #343541, light #e8e8e8) while `selectedBg` is
12
+ // a SELECTION BAND, deliberately pushed far off the page so a highlighted row
13
+ // lifts. Painting a 90%×85% takeover with the selection band made the whole
14
+ // screen that band: every dim/muted role lost the contrast it was designed
15
+ // against (gruvbox dim text fell to 2.1:1), and the row highlight drawn ON TOP
16
+ // had nowhere left to go. The card plane keeps content legibility and leaves the
17
+ // selection band free to do its actual job inside the popup.
18
+ //
19
+ // A card sits close to the page by design, so the EDGE signal rule 11 demands
20
+ // comes from the border, not the fill: we paint the rounded border in the theme
21
+ // accent so the float's boundary is unmistakable.
22
+ //
23
+ // pi 0.79 does not re-export these roles through its public API (the `.` entry
10
24
  // omits `getResolvedThemeColors` and the live `theme` singleton). It DOES publish
11
25
  // the live `Theme` instance on a process-global symbol once `initTheme()` has run
12
26
  // — the same hook pi's own SDK reads (see broker.ts `ctx.ui.theme`). We read it
@@ -26,43 +40,46 @@ function liveTheme() {
26
40
  const t = globalThis[THEME_SYMBOL];
27
41
  return (t ?? undefined);
28
42
  }
29
- /** The SGR escape that turns ON the distinct-surface background (theme
30
- * `selectedBg`), e.g. `\x1b[48;2;58;58;74m`. Pair with `\x1b[49m` to reset just
43
+ /** The SGR escape that turns ON the distinct-surface background (the theme's
44
+ * card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
31
45
  * the background. Throws if no process has initialized the theme — callers on a
32
46
  * render path (the attach overlay) always have, by construction. */
33
47
  export function surfaceBgAnsi() {
34
48
  const t = liveTheme();
35
49
  if (t === undefined)
36
50
  throw new Error('surfaceBgAnsi: pi theme not initialized in this process');
37
- return t.getBgAnsi('selectedBg');
51
+ return t.getBgAnsi('userMessageBg');
38
52
  }
39
- /** The distinct-surface background as a tmux colour token (`#rrggbb` on a
40
- * truecolor theme, `colourN` on a 256-colour theme), or undefined when the theme
41
- * is not yet loaded in this process OR defines no selection background. tmux
42
- * cannot read pi's theme, so we translate the SGR pi resolved for us. */
43
- function surfaceBgTmux() {
44
- const t = liveTheme();
45
- if (t === undefined)
46
- return undefined;
47
- const ansi = t.getBgAnsi('selectedBg');
48
- const tc = /48;2;(\d+);(\d+);(\d+)/.exec(ansi);
53
+ /** An SGR colour parameter (`48;2;r;g;b` or `38;5;n`) as a tmux colour token —
54
+ * `#rrggbb` on a truecolor theme, `colourN` on a 256-colour one. tmux cannot
55
+ * read pi's theme, so we translate the SGR pi already resolved for us.
56
+ * undefined when the role is empty (the SGR is a bare default-colour reset). */
57
+ function tmuxColor(ansi) {
58
+ const tc = /[34]8;2;(\d+);(\d+);(\d+)/.exec(ansi);
49
59
  if (tc !== null) {
50
60
  const hex = (n) => Number(n).toString(16).padStart(2, '0');
51
61
  return `#${hex(tc[1])}${hex(tc[2])}${hex(tc[3])}`;
52
62
  }
53
- const c256 = /48;5;(\d+)/.exec(ansi);
63
+ const c256 = /[34]8;5;(\d+)/.exec(ansi);
54
64
  if (c256 !== null)
55
65
  return `colour${c256[1]}`;
56
- return undefined; // empty/default selectedBg → nothing distinct to paint
66
+ return undefined;
57
67
  }
58
- /** tmux flags that frame a `display-menu` / `display-popup` on the distinct
59
- * surface background — a rounded border + the surface bg on the body and border.
68
+ /** tmux flags that frame a `display-menu` / `display-popup` as a distinct
69
+ * surface — a rounded border in the theme accent around a body painted with the
70
+ * theme's card plane. The accent border is what carries the "you are somewhere
71
+ * else" signal, because a card sits close to the page by design.
60
72
  * Empty when the theme is unavailable in this process (chrome stays default; the
61
73
  * attach viewer re-installs the menu with these once it has themed). One knob:
62
74
  * every crtr tmux float shares this single definition. */
63
75
  export function surfaceTmuxStyleArgs() {
64
- const bg = surfaceBgTmux();
76
+ const t = liveTheme();
77
+ if (t === undefined)
78
+ return [];
79
+ const bg = tmuxColor(t.getBgAnsi('userMessageBg'));
65
80
  if (bg === undefined)
66
81
  return [];
67
- return ['-b', 'rounded', '-s', `bg=${bg}`, '-S', `bg=${bg}`];
82
+ const accent = tmuxColor(t.getFgAnsi('accent'));
83
+ const border = accent === undefined ? `bg=${bg}` : `fg=${accent},bg=${bg}`;
84
+ return ['-b', 'rounded', '-s', `bg=${bg}`, '-S', border];
68
85
  }
@@ -1,3 +1,5 @@
1
+ export declare const META_FG = "38;5;246";
2
+ export declare const RECESSIVE_FG = "38;5;243";
1
3
  export interface Size {
2
4
  cols: number;
3
5
  rows: number;
@@ -22,6 +22,21 @@ const REVERSE = `${ESC}7m`;
22
22
  const DIM = `${ESC}2m`;
23
23
  const BOLD = `${ESC}1m`;
24
24
  const ITALIC = `${ESC}3m`;
25
+ // ── The recessive text ramp ────────────────────────────────────────────
26
+ //
27
+ // Two explicit greys for text that must sit BEHIND the primary content without
28
+ // disappearing into it. Use these instead of the two things that look equivalent
29
+ // and are not:
30
+ //
31
+ // - basic-16 bright-black (`90`) resolves against the TERMINAL's palette, which
32
+ // a dark theme routinely sets within a hair of its own background;
33
+ // - SGR DIM (`2`) is a multiplier, so stacking it on an already-dark grey lands
34
+ // at mud — and stacking it on THESE would undo the contrast they encode.
35
+ //
36
+ // Never combine either with these. Ratios below are against the two planes a
37
+ // raw-ANSI view can render on: a bare dark terminal and a themed popup card.
38
+ export const META_FG = '38;5;246'; // 4.3–4.9:1 — metadata you actively scan
39
+ export const RECESSIVE_FG = '38;5;243'; // 2.9–3.3:1 — deliberately behind, still read
25
40
  /** An SGR fg/bg parameter is digits and semicolons only (e.g. '32', '1;36', '236').
26
41
  * Guards styleSpan against a non-numeric value (a color name) producing a broken
27
42
  * escape sequence. */
@@ -336,7 +336,7 @@ export async function reconcileManagedWorktree(nodeId, guard, now = Date.now())
336
336
  return { status: 'skipped', reason: 'already_reconciled' };
337
337
  const cwd = existsSync(wt.path) ? wt.path : wt.repo_root;
338
338
  if (!existsSync(cwd)) {
339
- return finish(nodeId, wt, { status: 'refused', reason: 'repo_unavailable', detail: `neither the managed checkout (${wt.path}) nor its repository root (${wt.repo_root}) is present` }, { base: null, branch: null, checkout: null }, now, guard);
339
+ return persistComplete(nodeId, wt, guard);
340
340
  }
341
341
  const commonDir = await commonGitDirAsync(cwd);
342
342
  if (commonDir === null) {
@@ -423,9 +423,15 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
423
423
  const list = await gitAsync(['worktree', 'list', '--porcelain'], wt.path);
424
424
  if (list.status !== 0)
425
425
  return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
426
- const registeredPath = registeredCheckoutPath(list.stdout, wt.path, wt.branch);
426
+ // An explicit `close` already proved a branch-moved checkout's identity and
427
+ // authorized its later disposal for THIS exact branch — never adopted here.
428
+ // Only the recorded authorization changes which branch this pass expects;
429
+ // everything else below is re-verified exactly as for an ordinary record.
430
+ const disposal = wt.disposal;
431
+ const expectedBranch = disposal?.observed_branch ?? wt.branch;
432
+ const registeredPath = registeredCheckoutPath(list.stdout, wt.path, expectedBranch);
427
433
  if (registeredPath === null) {
428
- return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${wt.branch}; crtr will not act on a path it cannot prove it owns` };
434
+ return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${expectedBranch}; crtr will not act on a path it cannot prove it owns` };
429
435
  }
430
436
  const main = mainWorktreePath(list.stdout);
431
437
  if (main === null || samePath(main, registeredPath) || !existsSync(main)) {
@@ -439,8 +445,8 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
439
445
  const head = await gitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
440
446
  if (head.status !== 0)
441
447
  return { status: 'failed', reason: 'branch_check_failed', detail: detailOf(head) };
442
- if (head.stdout.trim() !== wt.branch) {
443
- return { status: 'refused', reason: 'wrong_branch', detail: `expected ${wt.branch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
448
+ if (head.stdout.trim() !== expectedBranch) {
449
+ return { status: 'refused', reason: 'wrong_branch', detail: `expected ${expectedBranch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
444
450
  }
445
451
  // Uncommitted work blocks removal unconditionally. No containment proof
446
452
  // overrides this: those reason about committed content, and nothing here has
@@ -454,6 +460,23 @@ async function reconcilePresentCheckout(nodeId, wt, guard) {
454
460
  let branchSha = await revParseRef(wt.path, 'HEAD');
455
461
  if (branchSha === null)
456
462
  return { status: 'failed', reason: 'head_unreadable', detail: `could not read HEAD in ${wt.path}` };
463
+ if (disposal !== undefined) {
464
+ // Re-verify the exact identity `close` authorized before mutating anything.
465
+ // A mismatch invalidates the authorization instead of adopting whatever is
466
+ // observed now — only the explicit command that wrote it may refresh it.
467
+ const commonDir = await commonGitDirAsync(wt.path);
468
+ if (commonDir !== disposal.common_dir || branchSha !== disposal.observed_sha) {
469
+ return { status: 'refused', reason: 'disposal_authorization_stale', detail: `${wt.path} changed since \`close\` authorized its disposal; rerun \`crtr node worktree close\` from that node to re-authorize, or ask the user with \`crtr human send\`` };
470
+ }
471
+ if (!guard.stillEligible())
472
+ return NODE_REVIVED;
473
+ const removed = await gitAsync(['worktree', 'remove', registeredPath], main);
474
+ if (removed.status !== 0)
475
+ return { status: 'refused', reason: 'worktree_remove_failed', detail: detailOf(removed) };
476
+ // `disposal.observed_branch` is not a `crtr/*` branch crouter created — it
477
+ // is preserved, never deleted, unlike the ordinary path below.
478
+ return persistComplete(nodeId, wt, guard);
479
+ }
457
480
  let proof = await proveContained(wt.path, wt, branchSha);
458
481
  let stash;
459
482
  if (proof === null && wt.land_intent !== undefined) {
@@ -115,17 +115,6 @@ export interface AutoDroppedWorktree {
115
115
  * explicit close: removal and branch deletion are deferred until that owner
116
116
  * has exited. */
117
117
  export declare function autoDropCleanManagedWorktreeLocked(nodeId: string, wt: ManagedWorktree, proof: CleanEmptyProof): AutoDroppedWorktree | null;
118
- /** `push final`'s escape hatch: a managed worktree that has
119
- * nothing to land is auto-dropped instead of blocking on an explicit
120
- * `node worktree close` — one the caller may have been told NOT to run
121
- * because a manager already landed the same change another way, or
122
- * that never had anything to close in the first place (a read-only
123
- * review node). Returns null and changes nothing when the worktree is not
124
- * PROVABLY empty-and-clean by `proveCleanEmptyManagedWorktree`, OR when a
125
- * concurrent mutation invalidates that proof before the record is closed
126
- * (see `autoDropCleanManagedWorktreeLocked`); the caller's existing
127
- * `open_managed_worktree` guard then still applies unchanged — real
128
- * commits, real uncommitted changes, and a lost race all require the
129
- * explicit path. */
118
+ export declare function branchIsPushedAtCurrentTip(wt: ManagedWorktree): Promise<boolean>;
130
119
  export declare function autoDropCleanManagedWorktree(nodeId: string): AutoDroppedWorktree | null;
131
120
  export {};
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { dirname, join, resolve } from 'node:path';
4
- import { gitSync } from './git.js';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { gitAsync, gitSync } from './git.js';
5
5
  import { crtrHome } from './canvas/paths.js';
6
6
  import { isPidAlive } from './canvas/pid.js';
7
7
  import { getNode, updateNode } from './canvas/index.js';
@@ -44,7 +44,27 @@ export function openManagedWorktreeForNode(nodeId) {
44
44
  return wt?.state === 'open' ? wt : null;
45
45
  }
46
46
  function repoRootFor(cwd) {
47
- return runGit(cwd, ['rev-parse', '--show-toplevel'], 'not_git_repo', 'Pass --cwd pointing inside a git repository.');
47
+ const result = gitSync(['rev-parse', '--show-toplevel'], cwd);
48
+ if (result.status !== 0) {
49
+ const detail = (result.stderr.trim() || result.stdout.trim()).trim();
50
+ throw new WorktreeError('not_git_repo', `cwd ${cwd} is not a git repository`, 'Pass --cwd <real repo> pointing inside a git repository.', detail);
51
+ }
52
+ return result.stdout.trim();
53
+ }
54
+ function isManagedWorktreeBase(repoRoot) {
55
+ const path = relative(canonicalPath(managedWorktreesRoot()), canonicalPath(repoRoot));
56
+ return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path);
57
+ }
58
+ function recordedBranchExists(cwd, branch) {
59
+ const ref = `refs/heads/${branch}`;
60
+ const exists = gitSync(['show-ref', '--verify', '--quiet', ref], cwd);
61
+ if (exists.status === 0)
62
+ return true;
63
+ if (exists.status === 1 && exists.executionFailed !== true)
64
+ return false;
65
+ const stderr = exists.stderr.trim() || '<empty>';
66
+ const execution = exists.processError === undefined ? '' : `\nprocess error: ${exists.processError}`;
67
+ throw new WorktreeError('recorded_branch_inspection_failed', `could not inspect recorded branch '${branch}' (git show-ref exited with status ${exists.status})`, 'Inspect the repository and its Git metadata, then retry.', `status: ${exists.status}\nstderr: ${stderr}${execution}`);
48
68
  }
49
69
  function resolveLandableBase(repoRoot, baseRef) {
50
70
  const ref = `refs/heads/${baseRef}`;
@@ -142,6 +162,43 @@ function registeredWorktree(repoRoot, path, branch) {
142
162
  }
143
163
  return false;
144
164
  }
165
+ function proveBranchContained(cwd, baseRef, branchSha) {
166
+ const candidates = [];
167
+ const localBaseRef = `refs/heads/${baseRef}`;
168
+ const localBase = gitSync(['rev-parse', '--verify', '--quiet', localBaseRef], cwd);
169
+ if (localBase.status === 0)
170
+ candidates.push({ ref: localBaseRef, sha: localBase.stdout.trim() });
171
+ const upstream = gitSync(['rev-parse', '--symbolic-full-name', '--verify', `${baseRef}@{upstream}`], cwd);
172
+ const upstreamRef = upstream.status === 0 && upstream.stdout.trim().startsWith('refs/') ? upstream.stdout.trim() : `refs/remotes/origin/${baseRef}`;
173
+ const upstreamSha = gitSync(['rev-parse', '--verify', '--quiet', upstreamRef], cwd);
174
+ if (upstreamSha.status === 0)
175
+ candidates.push({ ref: upstreamRef, sha: upstreamSha.stdout.trim() });
176
+ if (candidates.length === 0)
177
+ return null;
178
+ for (const c of candidates) {
179
+ if (gitSync(['merge-base', '--is-ancestor', branchSha, c.sha], cwd).status === 0)
180
+ return { containingRef: c.ref, containingSha: c.sha };
181
+ }
182
+ for (const c of candidates) {
183
+ const cherry = gitSync(['cherry', c.sha, branchSha], cwd);
184
+ if (cherry.status !== 0)
185
+ continue;
186
+ const lines = cherry.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
187
+ if (lines.every((l) => l.startsWith('-')))
188
+ return { containingRef: c.ref, containingSha: c.sha };
189
+ }
190
+ for (const c of candidates) {
191
+ const candidateTree = gitSync(['rev-parse', '--verify', `${c.sha}^{tree}`], cwd);
192
+ if (candidateTree.status !== 0)
193
+ continue;
194
+ const merged = gitSync(['merge-tree', '--write-tree', c.sha, branchSha], cwd);
195
+ if (merged.status !== 0)
196
+ continue;
197
+ if (merged.stdout.split('\n')[0]?.trim() === candidateTree.stdout.trim())
198
+ return { containingRef: c.ref, containingSha: c.sha };
199
+ }
200
+ return null;
201
+ }
145
202
  /** Undo a creation attempt, but only resources whose recorded path, branch,
146
203
  * and original base still prove they belong to that attempt. */
147
204
  function rollbackManagedWorktreeLocked(wt, pathWasAbsent) {
@@ -235,6 +292,9 @@ function seedManagedWorktreeExclude(repoRoot) {
235
292
  }
236
293
  export function createManagedWorktree(cwd, nodeId, baseRef = 'main') {
237
294
  const repoRoot = repoRootFor(cwd);
295
+ if (isManagedWorktreeBase(repoRoot)) {
296
+ throw new WorktreeError('managed_worktree_base', `cwd ${repoRoot} is a managed worktree and cannot be used as a managed worktree base`, 'Pass --cwd <real repo> pointing at the repository’s primary checkout.');
297
+ }
238
298
  return withRepositoryWorktreeLock(repoRoot, () => {
239
299
  const baseSha = resolveLandableBase(repoRoot, baseRef);
240
300
  const path = managedWorktreePath(nodeId);
@@ -386,6 +446,67 @@ function deferredCleanupResult(wt) {
386
446
  branch_delete_error: `cleanup pending: local branch ${wt.branch} remains checked out at ${wt.path}; delete it only after that checkout is removed and its landed commit is still contained in ${wt.base_ref}.`,
387
447
  };
388
448
  }
449
+ /** The disposal path for `close` when the recorded `crtr/*` branch is gone:
450
+ * ordinary PR work moved this checkout onto a real branch. This never lands
451
+ * or rebases that branch — it belongs to a workflow crouter does not own —
452
+ * and never deletes it. It only AUTHORIZES the daemon sweep to remove the
453
+ * checkout later, once this node's own broker has exited, and only after
454
+ * proving the branch's content is already delivered. Runs after the caller
455
+ * already proved the checkout clean and not mid-rebase. */
456
+ function closeBranchMovedWorktree(nodeId, wt, actualBranch) {
457
+ if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true) {
458
+ throw new WorktreeError('wrong_branch', `managed worktree's recorded branch ${wt.branch} no longer exists, and its checkout at ${wt.path} is not registered to ${actualBranch} either`, `Inspect ${wt.path} and its git worktree registration, then ask the user with \`crtr human send\` if you are unsure how to proceed.`);
459
+ }
460
+ const actualSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.');
461
+ const proof = proveBranchContained(wt.path, wt.base_ref, actualSha);
462
+ if (proof === null) {
463
+ throw new WorktreeError('branch_moved_unlanded', `managed worktree's recorded branch ${wt.branch} no longer exists; its checkout is now on ${actualBranch} at ${actualSha}, which carries content local base branch '${wt.base_ref}' does not yet contain`, `Land or push ${actualBranch} through its own workflow (merge its PR, or fast-forward '${wt.base_ref}' to include it) before closing, or ask the user with \`crtr human send\` if you are unsure how ${actualBranch} should be delivered.`);
464
+ }
465
+ const commonDir = commonGitDir(wt.repo_root);
466
+ // Recheck identity immediately before writing the authorization: nothing
467
+ // about the checkout may have moved between the proof above and this write.
468
+ const stillRegistered = registeredWorktree(wt.repo_root, wt.path, actualBranch) === true;
469
+ const stillOnBranch = currentBranch(wt.path) === actualBranch;
470
+ const stillAtSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'branch_check_failed', 'Inspect the worktree manually, then retry.') === actualSha;
471
+ if (!stillRegistered || !stillOnBranch || !stillAtSha) {
472
+ throw new WorktreeError('wrong_branch', `managed worktree at ${wt.path} changed while closing`, `Inspect ${wt.path}, then rerun \`crtr node worktree close\`.`);
473
+ }
474
+ const disposal = { observed_branch: actualBranch, observed_sha: actualSha, common_dir: commonDir, authorized: new Date().toISOString() };
475
+ persistClosedWorktreeState(nodeId, { ...wt, disposal }, 'pending');
476
+ const remove = `git -C ${shellQuote(wt.repo_root)} worktree remove ${shellQuote(wt.path)}`;
477
+ return {
478
+ node_id: nodeId,
479
+ branch: actualBranch,
480
+ worktree_path: wt.path,
481
+ // Nothing was rebased or fast-forwarded here: `${actualBranch}`'s content
482
+ // was already proven contained in `${proof.containingRef}` at `${proof.containingSha}`.
483
+ landed_sha: actualSha,
484
+ worktree_removed: false,
485
+ worktree_remove_error: `cleanup pending: the caller checkout at ${wt.path} remains intact. After its node exits, run \`${remove}\` after confirming it is safe to delete.`,
486
+ branch_deleted: false,
487
+ branch_delete_error: `${actualBranch} is not a crtr-managed branch and will never be deleted automatically; it is preserved once the checkout above is removed.`,
488
+ };
489
+ }
490
+ /** Word the `no_managed_worktree` refusal from the record's ACTUAL state
491
+ * rather than a blanket "no worktree". A `closed`/`cleanup: pending` record
492
+ * still has its checkout on disk, scheduled for the daemon sweep to finish —
493
+ * that is not lost ownership, and three separate reports (#849, #862, #872)
494
+ * were misdiagnosed because the old wording read as if it were. */
495
+ function noManagedWorktreeMessage(nodeId, verb) {
496
+ const wt = getNode(nodeId)?.managed_worktree;
497
+ if (wt == null)
498
+ return { message: `node ${nodeId} has no managed worktree`, next: `Nothing to ${verb} for this node.` };
499
+ if (wt.state === 'abandoned') {
500
+ const branch = wt.abandoned?.branch ?? wt.branch;
501
+ return { message: `node ${nodeId}'s managed worktree was already abandoned`, next: `Nothing to ${verb} for this node; its work was retained on branch ${branch}.` };
502
+ }
503
+ if (wt.state === 'closed') {
504
+ return wt.cleanup === 'complete'
505
+ ? { message: `node ${nodeId}'s managed worktree is already closed and its cleanup is complete`, next: `Nothing to ${verb} for this node.` }
506
+ : { message: `node ${nodeId}'s managed worktree is already closed; this is not lost ownership — its checkout at ${wt.path} is scheduled for automatic cleanup once that is safe`, next: `Nothing to ${verb} for this node. The daemon sweep finishes removing ${wt.path} on its own; inspect it directly only if it still needs manual attention.` };
507
+ }
508
+ return { message: `node ${nodeId} has no open managed worktree`, next: `Nothing to ${verb} for this node.` };
509
+ }
389
510
  function closeManagedWorktreeLocked(nodeId) {
390
511
  const node = getNode(nodeId);
391
512
  if (node === null) {
@@ -393,7 +514,8 @@ function closeManagedWorktreeLocked(nodeId) {
393
514
  }
394
515
  const wt = openManagedWorktreeForNode(nodeId);
395
516
  if (wt === null) {
396
- throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to close for this node.');
517
+ const { message, next } = noManagedWorktreeMessage(nodeId, 'close');
518
+ throw new WorktreeError('no_managed_worktree', message, next);
397
519
  }
398
520
  if (!existsSync(wt.path)) {
399
521
  const proof = proveAbsentPathManagedWorktree(wt);
@@ -427,7 +549,16 @@ function closeManagedWorktreeLocked(nodeId) {
427
549
  const branch = currentBranch(wt.path);
428
550
  if (branch !== wt.branch) {
429
551
  const found = branch === 'HEAD' ? 'detached HEAD' : branch;
430
- throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the user with \`crtr human send\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`);
552
+ const recordedRefExists = recordedBranchExists(wt.path, wt.branch);
553
+ if (recordedRefExists || branch === 'HEAD') {
554
+ throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, recordedRefExists
555
+ ? `Check out ${wt.branch} in ${wt.path} before closing, or ask the user with \`crtr human send\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`
556
+ : `The recorded branch ${wt.branch} no longer exists, and ${wt.path} is in detached HEAD, so there is no branch to preserve. Check out or create a branch there before closing, or ask the user with \`crtr human send\` if you are unsure how to proceed.`);
557
+ }
558
+ // The recorded crtr/* branch is gone: ordinary PR work moved this checkout
559
+ // onto a real branch. The checkout is already proven clean and not
560
+ // mid-rebase (both checked above), so disposal may proceed.
561
+ return closeBranchMovedWorktree(nodeId, wt, branch);
431
562
  }
432
563
  let baseSha;
433
564
  try {
@@ -527,16 +658,45 @@ export function closeManagedWorktree(nodeId) {
527
658
  const ABANDON_AUTHOR_NAME = 'crtr-abandon';
528
659
  const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
529
660
  const ABANDON_SUBJECT = 'crtr-abandon';
530
- function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by) {
661
+ function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by, branch = wt.branch) {
531
662
  updateNode(nodeId, {
532
663
  cwd: wt.repo_root,
533
664
  managed_worktree: {
534
665
  ...wt,
535
666
  state: 'abandoned',
536
- abandoned: { branch: wt.branch, tip_sha: tipSha, at, by },
667
+ abandoned: { branch, tip_sha: tipSha, at, by },
537
668
  },
538
669
  });
539
670
  }
671
+ /** The disposal path for `abandon` when the recorded `crtr/*` branch is gone:
672
+ * ordinary PR work moved this checkout onto a real branch. Abandon never
673
+ * lands that branch and — unlike the ordinary path above — never invents a
674
+ * commit on it: a synthetic `crtr-abandon` marker on someone's real PR
675
+ * branch is not a trade this code may make on their behalf. So this
676
+ * requires the checkout already clean and not mid-operation, then removes
677
+ * only the checkout and preserves the actual branch and its commits exactly
678
+ * as they stand. The caller already confirmed the node is not running. */
679
+ function abandonBranchMovedWorktree(nodeId, wt, actualBranch, by) {
680
+ if (isRebaseInProgress(wt.path)) {
681
+ throw new WorktreeError('rebase_in_progress', 'this managed worktree is mid-rebase', `Resolve conflicts in ${wt.path}, run \`git rebase --continue\` there, then rerun \`crtr node worktree abandon\`.`);
682
+ }
683
+ const dirty = statusPorcelain(wt.path);
684
+ if (dirty !== '') {
685
+ throw new WorktreeError('dirty_worktree', 'managed worktree has uncommitted changes', `The recorded branch ${wt.branch} no longer exists; its checkout is now on ${actualBranch} with uncommitted changes crtr will not commit on your behalf. Commit or stash them in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
686
+ }
687
+ if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true) {
688
+ throw new WorktreeError('wrong_branch', `managed worktree's recorded branch ${wt.branch} no longer exists, and its checkout at ${wt.path} is not registered to ${actualBranch} either`, `Inspect ${wt.path} and its git worktree registration, then ask the user with \`crtr human send\` if you are unsure how to proceed.`);
689
+ }
690
+ const actualSha = runGit(wt.path, ['rev-parse', 'HEAD'], 'abandon_tip_failed', 'Inspect the worktree manually, then retry.');
691
+ // Recheck identity immediately before the destructive step.
692
+ if (registeredWorktree(wt.repo_root, wt.path, actualBranch) !== true || currentBranch(wt.path) !== actualBranch) {
693
+ throw new WorktreeError('wrong_branch', `managed worktree at ${wt.path} changed while abandoning`, `Inspect ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
694
+ }
695
+ runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
696
+ const at = new Date().toISOString();
697
+ persistAbandonedWorktreeState(nodeId, wt, actualSha, at, by, actualBranch);
698
+ return { node_id: nodeId, branch: actualBranch, tip_sha: actualSha, worktree_path: wt.path, branch_deleted: false };
699
+ }
540
700
  function createAbandonMarker(wt, parentSha, nodeId, at) {
541
701
  const treeSha = runGit(wt.repo_root, ['rev-parse', `${parentSha}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
542
702
  const markerSha = runGit(wt.repo_root, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit-tree', treeSha, '-p', parentSha, '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the branch manually, then retry.');
@@ -550,7 +710,8 @@ function abandonManagedWorktreeLocked(nodeId, by) {
550
710
  }
551
711
  const wt = openManagedWorktreeForNode(nodeId);
552
712
  if (wt === null) {
553
- throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to abandon for this node.');
713
+ const { message, next } = noManagedWorktreeMessage(nodeId, 'abandon');
714
+ throw new WorktreeError('no_managed_worktree', message, next);
554
715
  }
555
716
  if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid)) {
556
717
  throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
@@ -558,8 +719,17 @@ function abandonManagedWorktreeLocked(nodeId, by) {
558
719
  if (!existsSync(wt.path)) {
559
720
  throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
560
721
  }
561
- if (currentBranch(wt.path) !== wt.branch) {
562
- throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch})`, `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
722
+ const actualBranch = currentBranch(wt.path);
723
+ if (actualBranch !== wt.branch) {
724
+ const recordedRefExists = recordedBranchExists(wt.path, wt.branch);
725
+ if (recordedRefExists || actualBranch === 'HEAD') {
726
+ throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}${actualBranch === 'HEAD' ? ', found detached HEAD' : ''})`, recordedRefExists
727
+ ? `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`
728
+ : `The recorded branch ${wt.branch} no longer exists, and ${wt.path} is in detached HEAD, so there is no branch to preserve. Check out or create a branch there, then rerun \`crtr node worktree abandon\`.`);
729
+ }
730
+ // The recorded crtr/* branch is gone: ordinary PR work moved this checkout
731
+ // onto a real branch.
732
+ return abandonBranchMovedWorktree(nodeId, wt, actualBranch, by);
563
733
  }
564
734
  const at = new Date().toISOString();
565
735
  const clean = proveCleanEmptyManagedWorktree(wt);
@@ -575,6 +745,15 @@ function abandonManagedWorktreeLocked(nodeId, by) {
575
745
  persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
576
746
  return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
577
747
  }
748
+ // A clean branch with committed work already has its complete history. The
749
+ // abandon record supplies the disposition; an empty provenance commit would
750
+ // only move the local branch away from an already-pushed tip.
751
+ if (!isRebaseInProgress(wt.path) && statusPorcelain(wt.path) === '') {
752
+ runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
753
+ const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
754
+ persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
755
+ return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
756
+ }
578
757
  runGit(wt.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
579
758
  runGit(wt.path, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit', '--allow-empty', '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the worktree manually, then retry.');
580
759
  runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
@@ -775,6 +954,39 @@ export function autoDropCleanManagedWorktreeLocked(nodeId, wt, proof) {
775
954
  * `open_managed_worktree` guard then still applies unchanged — real
776
955
  * commits, real uncommitted changes, and a lost race all require the
777
956
  * explicit path. */
957
+ function cleanCurrentBranchTip(wt) {
958
+ if (wt.state !== 'open' || !existsSync(wt.path) || isRebaseInProgress(wt.path))
959
+ return null;
960
+ const branch = gitSync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
961
+ if (branch.status !== 0 || branch.stdout.trim() !== wt.branch)
962
+ return null;
963
+ const status = gitSync(['status', '--porcelain'], wt.path);
964
+ if (status.status !== 0 || status.stdout.trim() !== '')
965
+ return null;
966
+ const head = gitSync(['rev-parse', 'HEAD'], wt.path);
967
+ return head.status === 0 ? head.stdout.trim() : null;
968
+ }
969
+ export async function branchIsPushedAtCurrentTip(wt) {
970
+ const initialTip = cleanCurrentBranchTip(wt);
971
+ if (initialTip === null)
972
+ return false;
973
+ // A cached origin/<branch> can be stale in either direction. Query origin's
974
+ // branch ref directly and require exact-tip equality: a remote that has an
975
+ // older commit would still leave newer local work stranded at finalization.
976
+ // This runs asynchronously with a bounded, non-interactive Git invocation so
977
+ // an unreachable remote never stalls crtrd's event loop.
978
+ const ssh = process.env['GIT_SSH_COMMAND'] ?? process.env['GIT_SSH'] ?? 'ssh';
979
+ const remote = await gitAsync(['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${wt.branch}`], wt.path, undefined, { timeoutMs: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: `${ssh} -o BatchMode=yes` } });
980
+ if (remote.status !== 0)
981
+ return false;
982
+ const expectedRef = `refs/heads/${wt.branch}`;
983
+ const matchingLine = remote.stdout.split('\n').find((line) => line.endsWith(`\t${expectedRef}`));
984
+ if (matchingLine?.slice(0, matchingLine.indexOf('\t')) !== initialTip)
985
+ return false;
986
+ // The remote proof is useful only if the local checkout is still the exact
987
+ // clean tip it inspected when finalization begins.
988
+ return cleanCurrentBranchTip(wt) === initialTip;
989
+ }
778
990
  export function autoDropCleanManagedWorktree(nodeId) {
779
991
  const wt = openManagedWorktreeForNode(nodeId);
780
992
  if (wt === null)
@@ -170,12 +170,13 @@ function handleArm(ctx) {
170
170
  ? requireUtcIso(req.expires_at, 'expires_at')
171
171
  : null;
172
172
  const envJson = typeof req.env_json === 'string' && req.env_json !== '' ? requireEnvJson(req.env_json) : null;
173
- const onOutput = requireEnum(req.on_output, 'on_output', ['silent', 'on-failure', 'always', 'on-change'], 'on-failure');
174
- const overlap = requireEnum(req.overlap, 'overlap', ['skip', 'queue', 'replace'], 'skip');
175
- const tier = requireEnum(req.tier, 'tier', ['normal', 'urgent', 'critical'], 'normal');
176
173
  // Sink: required by the delivering dispositions, meaningless otherwise —
177
- // progressive disclosure via the error that names the need.
174
+ // progressive disclosure via the error that names the need. A node sink is
175
+ // polling its recipient, so unchanged stdout must not wake it again by default.
178
176
  const rawSink = typeof req.sink === 'string' && req.sink.trim() !== '' ? req.sink.trim() : null;
177
+ const onOutput = requireEnum(req.on_output, 'on_output', ['silent', 'on-failure', 'always', 'on-change'], rawSink?.startsWith('node:') ? 'on-change' : 'on-failure');
178
+ const overlap = requireEnum(req.overlap, 'overlap', ['skip', 'queue', 'replace'], 'skip');
179
+ const tier = requireEnum(req.tier, 'tier', ['normal', 'urgent', 'critical'], 'normal');
179
180
  const delivers = onOutput === 'always' || onOutput === 'on-change';
180
181
  if (delivers && rawSink === null) {
181
182
  throw new InputError({
@@ -232,6 +232,11 @@ async function handleMessage(ctx) {
232
232
  };
233
233
  return { status: 201, body };
234
234
  }
235
+ // Interactive delivery has the same finalization doorway as durable mail:
236
+ // a live broker remains reachable briefly after its final claim, but must not
237
+ // accept a mandate it cannot finalize. `--reopen` is the deliberate exception.
238
+ if (req.delivery === 'interactive' && !req.reopen)
239
+ assertNotFinalized(id);
235
240
  // An interactive reopen commits before even choosing its engine-or-inbox
236
241
  // route. A live transport race then falls back with this same committed state.
237
242
  let reopenCommitted = false;
@@ -141,6 +141,8 @@ function buildRecipe(req, parent, cwd) {
141
141
  recipe.worktree = true;
142
142
  if (typeof req.worktree === 'string')
143
143
  recipe.worktreeBase = req.worktree;
144
+ if (req.worktree !== undefined)
145
+ recipe.worktreeCwd = resolve(req.pin_cwd ?? req.cwd ?? cwd);
144
146
  if (req.fork_from !== undefined)
145
147
  recipe.forkFrom = req.fork_from;
146
148
  if (req.model !== undefined)