@north-light/crouter 0.3.275 → 0.3.277

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 (36) hide show
  1. package/dist/builtin-memory/02-turn-lifecycle/01-terminal.md +1 -1
  2. package/dist/clients/attach/viewer.js +1 -1
  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/command-plugins.test.js +6 -6
  11. package/dist/core/__tests__/integration/worktree-land.test.js +7 -2
  12. package/dist/core/__tests__/seam/broker-provider-retry.test.js +3 -16
  13. package/dist/core/canvas/types.d.ts +18 -0
  14. package/dist/core/command-manifests/manifest.d.ts +4 -2
  15. package/dist/core/command-plugins/bundle.d.ts +7 -0
  16. package/dist/core/command-plugins/bundle.js +18 -4
  17. package/dist/core/command-plugins/help-addenda.d.ts +12 -3
  18. package/dist/core/command-plugins/help-addenda.js +17 -4
  19. package/dist/core/command.js +2 -2
  20. package/dist/core/git.d.ts +5 -1
  21. package/dist/core/git.js +18 -4
  22. package/dist/core/runtime/spawn.d.ts +2 -0
  23. package/dist/core/runtime/spawn.js +1 -1
  24. package/dist/core/worktree-sweep.js +28 -5
  25. package/dist/core/worktree.d.ts +1 -12
  26. package/dist/core/worktree.js +222 -10
  27. package/dist/daemon/api/handlers/crons.js +5 -4
  28. package/dist/daemon/api/handlers/messages.js +5 -0
  29. package/dist/daemon/api/handlers/nodes.js +2 -0
  30. package/dist/daemon/api/handlers/reports.js +11 -7
  31. package/dist/daemon/reconcilers/live-obligation.d.ts +3 -2
  32. package/dist/daemon/reconcilers/live-obligation.js +4 -0
  33. package/dist/types.d.ts +3 -3
  34. package/dist/types.js +2 -2
  35. package/package.json +3 -1
  36. package/runtime.lock.json +2 -2
@@ -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)
@@ -9,7 +9,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { getNode } from '../../../core/canvas/canvas.js';
11
11
  import { push, FinalizationError } from '../../../core/feed/feed.js';
12
- import { autoDropCleanManagedWorktree, hasOpenManagedWorktree, missingWorktreeRecoveryHint } from '../../../core/worktree.js';
12
+ import { autoDropCleanManagedWorktree, branchIsPushedAtCurrentTip, hasOpenManagedWorktree, missingWorktreeRecoveryHint } from '../../../core/worktree.js';
13
13
  import { reportsDir } from '../../../core/canvas/paths.js';
14
14
  import { notFound, usage } from '../../../core/errors.js';
15
15
  import { InputError } from '../../../core/io.js';
@@ -51,14 +51,19 @@ async function handlePush(ctx) {
51
51
  // `final` with an OPEN managed worktree, BEFORE the push/transition (spec
52
52
  // §6.2 — worktree fs ops are server-side). A provably-clean, zero-commit
53
53
  // worktree is auto-dropped so a read-only or already-landed node
54
- // can finish without an explicit `node worktree close`; any real unlanded
55
- // work instead blocks with the parity `open_managed_worktree` error,
54
+ // can finish without an explicit `node worktree close`; a clean worktree
55
+ // whose exact tip is already on origin can also finish while retaining its
56
+ // branch and checkout. Any work that is neither locally landed nor remotely
57
+ // retained instead blocks with the parity `open_managed_worktree` error,
56
58
  // round-tripped via CrtrError.details so the CLI renders the same InputError
57
59
  // (next-hint included) it surfaced before B-5 re-homed this to the server.
58
60
  let autoDroppedPath;
59
61
  if (req.tier === 'final' && hasOpenManagedWorktree(getNode(id))) {
60
62
  const dropped = autoDropCleanManagedWorktree(id);
61
- if (dropped === null) {
63
+ if (dropped !== null) {
64
+ autoDroppedPath = dropped.worktree_path;
65
+ }
66
+ else if (!(await branchIsPushedAtCurrentTip(getNode(id).managed_worktree))) {
62
67
  // Thrown as InputError so `toErrorBody` round-trips the FULL structured
63
68
  // payload (code + message + next) into `ApiError.details`, and the CLI's
64
69
  // shared `apiErrorToCliError` reconstructs it byte-for-byte — no
@@ -70,11 +75,10 @@ async function handlePush(ctx) {
70
75
  const wt = getNode(id)?.managed_worktree;
71
76
  return wt !== null && wt !== undefined && wt.state === 'open' && !existsSync(wt.path)
72
77
  ? missingWorktreeRecoveryHint(wt)
73
- : 'Commit the remaining work in the worktree, run `crtr node worktree close`, then rerun `crtr push final`.';
78
+ : 'Commit the remaining work in the worktree, push its branch to origin at its current tip, then rerun `crtr push final`; or run `crtr node worktree close` to land it locally.';
74
79
  })(),
75
80
  });
76
81
  }
77
- autoDroppedPath = dropped.worktree_path;
78
82
  }
79
83
  const result = await pushOrRethrow(id, req);
80
84
  const transitioned = req.tier === 'final' && before !== undefined
@@ -100,7 +104,7 @@ async function pushOrRethrow(id, req) {
100
104
  throw new InputError({
101
105
  error: 'already_finalized',
102
106
  message: 'This node already submitted its canonical final result.',
103
- next: 'Do not push another final from this node.',
107
+ next: 'Push an update with the new information, then stop.',
104
108
  });
105
109
  }
106
110
  if (err instanceof FinalizationError && err.code === 'finalization_forbidden') {
@@ -20,8 +20,9 @@ export declare function hasHumanViewer(nodeId: string): boolean;
20
20
  /** What parking an unattended node actually amounts to — the one rule both the
21
21
  * live-broker park clock and the lifecycle tick apply.
22
22
  *
23
- * `release` — something can still wake it, or a person is watching it, so
24
- * parking is a pause.
23
+ * `release` — something can still wake it, a person is watching it, or a
24
+ * fatal provider fault prevents its engine from completing the
25
+ * summary turn; parking is a pause.
25
26
  * `park` — a resident with nothing left to wake it has completed its
26
27
  * conversation, and the row becomes prunable history. The live
27
28
  * clock asks it for a summary turn first; this verdict is what
@@ -6,6 +6,7 @@ import { readOutputRequest } from '../../core/runtime/structured-output.js';
6
6
  import { focusOf } from '../../core/runtime/placement.js';
7
7
  import { hasAttachedBrokerViewers } from '../../core/runtime/broker/client-registry.js';
8
8
  import { fanDoctrineWake } from '../../core/runtime/close.js';
9
+ import { readFault } from '../../core/runtime/fault.js';
9
10
  import { transition } from '../../core/runtime/lifecycle.js';
10
11
  /** Durable work that requires a node to remain automatically wakeable. */
11
12
  export function hasLiveObligation(nodeId) {
@@ -50,6 +51,9 @@ export function parkEvent(nodeId, lifecycle) {
50
51
  return 'release';
51
52
  if (hasLiveObligation(nodeId))
52
53
  return 'release';
54
+ const fault = readFault(nodeId);
55
+ if (fault?.link === 'pi→provider' && fault.retry.disposition === 'fatal')
56
+ return 'release';
53
57
  if (lifecycle === 'resident')
54
58
  return hasUnseenWakeMail(nodeId) ? 'release' : 'park';
55
59
  if (readInboxSince(nodeId, readCursor(nodeId)).length > 0)
package/dist/types.d.ts CHANGED
@@ -97,7 +97,7 @@ export interface PluginManifest {
97
97
  commands?: string;
98
98
  /** Plugin-root-relative path to a declarative hook manifest (`hooks.json`).
99
99
  * Must be declared together with `hookExecutable`; hooks are trusted local
100
- * code and are unavailable to endpoint archive plugins. */
100
+ * code. */
101
101
  hooks?: string;
102
102
  /** Plugin-root-relative executable that dispatches declarations in `hooks`.
103
103
  * Must be a regular file with a POSIX executable permission bit. */
@@ -311,11 +311,11 @@ export interface ScopeConfig {
311
311
  /** Fold every SETTLED tool call in the attach viewer down to its single call
312
312
  * line (`/fold-tools`, Alt+C → z → f). A tool still running is never folded —
313
313
  * it folds itself once it settles. Toggling it in a viewer writes back here,
314
- * so the choice carries into later chats and later viewers. Default false. */
314
+ * so the choice carries into later chats and later viewers. Default true. */
315
315
  fold_finished_tools: boolean;
316
316
  /** Replace completed folded tool-call groups with a broker-generated one-line
317
317
  * summary when one is available. Pending and failed groups stay as calls.
318
- * Default false. */
318
+ * Default true. */
319
319
  summarize_tool_calls: boolean;
320
320
  /** Show individual memory reads and edited files in a folded tool recap.
321
321
  * Off shows the compact activity-count line instead. Default true. */
package/dist/types.js CHANGED
@@ -97,8 +97,8 @@ export function defaultScopeConfig() {
97
97
  live_cycles: DEFAULT_LIVE_CYCLES,
98
98
  condensed_history: DEFAULT_CONDENSED_HISTORY,
99
99
  bash_tool_purpose: true,
100
- fold_finished_tools: false,
101
- summarize_tool_calls: false,
100
+ fold_finished_tools: true,
101
+ summarize_tool_calls: true,
102
102
  detailed_tool_recaps: true,
103
103
  keybindings: {},
104
104
  tmux_passthrough: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.275",
3
+ "version": "0.3.277",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -70,6 +70,8 @@
70
70
  ],
71
71
  "scripts": {
72
72
  "build": "node scripts/build.mjs",
73
+ "activate": "npm run build && npm run install-runtime && crtr sys daemon restart",
74
+ "activate:pull": "git pull && npm install && npm run activate",
73
75
  "build:api": "tsc -p packages/crouter-api/tsconfig.json",
74
76
  "typecheck:pi-packages": "npx tsc -p src/builtin-pi-packages/pi-crtr-extensions/tsconfig.json",
75
77
  "test:build-output": "node scripts/build-concurrency-test.mjs",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.275",
3
+ "version": "0.3.277",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.275",
9
+ "version": "0.3.277",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {