@north-light/crouter 0.3.249 → 0.3.250

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 (170) hide show
  1. package/README.md +1 -1
  2. package/dist/api/__tests__/integration/client.test.js +28 -7
  3. package/dist/api/client.d.ts +1 -1
  4. package/dist/api/client.js +2 -2
  5. package/dist/api/dto/lifecycle.d.ts +3 -0
  6. package/dist/api/dto/messages.d.ts +4 -0
  7. package/dist/api/dto/nodes.d.ts +4 -0
  8. package/dist/api/dto/reports.d.ts +7 -2
  9. package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
  10. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
  11. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
  15. package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
  16. package/dist/clients/attach/session/chrome-refresh.js +1 -0
  17. package/dist/clients/attach/viewer.js +532 -532
  18. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
  19. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
  20. package/dist/clients/inbox/controller.js +32 -5
  21. package/dist/clients/inbox/tui/ansi.d.ts +3 -1
  22. package/dist/clients/inbox/tui/ansi.js +14 -10
  23. package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
  24. package/dist/commands/api-client.d.ts +2 -2
  25. package/dist/commands/api-client.js +5 -5
  26. package/dist/commands/canvas-browse.js +1 -1
  27. package/dist/commands/node/create.js +3 -1
  28. package/dist/commands/node/inspect.js +1 -0
  29. package/dist/commands/node/lifecycle.js +1 -1
  30. package/dist/commands/node/message.js +6 -4
  31. package/dist/commands/node/wait.js +1 -1
  32. package/dist/commands/push.js +36 -37
  33. package/dist/commands/sys/daemon.js +21 -8
  34. package/dist/commands/sys/setup-core.js +2 -2
  35. package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
  36. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
  37. package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
  38. package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
  39. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +5 -1
  40. package/dist/core/__tests__/child-death-wake.test.js +11 -7
  41. package/dist/core/__tests__/collectible.test.d.ts +1 -0
  42. package/dist/core/__tests__/collectible.test.js +88 -0
  43. package/dist/core/__tests__/context-intro.test.js +4 -41
  44. package/dist/core/__tests__/daemon-boot.test.js +30 -14
  45. package/dist/core/__tests__/daemon-wedge.test.js +25 -19
  46. package/dist/core/__tests__/fixtures/fake-engine.js +11 -0
  47. package/dist/core/__tests__/human-deliver.test.js +5 -5
  48. package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
  49. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
  50. package/dist/core/__tests__/integration/revive.test.js +48 -0
  51. package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
  52. package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
  53. package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
  54. package/dist/core/__tests__/migration.test.js +30 -0
  55. package/dist/core/__tests__/passive-subscription.test.js +39 -1
  56. package/dist/core/__tests__/prune.test.js +12 -3
  57. package/dist/core/__tests__/relaunch-root.test.js +1 -1
  58. package/dist/core/__tests__/respawn-throttle.test.js +16 -146
  59. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
  60. package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
  61. package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
  62. package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
  63. package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
  64. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
  65. package/dist/core/canvas/canvas.d.ts +9 -15
  66. package/dist/core/canvas/canvas.js +41 -47
  67. package/dist/core/canvas/crons.d.ts +5 -8
  68. package/dist/core/canvas/crons.js +5 -8
  69. package/dist/core/canvas/history.d.ts +5 -0
  70. package/dist/core/canvas/history.js +12 -1
  71. package/dist/core/canvas/migrations.js +58 -1
  72. package/dist/core/canvas/pid.d.ts +1 -1
  73. package/dist/core/canvas/pid.js +5 -2
  74. package/dist/core/canvas/types.d.ts +4 -0
  75. package/dist/core/feed/feed.d.ts +4 -0
  76. package/dist/core/feed/feed.js +13 -8
  77. package/dist/core/feed/inbox.d.ts +2 -1
  78. package/dist/core/feed/inbox.js +22 -3
  79. package/dist/core/git.d.ts +3 -0
  80. package/dist/core/git.js +3 -1
  81. package/dist/core/human/feedback-companion.js +1 -0
  82. package/dist/core/preview-registry.js +0 -1
  83. package/dist/core/review/companion.js +1 -0
  84. package/dist/core/review/realize.js +1 -0
  85. package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
  86. package/dist/core/runtime/broker/auth-reload.js +9 -3
  87. package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
  88. package/dist/core/runtime/broker/engine-drive.js +28 -23
  89. package/dist/core/runtime/broker/event-projection.d.ts +4 -0
  90. package/dist/core/runtime/broker/event-projection.js +43 -23
  91. package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
  92. package/dist/core/runtime/broker/fault-retry.js +94 -8
  93. package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
  94. package/dist/core/runtime/broker/frame-dispatch.js +4 -1
  95. package/dist/core/runtime/broker/inbox.js +4 -0
  96. package/dist/core/runtime/broker/rebind.js +18 -0
  97. package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
  98. package/dist/core/runtime/broker/turn-admission.js +31 -0
  99. package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
  100. package/dist/core/runtime/broker/turn-ignition.js +25 -15
  101. package/dist/core/runtime/broker.js +21 -2
  102. package/dist/core/runtime/close.d.ts +11 -9
  103. package/dist/core/runtime/close.js +23 -19
  104. package/dist/core/runtime/fault.d.ts +21 -0
  105. package/dist/core/runtime/fault.js +129 -19
  106. package/dist/core/runtime/fleet.d.ts +0 -6
  107. package/dist/core/runtime/host.d.ts +7 -1
  108. package/dist/core/runtime/host.js +20 -2
  109. package/dist/core/runtime/nodes.d.ts +5 -1
  110. package/dist/core/runtime/nodes.js +24 -1
  111. package/dist/core/runtime/placement.d.ts +6 -6
  112. package/dist/core/runtime/placement.js +15 -8
  113. package/dist/core/runtime/reset.js +2 -2
  114. package/dist/core/runtime/revive.js +40 -4
  115. package/dist/core/runtime/spawn.d.ts +3 -0
  116. package/dist/core/runtime/spawn.js +1 -0
  117. package/dist/core/runtime/warm-pool.d.ts +4 -0
  118. package/dist/core/runtime/warm-pool.js +2 -0
  119. package/dist/core/worktree.d.ts +4 -3
  120. package/dist/core/worktree.js +73 -27
  121. package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
  122. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
  123. package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
  124. package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
  125. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
  126. package/dist/daemon/api/handlers/bash-jobs.js +2 -0
  127. package/dist/daemon/api/handlers/broker-ops.js +23 -2
  128. package/dist/daemon/api/handlers/human.js +1 -1
  129. package/dist/daemon/api/handlers/messages.js +11 -2
  130. package/dist/daemon/api/handlers/nodes.js +13 -1
  131. package/dist/daemon/api/handlers/reports.js +14 -6
  132. package/dist/daemon/api/map.js +1 -0
  133. package/dist/daemon/api/server.d.ts +9 -12
  134. package/dist/daemon/api/server.js +30 -19
  135. package/dist/daemon/companion-retire.js +3 -9
  136. package/dist/daemon/cron/sinks.js +7 -2
  137. package/dist/daemon/crtrd.js +4 -1
  138. package/dist/daemon/fleet.d.ts +2 -16
  139. package/dist/daemon/fleet.js +63 -150
  140. package/dist/daemon/human/finish.js +2 -0
  141. package/dist/daemon/manage.d.ts +11 -0
  142. package/dist/daemon/manage.js +32 -3
  143. package/dist/daemon/messaging/node-message.js +7 -1
  144. package/dist/daemon/reconcilers/bash-deadline.js +2 -0
  145. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
  146. package/dist/daemon/reconcilers/broker-supervision.js +22 -9
  147. package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
  148. package/dist/daemon/reconcilers/live-obligation.js +18 -6
  149. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
  150. package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
  151. package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
  152. package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
  153. package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
  154. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
  155. package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
  156. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  157. package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
  158. package/dist/pi-extensions/canvas-context-intro.js +4 -76
  159. package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
  160. package/dist/pi-extensions/canvas-review-boundary.js +4 -29
  161. package/dist/pi-extensions/canvas-stophook.js +1 -6
  162. package/dist/shared/birth-announcement.d.ts +12 -0
  163. package/dist/shared/birth-announcement.js +25 -0
  164. package/dist/shared/generated-context.d.ts +8 -4
  165. package/dist/shared/generated-context.js +15 -9
  166. package/package.json +4 -4
  167. package/runtime.lock.json +2 -2
  168. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
  169. package/dist/pi-extensions/truncate.d.ts +0 -5
  170. package/dist/pi-extensions/truncate.js +0 -14
@@ -1,4 +1,4 @@
1
- import { hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, subscribersOf, } from '../../core/canvas/index.js';
1
+ import { newestReportRef, hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPendingCancelOnWakeCron, subscribersOf, } from '../../core/canvas/index.js';
2
2
  import { fullName } from '../../core/canvas/labels.js';
3
3
  import { countTickets } from '../../core/canvas/attention.js';
4
4
  import { hasUnseenWakeMail, readCursor, readInboxSince } from '../../core/feed/inbox.js';
@@ -33,22 +33,34 @@ export function hasPendingWake(nodeId) {
33
33
  export function parkEvent(nodeId, lifecycle) {
34
34
  if (hasLiveObligation(nodeId))
35
35
  return 'release';
36
+ if (lifecycle === 'resident')
37
+ return hasUnseenWakeMail(nodeId) ? 'release' : 'park';
36
38
  if (readInboxSince(nodeId, readCursor(nodeId)).length > 0)
37
39
  return 'release';
38
- if (lifecycle === 'resident')
39
- return 'park';
40
40
  return hasLiveSubscriber(nodeId) ? 'release' : 'cancel';
41
41
  }
42
42
  /** Apply an unattended decision and notify subscribers of automatic completion.
43
43
  * This is the DEGRADED enactment: no summary turn ran, so subscribers get the
44
- * doctrine wake instead of the node's own report. The live clock's good path
44
+ * doctrine notice instead of the node's own report. The live clock's good path
45
45
  * (broker settlement after a parking turn) enacts `park` directly and fans
46
- * nothing — the node's report is the notice. */
46
+ * nothing — the node's report is the notice.
47
+ *
48
+ * The notice is DEFERRED: a node settling quietly at the end of its unattended
49
+ * grace is not an event worth starting a manager's cycle for, so it rides the
50
+ * manager's next natural cycle and arrives alongside whatever does.
51
+ *
52
+ * Only `park` fans, and only a resident parks, so the notice speaks about a
53
+ * resident throughout: its conversation ended without a final report, its state
54
+ * lives in whatever report it did leave, and a message reopens it. */
47
55
  export function applyParkEvent(nodeId, event) {
48
56
  const node = event === 'cancel'
49
57
  ? transition(nodeId, event, { reason: 'stranded' })
50
58
  : transition(nodeId, event);
51
59
  if (event !== 'park')
52
60
  return;
53
- fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child done — ${fullName(node)} (${nodeId}) was automatically marked done after its unattended grace with nothing left to wake it. It sent no final report.`, { reason: 'child-auto-done', child: nodeId });
61
+ const ref = newestReportRef(nodeId);
62
+ const pointer = ref === null
63
+ ? `It left no report behind; \`crtr node inspect show ${nodeId}\` shows where it stands.`
64
+ : `Its latest report is where it left things: \`crtr canvas history read ${ref}\`.`;
65
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), `Resident concluded — ${fullName(node)} (${nodeId}) had nothing left to wake it through its unattended grace, so its conversation was concluded. It submitted no final report. ${pointer} A message to it reopens it on a fresh cycle.`, { reason: 'child-auto-done', child: nodeId }, 'deferred');
54
66
  }
@@ -1,5 +1,16 @@
1
- import type { NodeRow } from '../../../core/canvas/index.js';
2
- /** Whether a durable backoff still holds this row back from a relaunch. A row
3
- * in backoff holds no slot and is excluded from the saturation figure — it is
4
- * waiting on a clock, not on capacity. */
1
+ import { type NodeRow } from '../../../core/canvas/index.js';
2
+ export declare const MAX_RESPAWN_ATTEMPTS = 6;
3
+ export declare function respawnBackoffMs(consecutiveFailures: number): number;
4
+ export interface RespawnAttempt {
5
+ failures: number;
6
+ notBefore: string | null;
7
+ exhausted: boolean;
8
+ }
9
+ /** Whether a durable backoff still holds this row back from a relaunch. */
5
10
  export declare function isInBackoff(row: NodeRow, now: number): boolean;
11
+ /** The only respawn-failure policy: progress since launch clears the sequence;
12
+ * every other missing-engine observation advances it and records its backoff. */
13
+ export declare function decideRespawnAttempt(row: Pick<NodeRow, 'last_settled_at' | 'launched_at' | 'respawn_failures'>, now: number): RespawnAttempt;
14
+ /** Persist the policy decision through the daemon-only canvas writer. */
15
+ export declare function recordRespawnPolicy(row: NodeRow, now: number): RespawnAttempt;
16
+ export declare function clearRespawnPolicy(nodeId: string): void;
@@ -1,6 +1,13 @@
1
- /** Whether a durable backoff still holds this row back from a relaunch. A row
2
- * in backoff holds no slot and is excluded from the saturation figure — it is
3
- * waiting on a clock, not on capacity. */
1
+ import { recordRespawnAttempt } from '../../../core/canvas/index.js';
2
+ export const MAX_RESPAWN_ATTEMPTS = 6;
3
+ const RESPAWN_BACKOFF_BASE_MS = 5_000;
4
+ const RESPAWN_BACKOFF_MAX_MS = 10 * 60_000;
5
+ export function respawnBackoffMs(consecutiveFailures) {
6
+ if (consecutiveFailures <= 0)
7
+ return 0;
8
+ return Math.min(RESPAWN_BACKOFF_BASE_MS * 2 ** (consecutiveFailures - 1), RESPAWN_BACKOFF_MAX_MS);
9
+ }
10
+ /** Whether a durable backoff still holds this row back from a relaunch. */
4
11
  export function isInBackoff(row, now) {
5
12
  const notBefore = row.respawn_not_before;
6
13
  if (notBefore == null)
@@ -8,3 +15,27 @@ export function isInBackoff(row, now) {
8
15
  const at = Date.parse(notBefore);
9
16
  return Number.isFinite(at) && now < at;
10
17
  }
18
+ /** The only respawn-failure policy: progress since launch clears the sequence;
19
+ * every other missing-engine observation advances it and records its backoff. */
20
+ export function decideRespawnAttempt(row, now) {
21
+ const settledAt = row.last_settled_at == null ? Number.NaN : Date.parse(row.last_settled_at);
22
+ const launchedAt = row.launched_at == null ? Number.NaN : Date.parse(row.launched_at);
23
+ if (Number.isFinite(settledAt) && Number.isFinite(launchedAt) && settledAt > launchedAt) {
24
+ return { failures: 0, notBefore: null, exhausted: false };
25
+ }
26
+ const failures = (row.respawn_failures ?? 0) + 1;
27
+ return {
28
+ failures,
29
+ notBefore: new Date(now + respawnBackoffMs(failures)).toISOString(),
30
+ exhausted: failures >= MAX_RESPAWN_ATTEMPTS,
31
+ };
32
+ }
33
+ /** Persist the policy decision through the daemon-only canvas writer. */
34
+ export function recordRespawnPolicy(row, now) {
35
+ const attempt = decideRespawnAttempt(row, now);
36
+ recordRespawnAttempt(row.node_id, { failures: attempt.failures, notBefore: attempt.notBefore });
37
+ return attempt;
38
+ }
39
+ export function clearRespawnPolicy(nodeId) {
40
+ recordRespawnAttempt(nodeId, { failures: 0, notBefore: null });
41
+ }
@@ -1,2 +1,5 @@
1
1
  import type { NodeRow } from '../../../core/canvas/index.js';
2
- export declare function handleTerminatingRow(row: NodeRow): void;
2
+ /** Request teardown only while a terminal row still has a daemon handle or a
3
+ * provably live recorded process. An indeterminate pid is left for the next
4
+ * tick: uncertainty never authorizes a signal. */
5
+ export declare function handleTerminatingRow(row: NodeRow, hasFleetHandle: boolean): boolean;
@@ -1,3 +1,12 @@
1
- export function handleTerminatingRow(row) {
2
- void row;
1
+ import { recordedPidLiveness } from '../../../core/canvas/pid.js';
2
+ import { requestBrokerTeardown } from '../../../core/runtime/host.js';
3
+ /** Request teardown only while a terminal row still has a daemon handle or a
4
+ * provably live recorded process. An indeterminate pid is left for the next
5
+ * tick: uncertainty never authorizes a signal. */
6
+ export function handleTerminatingRow(row, hasFleetHandle) {
7
+ if (!hasFleetHandle && (row.pi_pid == null || recordedPidLiveness(row.pi_pid, row.pi_pid_identity) !== 'alive')) {
8
+ return false;
9
+ }
10
+ requestBrokerTeardown(row.node_id);
11
+ return true;
3
12
  }
@@ -59,18 +59,21 @@ export class NodeLifecycleReconciler {
59
59
  }
60
60
  async classifyRow(now, row, ctx, candidates) {
61
61
  const id = row.node_id;
62
+ if (isTerminal(row)) {
63
+ if (handleTerminatingRow(row, ctx.fleet.has(id)))
64
+ return;
65
+ if (row.pi_pid != null && recordedPidLiveness(row.pi_pid, row.pi_pid_identity) === 'dead') {
66
+ clearRecordedPid(id);
67
+ }
68
+ if (row.frozen_at != null)
69
+ candidates.push({ row, enact: 'thaw' });
70
+ return;
71
+ }
62
72
  // 1. A fleet handle IS execution. Whatever froze this row is over.
63
73
  if (ctx.fleet.has(id)) {
64
74
  this.thawMark(row);
65
75
  return;
66
76
  }
67
- if (isTerminal(row)) {
68
- if (row.pi_pid != null)
69
- handleTerminatingRow(row);
70
- else if (row.frozen_at != null)
71
- candidates.push({ row, enact: 'thaw' });
72
- return;
73
- }
74
77
  // A paused profile launches nothing, and its stuck-delivery episode is over
75
78
  // rather than merely stalled — the pause is not a delivery failure.
76
79
  if (isProfilePaused(row.profile_id)) {
@@ -101,6 +104,10 @@ export class NodeLifecycleReconciler {
101
104
  // its way to the launch seam, which owns its own launch and must be left
102
105
  // alone rather than raced or written off as a boot failure.
103
106
  if (row.pi_pid == null) {
107
+ if (row.respawn_not_before != null) {
108
+ candidates.push({ row, enact: 'policy' });
109
+ return;
110
+ }
104
111
  if (row.frozen_at != null)
105
112
  candidates.push({ row, enact: 'thaw' });
106
113
  return;
@@ -13,8 +13,8 @@ export declare class StorageMaintenanceReconciler {
13
13
  /** Milliseconds since meta/telemetry was last written. Inbox activity is
14
14
  * deliberately excluded: children can keep writing a dead manager's inbox. */
15
15
  private msSinceDiskActivity;
16
- /** Reap only the physical placement residue of long-dead nodes. The durable
17
- * row, graph, and on-disk state remain available for a later manual revive. */
16
+ /** Reap only physical placement residue of terminal nodes. The durable row,
17
+ * graph, and on-disk state remain available for a later manual revive. */
18
18
  private reapDeadResidue;
19
19
  /** Delete focus rows whose recorded tmux pane no longer exists. A failed tmux
20
20
  * probe is "can't tell" and never authorizes deleting every viewport. */
@@ -66,12 +66,12 @@ export class StorageMaintenanceReconciler {
66
66
  }
67
67
  return latest === 0 ? Number.POSITIVE_INFINITY : now - latest;
68
68
  }
69
- /** Reap only the physical placement residue of long-dead nodes. The durable
70
- * row, graph, and on-disk state remain available for a later manual revive. */
69
+ /** Reap only physical placement residue of terminal nodes. The durable row,
70
+ * graph, and on-disk state remain available for a later manual revive. */
71
71
  reapDeadResidue(now) {
72
72
  let rows;
73
73
  try {
74
- rows = listNodes({ status: ['dead'] });
74
+ rows = listNodes({ status: ['done', 'canceled', 'dead'] });
75
75
  }
76
76
  catch (err) {
77
77
  operationIdContext.fresh(() => {
@@ -186,7 +186,7 @@ test('gh-451: finished does NOT shut down while a non-deferred inbox entry sits
186
186
  assert.equal(getNode('mgr')?.intent ?? null, null, 'no idle-release intent while a non-deferred entry is unseen');
187
187
  assert.equal(shutdown, false, 'pi stays alive so the in-process watcher can still deliver/commit');
188
188
  });
189
- test('gh-451: a DEFERRED-only unseen entry does not block idle-release (matches the daemon\'s own wake gate)', { timeout: 1_000 }, async () => {
189
+ test('a deferred send to a TERMINAL node was raised on append, so it holds the settle instead of riding a later cycle', { timeout: 1_000 }, async () => {
190
190
  createNode(node('root', { parent: null, lifecycle: 'resident' }));
191
191
  createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
192
192
  createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
@@ -199,9 +199,9 @@ test('gh-451: a DEFERRED-only unseen entry does not block idle-release (matches
199
199
  let shutdown = false;
200
200
  const ctx = { shutdown: () => { shutdown = true; } };
201
201
  await settle(pi, stopEvent('still waiting on the worker'), ctx);
202
- assert.equal(getNode('mgr')?.intent, 'idle-release', 'a deferred-only unseen entry does not block release (#341 parity)');
203
- assert.equal(getNode('mgr')?.status, 'idle');
204
- assert.equal(shutdown, true, 'pi shuts down — deferred entries ride the next natural cycle, not this settle');
202
+ assert.equal(getNode('mgr')?.intent ?? null, null, 'a terminal node takes no reliable later cycle, so its raised entry blocks the release');
203
+ assert.equal(getNode('mgr')?.status, 'active', 'mgr stays active for the entry the watcher still owes it');
204
+ assert.equal(shutdown, false, 'pi stays alive so the in-process watcher can deliver it');
205
205
  });
206
206
  test('queued continuation finishes before natural-stop routing commits', { timeout: 1_000 }, async () => {
207
207
  createNode(node('leaf', { parent: null, lifecycle: 'terminal' }));
@@ -23,8 +23,9 @@ interface SessionEntryLike {
23
23
  }
24
24
  interface SessionStartCtxLike {
25
25
  sessionManager: {
26
- getEntries: () => SessionEntryLike[];
27
- getBranch?: (fromId?: string) => SessionEntryLike[];
26
+ /** Required by pi's `ReadonlySessionManager`: the active branch, which is
27
+ * the context the next turn actually sees. */
28
+ getBranch: (fromId?: string) => SessionEntryLike[];
28
29
  };
29
30
  }
30
31
  interface CustomMessageLike {
@@ -37,35 +38,12 @@ interface CustomMessageLike {
37
38
  nodeId: string;
38
39
  };
39
40
  }
40
- /** The message handed to a message renderer. `content` is normally the string we
41
- * sent, but pi types it as string-or-blocks, so we handle both. */
42
- interface RenderedMessageLike {
43
- customType: string;
44
- content: string | Array<{
45
- type: string;
46
- text?: string;
47
- }>;
48
- }
49
- /** Minimal structural match for pi-tui's `Component` (render + invalidate). A
50
- * plain object of this shape is a valid child for pi's Container. */
51
- interface ComponentLike {
52
- render: (width: number) => string[];
53
- invalidate: () => void;
54
- }
55
- /** Subset of pi's `Theme` we touch — `fg(color, text)` wraps text in ANSI. Used
56
- * defensively (falls back to plain text if absent). */
57
- interface ThemeLike {
58
- fg?: (color: string, text: string) => string;
59
- }
60
41
  interface PiLike {
61
42
  on: (event: 'session_start', handler: (event: unknown, ctx: SessionStartCtxLike) => void | Promise<void>) => void;
62
43
  sendMessage: (message: CustomMessageLike, options?: {
63
44
  deliverAs?: string;
64
45
  triggerTurn?: boolean;
65
46
  }) => void;
66
- registerMessageRenderer: (customType: string, renderer: (message: RenderedMessageLike, options: {
67
- expanded?: boolean;
68
- }, theme: ThemeLike) => ComponentLike | undefined) => void;
69
47
  }
70
48
  /** Build the full bearings baseline for a conversation without inherited
71
49
  * crouter context. Exported for testing. */
@@ -73,15 +51,6 @@ export declare function buildContextIntro(nodeId: string, target?: ExposureTarge
73
51
  /** Build the compact node-scoped update for a fork whose copied branch already
74
52
  * carries a full crouter baseline. Exported for testing. */
75
53
  export declare function buildForkContextIntro(nodeId: string): Promise<string>;
76
- /**
77
- * Renderer for `crtr-context` messages. Collapsed (default) shows a one-line
78
- * stub; expanded (Ctrl+O) shows the label + full body. Returns a plain object
79
- * matching pi's structural `Component` interface — no pi-tui import. Exported for
80
- * testing.
81
- */
82
- export declare function renderContextMessage(message: RenderedMessageLike, options: {
83
- expanded?: boolean;
84
- }, theme: ThemeLike): ComponentLike;
85
54
  /** Register new-context baseline assembly; a strict resume changes nothing. */
86
55
  export declare function registerCanvasContextIntro(pi: PiLike): void;
87
56
  export default registerCanvasContextIntro;
@@ -11,26 +11,16 @@
11
11
  // stamp and receives a new baseline. The preference snapshot and persona
12
12
  // acknowledgment persist only after Pi materializes that baseline.
13
13
  //
14
- // COLLAPSED BY DEFAULT: a `registerMessageRenderer` keyed to our customType
15
- // renders the block as a single one-line stub; the full body only appears when
16
- // the user expands tool output (Ctrl+O / `app.tools.expand`). pi drives this via
17
- // `CustomMessageComponent.setExpanded(toolOutputExpanded)`, so the same toggle
18
- // that expands tool results expands the bearings. The renderer returns a plain
19
- // object satisfying pi's structural `Component` interface ({ render, invalidate })
20
- // — no pi-tui class needed. The LLM always sees the full `content` regardless of
21
- // how it renders; the renderer is display-only.
22
- //
23
14
  // Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
24
15
  // crouter's own tsc build without a dep on the pi packages.
25
16
  import { envNodeId } from '../shared/env.js';
26
- import { CONTEXT_INTRO_CUSTOM_TYPE, formatCard, parseCard } from '../shared/generated-context.js';
17
+ import { CONTEXT_INTRO_CUSTOM_TYPE, formatCard } from '../shared/generated-context.js';
27
18
  import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
28
19
  import { buildContextBearingsFromState, buildForkBearingsFromState, buildProjectContextBlockForBroker, renderPreferencesFromState, } from '../core/runtime/broker-extension-render.js';
29
20
  import { acknowledgePersonaBaseline } from '../core/runtime/broker-persona-guidance.js';
30
21
  import { situationalContextEnvelope } from '../core/runtime/situational-context.js';
31
22
  import { clearContextExposureState, cloneTranscriptExposureState, emptyContextExposureState, exposureTarget, freezePreferenceSnapshot, hasTranscriptExposure, loadContextExposureState, replaceContextExposureState, saveContextExposureState, sharedContextExposureState, } from '../core/substrate/injected-store.js';
32
23
  import { clearSessionCache } from '../core/substrate/session-cache.js';
33
- import { truncateToWidth } from './truncate.js';
34
24
  /** The `customType` stamped on the injected session message. Used both to write
35
25
  * the entry and to detect it on resume (the idempotency guard). Defined beside
36
26
  * the block builder in `core/runtime/bearings.ts` because the warm-pool claim's
@@ -49,16 +39,7 @@ export async function buildForkContextIntro(nodeId) {
49
39
  const state = await brokerExtensionState(nodeId);
50
40
  return buildForkBearingsFromState(state, situationalContextEnvelope(nodeId) ?? '');
51
41
  }
52
- // Collapsed-by-default rendering
53
- /** Pull the plain text out of a custom message's content (string or blocks). */
54
- function messageText(message) {
55
- if (typeof message.content === 'string')
56
- return message.content;
57
- return message.content
58
- .filter((c) => c.type === 'text' && typeof c.text === 'string')
59
- .map((c) => c.text)
60
- .join('\n');
61
- }
42
+ // Message extraction
62
43
  /** Plain text of a session entry's content (string or text blocks), '' when
63
44
  * absent. Used by the idempotency guard to tell OUR own injected bearings
64
45
  * (which name our node id) from a SOURCE's bearings copied in by `--fork-from`. */
@@ -74,62 +55,9 @@ function entryText(e) {
74
55
  }
75
56
  return '';
76
57
  }
77
- function activeBranch(ctx) {
78
- return typeof ctx.sessionManager.getBranch === 'function'
79
- ? ctx.sessionManager.getBranch()
80
- : ctx.sessionManager.getEntries();
81
- }
82
- /** Hard-wrap a single logical line to `width` columns (content carries no ANSI).
83
- * Code-point aware so wide-string slicing never splits a surrogate pair; the
84
- * bearings prose is plain text, so code-point count == visible columns. */
85
- function wrapLine(line, width) {
86
- if (width <= 0)
87
- return [''];
88
- const chars = Array.from(line);
89
- if (chars.length <= width)
90
- return [line];
91
- const out = [];
92
- for (let i = 0; i < chars.length; i += width)
93
- out.push(chars.slice(i, i + width).join(''));
94
- return out;
95
- }
96
- /**
97
- * Renderer for `crtr-context` messages. Collapsed (default) shows a one-line
98
- * stub; expanded (Ctrl+O) shows the label + full body. Returns a plain object
99
- * matching pi's structural `Component` interface — no pi-tui import. Exported for
100
- * testing.
101
- */
102
- export function renderContextMessage(message, options, theme) {
103
- const expanded = options?.expanded === true;
104
- const paint = (color, text) => typeof theme?.fg === 'function' ? theme.fg(color, text) : text;
105
- return {
106
- render(width) {
107
- const w = typeof width === 'number' && width > 0 ? width : 80;
108
- if (!expanded) {
109
- // Truncate BEFORE painting so the ANSI wrapper never inflates the
110
- // measured width; an over-wide line aborts pi's entire TUI render.
111
- const stub = `[${CONTEXT_INTRO_CUSTOM_TYPE}] orienting bearings — ctrl+o to expand`;
112
- return [paint('dim', truncateToWidth(stub, w))];
113
- }
114
- const lines = [paint('customMessageLabel', truncateToWidth(`[${CONTEXT_INTRO_CUSTOM_TYPE}]`, w)), ''];
115
- const body = parseCard({ role: 'custom', customType: message.customType, content: messageText(message) })?.body ?? messageText(message);
116
- for (const raw of body.split('\n')) {
117
- for (const wrapped of wrapLine(raw, w))
118
- lines.push(paint('customMessageText', wrapped));
119
- }
120
- return lines;
121
- },
122
- invalidate() {
123
- /* stateless — nothing to clear */
124
- },
125
- };
126
- }
127
58
  // Extension
128
59
  /** Register new-context baseline assembly; a strict resume changes nothing. */
129
60
  export function registerCanvasContextIntro(pi) {
130
- // Collapse the block to a one-liner until the user expands tool output (Ctrl+O).
131
- // Harmless to register outside TUI mode (it's only consulted while rendering).
132
- pi.registerMessageRenderer(CONTEXT_INTRO_CUSTOM_TYPE, renderContextMessage);
133
61
  pi.on('session_start', async (event, ctx) => {
134
62
  const nodeId = envNodeId();
135
63
  if (nodeId === undefined || nodeId.trim() === '')
@@ -150,7 +78,7 @@ export function registerCanvasContextIntro(pi) {
150
78
  }
151
79
  // The active branch, rather than the whole session file, is the context the
152
80
  // next turn sees. A new cycle therefore receives its own current baseline.
153
- const contextEntries = activeBranch(ctx).filter((e) => e.type === 'custom_message' && e.customType === CONTEXT_INTRO_CUSTOM_TYPE);
81
+ const contextEntries = ctx.sessionManager.getBranch().filter((e) => e.type === 'custom_message' && e.customType === CONTEXT_INTRO_CUSTOM_TYPE);
154
82
  const ours = contextEntries.some((e) => e.details?.nodeId === nodeId || entryText(e).includes(nodeId));
155
83
  if (ours)
156
84
  return;
@@ -170,7 +98,7 @@ export function registerCanvasContextIntro(pi) {
170
98
  display: true,
171
99
  details: { nodeId },
172
100
  });
173
- const delivered = activeBranch(ctx).some((entry) => entry.type === 'custom_message'
101
+ const delivered = ctx.sessionManager.getBranch().some((entry) => entry.type === 'custom_message'
174
102
  && entry.customType === CONTEXT_INTRO_CUSTOM_TYPE
175
103
  && entry.details?.nodeId === nodeId
176
104
  && entryText(entry) === messageContent);
@@ -7,8 +7,9 @@ interface SessionEntryLike {
7
7
  }
8
8
  interface SessionStartCtxLike {
9
9
  sessionManager: {
10
- getEntries: () => SessionEntryLike[];
11
- getBranch?: (fromId?: string) => SessionEntryLike[];
10
+ /** Required by pi's `ReadonlySessionManager`: the active branch, which is
11
+ * the context the next turn actually sees. */
12
+ getBranch: (fromId?: string) => SessionEntryLike[];
12
13
  };
13
14
  }
14
15
  interface CustomMessageLike {
@@ -25,35 +26,13 @@ interface CustomMessageLike {
25
26
  kind: 'review' | 'page_feedback';
26
27
  };
27
28
  }
28
- interface RenderedMessageLike {
29
- customType: string;
30
- content: string | Array<{
31
- type: string;
32
- text?: string;
33
- }>;
34
- }
35
- interface ComponentLike {
36
- render: (width: number) => string[];
37
- invalidate: () => void;
38
- }
39
- interface ThemeLike {
40
- fg?: (color: string, text: string) => string;
41
- }
42
29
  interface PiLike {
43
30
  on: (event: 'session_start', handler: (event: unknown, ctx: SessionStartCtxLike) => void | Promise<void>) => void;
44
31
  sendMessage: (message: CustomMessageLike, options?: {
45
32
  deliverAs?: string;
46
33
  triggerTurn?: boolean;
47
34
  }) => void;
48
- registerMessageRenderer: (customType: string, renderer: (message: RenderedMessageLike, options: {
49
- expanded?: boolean;
50
- }, theme: ThemeLike) => ComponentLike | undefined) => void;
51
35
  }
52
- /** Render the marker as a single compact divider; its full content remains in
53
- * the message list for the model and generated-context presenters. */
54
- export declare function renderReviewBoundary(message: RenderedMessageLike, options: {
55
- expanded?: boolean;
56
- }, theme: ThemeLike): ComponentLike;
57
36
  /**
58
37
  * Register the review boundary marker. Companion nodes receive one boundary
59
38
  * marker on session_start that separates inherited context from the visible
@@ -7,30 +7,7 @@
7
7
  import { envNodeId } from '../shared/env.js';
8
8
  import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
9
9
  import { isReviewBoundary, REVIEW_BOUNDARY_CUSTOM_TYPE } from '../core/runtime/session-visibility.js';
10
- import { formatCard, parseCard } from '../shared/generated-context.js';
11
- import { truncateToWidth } from './truncate.js';
12
- // Collapsed-by-default rendering
13
- /** Render the marker as a single compact divider; its full content remains in
14
- * the message list for the model and generated-context presenters. */
15
- export function renderReviewBoundary(message, options, theme) {
16
- const paint = (color, text) => typeof theme?.fg === 'function' ? theme.fg(color, text) : text;
17
- const text = typeof message.content === 'string'
18
- ? message.content
19
- : message.content.filter((block) => block.type === 'text' && typeof block.text === 'string').map((block) => block.text).join('\n');
20
- const body = parseCard({ role: 'custom', customType: message.customType, content: text })?.body ?? text;
21
- return {
22
- render(width) {
23
- const w = typeof width === 'number' && width > 0 ? width : 80;
24
- const divider = paint('dim', truncateToWidth('[review boundary] earlier conversation is not shown', w));
25
- if (options?.expanded !== true)
26
- return [divider];
27
- return [divider, '', ...body.split('\n').map((line) => paint('customMessageText', truncateToWidth(line, w)))];
28
- },
29
- invalidate() {
30
- /* stateless — nothing to clear */
31
- },
32
- };
33
- }
10
+ import { formatCard } from '../shared/generated-context.js';
34
11
  // Extension
35
12
  /**
36
13
  * Register the review boundary marker. Companion nodes receive one boundary
@@ -38,7 +15,6 @@ export function renderReviewBoundary(message, options, theme) {
38
15
  * transcript. Ordinary nodes get nothing.
39
16
  */
40
17
  export function registerCanvasReviewBoundary(pi) {
41
- pi.registerMessageRenderer(REVIEW_BOUNDARY_CUSTOM_TYPE, renderReviewBoundary);
42
18
  pi.on('session_start', async (_event, ctx) => {
43
19
  const nodeId = envNodeId();
44
20
  if (!nodeId || nodeId.trim() === '')
@@ -54,10 +30,9 @@ export function registerCanvasReviewBoundary(pi) {
54
30
  return;
55
31
  // The active branch, not the whole session file, is the context the next
56
32
  // turn sees. Check for an existing boundary marker on this branch.
57
- const scan = typeof ctx.sessionManager.getBranch === 'function'
58
- ? ctx.sessionManager.getBranch()
59
- : ctx.sessionManager.getEntries();
60
- const alreadyMarked = scan.some((entry) => isReviewBoundary(entry, node.review_binding?.review_id));
33
+ const alreadyMarked = ctx.sessionManager
34
+ .getBranch()
35
+ .some((entry) => isReviewBoundary(entry, node.review_binding?.review_id));
61
36
  if (alreadyMarked)
62
37
  return;
63
38
  const binding = node.review_binding;
@@ -94,12 +94,7 @@ function lastAssistantMessage(messages) {
94
94
  * Pi-owned fact; crtrd compares it with the node's daemon-owned review binding. */
95
95
  function currentReviewBoundaryIds(ctx) {
96
96
  try {
97
- const manager = ctx?.sessionManager;
98
- const entries = typeof manager?.getBranch === 'function'
99
- ? manager.getBranch()
100
- : typeof manager?.getEntries === 'function'
101
- ? manager.getEntries()
102
- : [];
97
+ const entries = ctx?.sessionManager?.getBranch();
103
98
  if (!Array.isArray(entries))
104
99
  return [];
105
100
  const ids = new Set();
@@ -0,0 +1,12 @@
1
+ /** The entry marker keeps delivery-time rendering exclusive to birth notices. */
2
+ export declare const BIRTH_ANNOUNCEMENT_MARKER = "crtr.birth-announcement.v1";
3
+ export declare const BIRTH_ANNOUNCEMENT_AGE_TOKEN = "[[CRTR_BIRTH_ANNOUNCEMENT_AGE]]";
4
+ export declare function birthAnnouncementBody(child: {
5
+ name: string;
6
+ nodeId: string;
7
+ kind: string;
8
+ creator: string;
9
+ }): string;
10
+ export declare function birthAnnouncementAge(createdAt: string, now?: number): string;
11
+ /** Return a birth notice's body rendered at delivery, or null for every other entry. */
12
+ export declare function renderBirthAnnouncementBody(data: Record<string, unknown> | undefined, createdAt: string, now?: number): string | null;
@@ -0,0 +1,25 @@
1
+ /** The entry marker keeps delivery-time rendering exclusive to birth notices. */
2
+ export const BIRTH_ANNOUNCEMENT_MARKER = 'crtr.birth-announcement.v1';
3
+ export const BIRTH_ANNOUNCEMENT_AGE_TOKEN = '[[CRTR_BIRTH_ANNOUNCEMENT_AGE]]';
4
+ export function birthAnnouncementBody(child) {
5
+ return `${child.name} (${child.nodeId}, ${child.kind}) was created under you by ${child.creator} ${BIRTH_ANNOUNCEMENT_AGE_TOKEN} ago. For your awareness only — no response or action is needed, and no need to mention it unless asked. Treat its future reports as background you already know about; investigate only if something genuinely needs you.`;
6
+ }
7
+ export function birthAnnouncementAge(createdAt, now = Date.now()) {
8
+ const elapsed = Math.max(0, now - Date.parse(createdAt));
9
+ if (elapsed < 60_000)
10
+ return 'moments';
11
+ const minutes = Math.floor(elapsed / 60_000);
12
+ if (minutes < 60)
13
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
14
+ const hours = Math.floor(elapsed / 3_600_000);
15
+ if (hours < 48)
16
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
17
+ const days = Math.floor(elapsed / 86_400_000);
18
+ return `${days} day${days === 1 ? '' : 's'}`;
19
+ }
20
+ /** Return a birth notice's body rendered at delivery, or null for every other entry. */
21
+ export function renderBirthAnnouncementBody(data, createdAt, now = Date.now()) {
22
+ if (data?.['birth_announcement'] !== BIRTH_ANNOUNCEMENT_MARKER || typeof data['body'] !== 'string')
23
+ return null;
24
+ return data['body'].replace(BIRTH_ANNOUNCEMENT_AGE_TOKEN, birthAnnouncementAge(createdAt, now));
25
+ }
@@ -10,10 +10,14 @@ export declare const REVIEW_BOUNDARY_CUSTOM_TYPE = "crtr-review-boundary";
10
10
  export declare const STALL_REPROMPT: string;
11
11
  /** The daemon's parking mandate: the last turn of a conversation the unattended
12
12
  * clock is concluding. Delivered live, wrapped in a `park` runtime card. It
13
- * shares a CONTRACT with `node yield`'s pre-invocation guide (roadmap current,
14
- * short and shrinking; context dir for in-progress material; memory only for
15
- * gated permanent lessons) but not a string: yield is read by an agent choosing
16
- * to refresh, this by an agent being told to conclude. */
13
+ * governs both the node's durable inheritance and reader-facing output:
14
+ * one update for subscribers and history, then a goodbye that does not narrate
15
+ * the parking.
16
+ *
17
+ * The durable inheritance shares a CONTRACT with `node yield`'s pre-invocation
18
+ * guide (roadmap current, short and shrinking; context dir for in-progress
19
+ * material; memory only for gated permanent lessons) but not a string: yield is
20
+ * read by an agent choosing to refresh, this by an agent being told to conclude. */
17
21
  export declare const PARK_SUMMARY_PROMPT: string;
18
22
  /** Static recovery prompts shared by the broker producer and display classifier. */
19
23
  export declare const AUTH_FAULT_RECOVERY_BODY = "Provider credentials were just updated (a new login landed). Your previous turn stopped on a provider authentication failure. Continue from where you left off and retry the work that failed.";