@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
@@ -134,7 +134,7 @@ const addLeaf = defineLeaf({
134
134
  { kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'One-shot: run the stored bash once at <when> — a duration ("90s","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", interpreted in --tz else host zone). Exactly one of --at / --every is required. The row is deleted after its run unless the run\'s disposition pauses it.' },
135
135
  { kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'Recurring: run the stored bash at each cadence — a fixed interval ("6h" — first fire one interval from now), a 5-field cron ("0 9 * * *"), or an @alias ("@daily"). Each fire is another bash run. Minimum cadence 60s. Exactly one of --at / --every is required. For a gated cron (a command that exits 75 while ineligible), pick a LONG cadence: each natural slot is only the backstop re-check, and the daemon poke supplies the latency — "6h" plus poke beats "15m" polling on both latency and cost.' },
136
136
  { kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone (e.g. "America/New_York") for a bare-ISO --at or a calendar --every. Defaults to the host zone.' },
137
- { kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, default: 'on-failure', constraint: 'What each run does with its output. silent: record in the run log, tell nobody — never escalate. on-failure (default): no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
137
+ { kind: 'flag', name: 'on-output', type: 'enum', choices: ['silent', 'on-failure', 'always', 'on-change'], required: false, constraint: 'What each run does with its output. Defaults to on-change for a node:<id> sink; otherwise on-failure. silent: record in the run log, tell nobody — never escalate. on-failure: no success delivery; a failing run pauses the cron and spawns a node to deal with it. always: deliver stdout every run to --sink at --tier. on-change: deliver stdout only when it differs from the previous run\'s — the shape polling wants.' },
138
138
  { kind: 'flag', name: 'sink', type: 'string', required: false, constraint: 'Where always/on-change stdout deliveries go: "node:<id>" (an existing node\'s inbox — must exist now; gone/finalized at fire time is a failure), "spawn:<kind>" (each delivered output creates a self-finishing node with stdout as kickoff — a managed child while its creator exists, otherwise a terminal parentless root), or "human" (the humanloop inbox). Required by always/on-change, rejected otherwise.' },
139
139
  { kind: 'flag', name: 'tier', type: 'enum', choices: ['normal', 'urgent', 'critical'], required: false, default: 'urgent', constraint: 'Inbox priority of a node-sink delivery. urgent (default): steers the target mid-turn, or wakes it when dormant — a fire is news the node asked for on a schedule, so it should land now. normal: waits for the running turn to settle. critical: aborts the running turn outright.' },
140
140
  { kind: 'flag', name: 'expires', type: 'string', required: false, constraint: 'Clock bound (same grammar as --at): the row is deleted once this instant passes. The self-limiting half of a poll-until pattern. For a held one-shot (last run exited 75), expiry bounds the wait for eligibility — the row is deleted UNFIRED, never fired blind at expiry.' },
@@ -254,7 +254,7 @@ const addLeaf = defineLeaf({
254
254
  env_json: envJson,
255
255
  profile: profile ?? null,
256
256
  scope,
257
- on_output: input['onOutput'] ?? 'on-failure',
257
+ on_output: input['onOutput'],
258
258
  sink: typeof input['sink'] === 'string' && input['sink'] !== '' ? input['sink'] : null,
259
259
  tier: input['tier'] ?? 'urgent',
260
260
  expires_at: expiresAt,
@@ -525,7 +525,7 @@ export function registerCron() {
525
525
  help: {
526
526
  name: 'cron',
527
527
  summary: 'scheduled bash commands, daemon-run',
528
- model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node, an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 and the occurrence is OWED: the row is held, fires again the moment the daemon receives an eligibility poke from the host platform, and otherwise re-checks at its next natural slot (a held one-shot instead waits for a poke until --expires deletes it unfired). Any other nonzero exit is a real failure and escalates per --on-output. Use 75 for "not yet — retry when conditions change" (a device coming online); keep gate ERRORS nonzero so a broken probe is loud instead of silently parked. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
528
+ model: 'A cron stores and daemon-runs arbitrary bash at its clock. Use ordinary bash for ordinary work; choose node:<id> for stdout as inbox information to an existing node (its disposition defaults to on-change), an existing node\'s lifecycle fresh-revive action in the bash for a clean re-check without inbox output, or spawn:<kind> to route each output-producing fire to a fresh self-finishing node (a managed child while its creator exists, otherwise a terminal parentless root). Per-fire fresh agent work belongs on the existing spawn:<kind> sink, not an independent resident-root birth. A gate is bash, at the top of the command. Exit 0 after deciding not to act and the occurrence is simply spent — right when the recurrence is your polling cadence. Exit 75 and the occurrence is OWED: the row is held, fires again the moment the daemon receives an eligibility poke from the host platform, and otherwise re-checks at its next natural slot (a held one-shot instead waits for a poke until --expires deletes it unfired). Any other nonzero exit is a real failure and escalates per --on-output. Use 75 for "not yet — retry when conditions change" (a device coming online); keep gate ERRORS nonzero so a broken probe is loud instead of silently parked. Beyond the exit-code contract, cron has no native predicate-trigger semantics. --at runs once and deletes the row unless the run\'s disposition pauses it; --every runs each cadence. Output disposition (--on-output) records silently, escalates failures, or routes stdout to its --sink. Anchor a cron to delete it with a node; --cancel-on-wake instead deletes it when the anchor wakes. An unanchored recurring cron ends through --expires, explicit cancellation, or self-cancellation from its bash. Every run lands in a bounded per-cron run log (`cron show`); cwd, env, and profile are snapshotted at arm time, and --scope controls who lists and cancels it.',
529
529
  },
530
530
  children: [addLeaf, listLeaf, showLeaf, runLeaf, pauseLeaf, resumeLeaf, cancelLeaf],
531
531
  });
@@ -12,6 +12,7 @@ import { stateBlock } from '../../core/help.js';
12
12
  import { cliClient, getNodeOrNull, rethrowAsCliError } from '../api-client.js';
13
13
  import { inTmux } from '../../core/runtime/placement-tmux.js';
14
14
  import { openSpawnViewer } from '../surface/node/placement.js';
15
+ import { resolve } from 'node:path';
15
16
  const YIELD_NUDGE_THRESHOLD = 100_000;
16
17
  const NODE_NEW_OUTPUT_SCHEMA_EFFECT = '--output-schema writes the accepted result to the node’s context/result.json and pushes it as the node’s final report.';
17
18
  const STD_CHILD_FOLLOW_UP = "Do not wait, poll, or duplicate this child's assignment in your own context — there is no result to await, stopping will not strand you, and duplicated work wastes the delegated context. You're auto-subscribed, so its finish wakes you on its own. Two moves only: do other independent work right now, or stop and end your turn — the wake brings you back.";
@@ -177,7 +178,8 @@ async function runNodeCreation(input) {
177
178
  }
178
179
  const kind = input['kind'];
179
180
  const mode = (input['mode'] ?? 'base');
180
- const pinCwd = input['cwd']?.trim();
181
+ const pinCwdRaw = input['cwd']?.trim();
182
+ const pinCwd = pinCwdRaw !== undefined && pinCwdRaw !== '' ? resolve(pinCwdRaw) : undefined;
181
183
  const name = input['name'];
182
184
  const parent = input['parent'];
183
185
  const root = input['root'] === true;
@@ -71,16 +71,16 @@ const worktreeAbandon = defineLeaf({
71
71
  });
72
72
  const worktreeClose = defineLeaf({
73
73
  name: 'close',
74
- description: 'land and close the current node-managed worktree',
75
- whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto its recorded local base branch and fast-forward that base before finishing; its active checkout is retained for deferred cleanup',
74
+ description: 'land or safely close the current node-managed worktree',
75
+ whenToUse: 'you are inside the node that owns an open managed worktree and are ready to land its recorded branch into the local base, or to close a moved real branch whose already-delivered content can be safely preserved while its checkout is disposed later',
76
76
  help: {
77
77
  name: 'node worktree close',
78
- summary: 'land and close the current node-managed worktree',
78
+ summary: 'land or safely close the current node-managed worktree',
79
79
  params: [],
80
80
  output: [
81
81
  { name: 'node_id', type: 'string', required: true, constraint: 'The caller node id whose managed worktree was closed.' },
82
- { name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into its recorded local base branch.' },
83
- { name: 'landed_sha', type: 'string', required: true, constraint: 'The commit SHA that the recorded local base branch was fast-forwarded onto.' },
82
+ { name: 'branch', type: 'string', required: true, constraint: 'The branch whose delivered content is retained: fast-forwarded into the recorded local base in an ordinary close, or preserved unchanged when a moved real branch is authorized for disposal.' },
83
+ { name: 'landed_sha', type: 'string', required: true, constraint: 'The retained branch tip: fast-forwarded into the local base in an ordinary close, or proven already contained in the local base or its upstream before a moved real branch is authorized for disposal.' },
84
84
  { name: 'worktree_path', type: 'string', required: true, constraint: 'The managed worktree path whose cleanup is deferred until its caller exits.' },
85
85
  { name: 'worktree_removed', type: 'boolean', required: true, constraint: 'Always false for an active caller: its checkout remains intact rather than being removed beneath the node.' },
86
86
  { name: 'worktree_remove_error', type: 'string', required: true, constraint: 'The named deferred-cleanup state and safe manual follow-up after the caller exits.' },
@@ -89,7 +89,7 @@ const worktreeClose = defineLeaf({
89
89
  ],
90
90
  outputKind: 'object',
91
91
  effects: [
92
- 'Rebases the worktree onto its recorded local base branch, fast-forwards that base onto it (never pushes to origin), marks the node-managed worktree closed with cleanup pending, and leaves the active caller checkout and branch intact.',
92
+ 'Rebases the worktree onto its recorded local base branch and fast-forwards that base onto it (never pushes to origin), marking the node-managed worktree closed with cleanup pending and leaving the active caller checkout and branch intact. When the recorded branch is gone because ordinary work moved this checkout onto a real branch, and that branch\'s content is already contained in the recorded base, closes by authorizing the checkout\'s later removal instead of landing — preserving that branch rather than deleting it.',
93
93
  ],
94
94
  },
95
95
  run: async () => {
@@ -105,7 +105,7 @@ const worktreeClose = defineLeaf({
105
105
  const branch = String(r['branch'] ?? '');
106
106
  const sha = String(r['landed_sha'] ?? '');
107
107
  const path = String(r['worktree_path'] ?? '');
108
- let base = `Managed worktree landed — its local base branch fast-forwarded to ${branch} at ${sha}.`;
108
+ let base = `Managed worktree closed — ${branch} at ${sha} is retained.`;
109
109
  const notes = [];
110
110
  if (r['worktree_removed'] === false) {
111
111
  notes.push(String(r['worktree_remove_error'] ?? `worktree checkout at ${path} could not be removed automatically.`));
@@ -1,6 +1,6 @@
1
1
  import { envProfileId } from '../../shared/env.js';
2
2
  import { dirname, join, resolve, isAbsolute, relative, sep } from 'node:path';
3
- import { renameSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, renameSync, writeFileSync } from 'node:fs';
4
4
  import { createHash } from 'node:crypto';
5
5
  import { defineLeaf } from '../../core/command.js';
6
6
  import { notFound, usage, general, network } from '../../core/errors.js';
@@ -282,6 +282,15 @@ function writeStagedBundle(stagingRoot, manifest, bundle) {
282
282
  ensureDir(resolve(target, '..'));
283
283
  writeFileSync(target, document.bytes);
284
284
  }
285
+ if (bundle.hooks !== undefined) {
286
+ writeFileSync(stagedPath(stagingRoot, 'hooks.json'), bundle.hooks.manifest);
287
+ for (const file of bundle.hooks.files) {
288
+ const target = stagedPath(stagingRoot, file.path);
289
+ ensureDir(resolve(target, '..'));
290
+ writeFileSync(target, file.bytes);
291
+ }
292
+ chmodSync(stagedPath(stagingRoot, 'hooks/dispatch'), 0o755);
293
+ }
285
294
  }
286
295
  async function validateStagedBundle(name, scope, root, scopeRootPath, manifest) {
287
296
  const memory = await assertPluginCandidateValid(manifest, root, scope, scopeRootPath, `${root}/.crouter-plugin/plugin.json`);
@@ -345,6 +354,7 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
345
354
  // later archive that no longer declares them is what retires them.
346
355
  ...(validated.bundle.page_components !== undefined ? { page_components: validated.bundle.page_components } : {}),
347
356
  ...(validated.bundle.memory_extensions !== undefined ? { memory_extensions: validated.bundle.memory_extensions } : {}),
357
+ ...(validated.bundle.hooks !== undefined ? { hooks: 'hooks.json', hookExecutable: 'hooks/dispatch' } : {}),
348
358
  };
349
359
  const tmpRoot = join(scopeRootPath, 'tmp');
350
360
  const staging = join(tmpRoot, `${name}.${process.pid}`);
@@ -390,7 +400,8 @@ async function replaceBundlePlugin(name, bundleSource, scope, options) {
390
400
  const commands = await commandReport(name, scope);
391
401
  if (commands === undefined)
392
402
  throw general(`bundle plugin "${name}" has no staged command report`);
393
- return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, kinds: Object.keys(validated.bundle.kinds ?? {}).length, pageComponents: (validated.bundle.page_components ?? []).length, commands, ...(memory !== undefined ? { memory } : {}) };
403
+ const hooks = await hookLifecycleReport(name, scope);
404
+ return { name, scope, path: root, transport: 'http', version, docs: validated.bundle.memory.length, kinds: Object.keys(validated.bundle.kinds ?? {}).length, pageComponents: (validated.bundle.page_components ?? []).length, commands, ...(hooks !== undefined ? { hooks } : {}), ...(memory !== undefined ? { memory } : {}) };
394
405
  }
395
406
  async function installHttpPlugin(name, endpoint, authEnv, scope) {
396
407
  const source = validateHttpInstall(name, endpoint, authEnv);
@@ -674,7 +685,7 @@ async function updateOnePlugin(plugin, marketplaceCache, opts) {
674
685
  const sourceInstalled = isSourceInstalled(plugin);
675
686
  if (!sourceInstalled && plugin.manifest.bundle !== undefined) {
676
687
  const replaced = await replaceBundlePlugin(plugin.name, plugin.manifest.bundle, plugin.scope, { enable: plugin.enabled });
677
- return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, kinds: replaced.kinds, pageComponents: replaced.pageComponents, commands: replaced.commands, ...(replaced.memory !== undefined ? { memory: replaced.memory } : {}) };
688
+ return { name: plugin.name, transport: 'http', updated: true, version: replaced.version, docs: replaced.docs, kinds: replaced.kinds, pageComponents: replaced.pageComponents, commands: replaced.commands, ...(replaced.hooks !== undefined ? { hooks: replaced.hooks } : {}), ...(replaced.memory !== undefined ? { memory: replaced.memory } : {}) };
678
689
  }
679
690
  if (plugin.manifest.transport?.kind === 'http' && !sourceInstalled)
680
691
  rejectLegacyHttpPlugin(plugin.name);
@@ -729,16 +740,16 @@ export const pluginInstall = defineLeaf({
729
740
  { name: 'kinds', type: 'integer', required: false, constraint: 'Kind-registry entry count declared by an --endpoint archive\u2019s bundle.json.' },
730
741
  { name: 'pageComponents', type: 'integer', required: false, constraint: 'Page-component registration count declared by an --endpoint archive\u2019s bundle.json.' },
731
742
  { name: 'commands', type: 'object', required: false, constraint: 'Present only when the plugin declares a command manifest. {mounts: string[] (accepted top-level command names now live for the next invocation), issues: object[] (typed validation issues that rejected a contribution — {code, path?, message, received, expected, next})}. Validated statically; commands are never executed.' },
732
- { name: 'hooks', type: 'object', required: false, constraint: 'Present only when the source plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}. Command targets, lifecycle events, and replacement collisions are validated statically; hook executables are never run.' },
743
+ { name: 'hooks', type: 'object', required: false, constraint: 'Present only when the plugin declares hooks. {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}. Command targets, lifecycle events, and replacement collisions are validated statically; hook executables are never run.' },
733
744
  { name: 'warnings', type: 'string[]', required: false, constraint: 'Advisory missing PATH executable requirements declared by the installed plugin. The install still succeeds and leaves the plugin enabled.' },
734
745
  { name: 'memory', type: 'object', required: false, constraint: 'Present only when the plugin ships a memory store. {docs: integer (documents checked), migrated: string[] (store-relative documents rewritten to reach exact canonical identity), linkedSource?: string (the caller-owned directory converged in place, present only when the install keeps that directory as a live link)}.' },
735
746
  ],
736
747
  outputKind: 'object',
737
748
  effects: [
738
749
  'A ref install clones, links, or copies the plugin into the scope plugins directory and registers it with enabled=true. Marketplace installs refresh the source marketplace before resolving the plugin entry.',
739
- 'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds), and any page components it declares (they join the active page-component catalog alongside the user config entries); a fetch or validation failure preserves the prior package.',
750
+ 'An --endpoint install fetches and validates one authenticated uncompressed tar archive before writing. It replaces the complete plugin directory with synthesized provenance and invocation metadata, commands.json, memory docs, and, when declared, hooks.json plus the hooks/ tree; it also copies any kind-registry entries the bundle.json declares (they join the launch registry below this scope\u2019s own config.json kinds), and any page components it declares (they join the active page-component catalog alongside the user config entries); a fetch or validation failure preserves the prior package.',
740
751
  'If the plugin declares a command manifest, its commands go live on the next crtr invocation. Exec commands run trusted local code only when explicitly invoked; HTTP commands call their declared endpoint only when explicitly invoked.',
741
- 'Source plugin command hooks go live on the next crtr invocation. They run implicitly with caller authority, may receive normalized inputs and after results, may block or replace declared targets, and may transmit received data. Lifecycle hooks run unprompted at every declared event before node launch and may transmit node identity and resolved paths. Hook executables are never run during install or update validation.',
752
+ 'Plugin command hooks go live on the next crtr invocation. They run implicitly with caller authority, may receive normalized inputs and after results, may block or replace declared targets, and may transmit received data. Lifecycle hooks run unprompted at every declared event before node launch and may transmit node identity and resolved paths. Hook executables are never run during install or update validation.',
742
753
  'A plugin manifest `requires` declaration is checked against the PATH a node bash receives after install. Missing executables emit a warning with the declared install hint but never fail the install, disable the plugin, or remove its bare-binary contributions.',
743
754
  'Before activation the candidate\u2019s memory corpus is migrated to exact canonical identity and refused if it cannot mount. A staged candidate is migrated in staging and rolls back with the package; a local path or marketplace-relative source stays a live link, so its own directory is rewritten in place, keeps that rewrite whether or not the install completes, and is named as `memory.linkedSource`. A refused corpus writes nothing and installs nothing.',
744
755
  ],
@@ -895,7 +906,7 @@ export const pluginDisable = defineLeaf({
895
906
  export const pluginUpdate = defineLeaf({
896
907
  name: 'update',
897
908
  description: 'update installed plugin content',
898
- whenToUse: 'refreshing one named plugin or all installed plugins: archive plugins replace commands and memory together, while source-installed plugins retain their source update behavior',
909
+ whenToUse: 'refreshing one named plugin or all installed plugins: archive plugins replace their complete package, while source-installed plugins retain their source update behavior',
899
910
  help: {
900
911
  name: 'pkg plugin update',
901
912
  summary: 'refresh one or all installed plugins',
@@ -904,7 +915,7 @@ export const pluginUpdate = defineLeaf({
904
915
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Narrows resolution.' },
905
916
  ],
906
917
  output: [
907
- { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. hooks is present for source hook plugins: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
918
+ { name: 'updated', type: 'object[]', required: true, constraint: 'One entry per plugin processed: {name, transport?, updated, sha?, version?, docs?, kinds?, pageComponents?, commands?, hooks?, memory?, warnings?, error?}. memory is present for a plugin shipping a memory store and reports the same corpus migration install performs: {docs, migrated, linkedSource?}. Archive plugins report transport=http and replace their complete directory after an authenticated refetch; version is the archive content identifier, docs is the written memory-document count, kinds is the declared kind-registry entry count, and pageComponents is the declared page-component registration count. sha is present for git updates. A bulk update reports and skips a failed archive plugin. commands is present for validated command plugins: {mounts: string[], issues: object[]}. hooks is present for plugins declaring hooks: {manifestPath?, executablePath?, declarations: {target, phase, op, description, effects}[], lifecycle?: {event, phase, op, description, effects}[], issues: object[], trust: string}; it is static and never executes hooks. warnings lists advisory PATH requirements that remain absent after update.' }
908
919
  ],
909
920
  outputKind: 'object',
910
921
  effects: [
@@ -71,7 +71,7 @@ function makePushLeaf(kind) {
71
71
  ...(kind === 'final'
72
72
  ? [
73
73
  'Marks the node done (status + intent) server-side; its engine shuts down on next stop.',
74
- 'When this node owns a managed worktree with zero commits ahead of its base and no uncommitted changes, the server auto-drops it instead of blocking on `node worktree close`; a worktree with real commits or real uncommitted changes still blocks with open_managed_worktree.',
74
+ 'When this node owns a clean managed worktree already contained in its local base, the server auto-drops it instead of blocking on `node worktree close`. A clean worktree whose exact branch tip is already on origin may also finalize while retaining its branch and checkout; uncommitted changes or a branch tip not on origin still block with open_managed_worktree.',
75
75
  ]
76
76
  : []),
77
77
  ],
@@ -10,6 +10,7 @@ export interface WaitOpts {
10
10
  intervalMs?: number;
11
11
  label?: string;
12
12
  }
13
+ export declare function readLines(path: string): string[];
13
14
  export interface Injected {
14
15
  content: string;
15
16
  deliverAs?: string;
@@ -123,6 +123,18 @@ async function waitFor(probe, opts = {}) {
123
123
  await new Promise((r) => setTimeout(r, intervalMs));
124
124
  }
125
125
  }
126
+ export function readLines(path) {
127
+ try {
128
+ const content = readFileSync(path, 'utf8');
129
+ const lines = content.split('\n');
130
+ if (!content.endsWith('\n'))
131
+ lines.pop();
132
+ return lines.filter((l) => l.trim() !== '');
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ }
126
138
  /** Thin wrapper: a headless-only harness (broker-hosted, paneless nodes). */
127
139
  export async function createHeadlessHarness(opts = {}) {
128
140
  return createHarness({ ...opts, headless: true });
@@ -377,16 +389,6 @@ export async function createHarness(opts = {}) {
377
389
  return [];
378
390
  }
379
391
  }
380
- function readLines(path) {
381
- try {
382
- return readFileSync(path, 'utf8')
383
- .split('\n')
384
- .filter((l) => l.trim() !== '');
385
- }
386
- catch {
387
- return [];
388
- }
389
- }
390
392
  function cli(nodeId, args) {
391
393
  const runtimeArgs = TSX_ESM === null ? [] : ['--import', TSX_ESM];
392
394
  const res = spawnSync(process.execPath, [...runtimeArgs, CLI_ENTRY, ...args], {
@@ -440,8 +440,13 @@ test('close concludes a clean child after parent-first closure removes its check
440
440
  run(['commit', '-m', 'init'], repo);
441
441
  const parent = createManagedWorktree(repo, 'wt-parent-first-parent');
442
442
  createNode(node('wt-parent-first-parent', { cwd: parent.path, managed_worktree: parent }));
443
- const child = createManagedWorktree(parent.path, 'wt-parent-first-child', parent.branch);
444
- createNode(node('wt-parent-first-child', { cwd: child.path, managed_worktree: child }));
443
+ // Creation now refuses a managed worktree as its base (`managed_worktree_base`),
444
+ // so the child is cut from the real repository against the parent's BRANCH, and
445
+ // its record carries the parent checkout as `repo_root` — the exact legacy shape
446
+ // this case is about, still on disk for every record created before that guard.
447
+ const child = createManagedWorktree(repo, 'wt-parent-first-child', parent.branch);
448
+ const childRecord = { ...child, repo_root: parent.path };
449
+ createNode(node('wt-parent-first-child', { cwd: child.path, managed_worktree: childRecord }));
445
450
  assert.equal(gitSync(['rev-parse', 'HEAD'], child.path).stdout.trim(), child.base_sha, 'the child must be a clean zero-commit checkout at its recorded base');
446
451
  closeManagedWorktree('wt-parent-first-parent');
447
452
  run(['worktree', 'remove', parent.path], repo);
@@ -9,7 +9,7 @@ import { test, before, after, afterEach } from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
- import { createHeadlessHarness } from '../helpers/harness.js';
12
+ import { createHeadlessHarness, readLines } from '../helpers/harness.js';
13
13
  import { createAttachKit } from '../helpers/broker-clients.js';
14
14
  import { defaultModelLaddersConfig } from '../../../types.js';
15
15
  import { admitProviderRetryEpisode, readFault, readProviderRetryEpisode } from '../../runtime/fault.js';
@@ -63,21 +63,8 @@ function overflowOutcome(id) {
63
63
  // re-drive is a fresh session.prompt → emitTurn, which fires one 'agent_start';
64
64
  // counting them is the observable proof of how many times the turn was re-driven.
65
65
  function agentStartCount(id) {
66
- try {
67
- return readFileSync(join(h.home, 'nodes', id, 'fake-pi.events.jsonl'), 'utf8')
68
- .split('\n')
69
- .filter((l) => {
70
- try {
71
- return JSON.parse(l).event === 'agent_start';
72
- }
73
- catch {
74
- return false;
75
- }
76
- }).length;
77
- }
78
- catch {
79
- return 0;
80
- }
66
+ return readLines(join(h.home, 'nodes', id, 'fake-pi.events.jsonl'))
67
+ .filter((line) => JSON.parse(line).event === 'agent_start').length;
81
68
  }
82
69
  async function crashAndRespawn(id, boots) {
83
70
  const pid = h.node(id)?.pi_pid;