@north-light/crouter 0.3.221 → 0.3.223

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 (169) hide show
  1. package/dist/api/client.d.ts +21 -1
  2. package/dist/api/client.js +34 -0
  3. package/dist/api/dto/chat-inventory.d.ts +13 -0
  4. package/dist/api/dto/human-requests.d.ts +88 -0
  5. package/dist/api/dto/human-requests.js +4 -0
  6. package/dist/api/dto/human.d.ts +3 -0
  7. package/dist/api/dto/reviews.d.ts +2 -0
  8. package/dist/api/index.d.ts +1 -0
  9. package/dist/api/index.js +1 -0
  10. package/dist/api/routes.d.ts +7 -0
  11. package/dist/api/routes.js +10 -0
  12. package/dist/builtin-memory/00-runtime-base/00-authoring.md +31 -0
  13. package/dist/builtin-memory/00-runtime-base/01-escalation.md +14 -0
  14. package/dist/builtin-memory/{insights/listen.md → 00-runtime-base/02-insight-capture.md} +1 -0
  15. package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +27 -0
  16. package/dist/builtin-memory/{02-lifecycle/01-resident.md → 02-turn-lifecycle/02-resident.md} +5 -0
  17. package/dist/builtin-memory/04-base-worker.md +4 -8
  18. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  19. package/dist/builtin-memory/05-kinds/advisor/01-orchestrator.md +1 -0
  20. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +1 -0
  21. package/dist/builtin-memory/05-kinds/design/00-base.md +2 -1
  22. package/dist/builtin-memory/05-kinds/design/01-orchestrator.md +2 -1
  23. package/dist/builtin-memory/05-kinds/design/design-contract.md +19 -0
  24. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -0
  25. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +1 -0
  26. package/dist/builtin-memory/05-kinds/explore/00-base.md +1 -0
  27. package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +1 -0
  28. package/dist/builtin-memory/05-kinds/general/00-base.md +1 -0
  29. package/dist/builtin-memory/05-kinds/plan/00-base.md +2 -1
  30. package/dist/builtin-memory/05-kinds/plan/01-orchestrator.md +2 -1
  31. package/dist/builtin-memory/05-kinds/plan/plan-contract.md +28 -0
  32. package/dist/builtin-memory/05-kinds/plan/reviewers/architecture-fit.md +1 -0
  33. package/dist/builtin-memory/05-kinds/plan/reviewers/code-smells.md +1 -0
  34. package/dist/builtin-memory/05-kinds/plan/reviewers/lens-contract.md +1 -0
  35. package/dist/builtin-memory/05-kinds/plan/reviewers/pattern-consistency.md +1 -0
  36. package/dist/builtin-memory/05-kinds/plan/reviewers/requirements-coverage.md +1 -0
  37. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +1 -0
  38. package/dist/builtin-memory/05-kinds/review/00-base.md +1 -0
  39. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -0
  40. package/dist/builtin-memory/05-kinds/review/companion/00-base.md +1 -0
  41. package/dist/builtin-memory/05-kinds/review/security-findings.md +1 -0
  42. package/dist/builtin-memory/05-kinds/spec/00-base.md +4 -3
  43. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -0
  44. package/dist/builtin-memory/05-kinds/spec/requirements.md +1 -0
  45. package/dist/builtin-memory/design/guide.md +35 -0
  46. package/dist/builtin-memory/design/roadmap.md +21 -0
  47. package/dist/builtin-memory/insights/capture.md +1 -1
  48. package/dist/builtin-memory/internal/memory-loading.md +4 -4
  49. package/dist/builtin-memory/internal/plugins.md +10 -1
  50. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  51. package/dist/builtin-memory/plan/roadmap.md +6 -28
  52. package/dist/builtin-memory/spec/guide.md +19 -8
  53. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +28 -15
  54. package/dist/clients/attach/render/markdown-source.js +106 -1
  55. package/dist/clients/attach/session/file-links.d.ts +13 -4
  56. package/dist/clients/attach/session/file-links.js +54 -58
  57. package/dist/clients/attach/viewer.js +545 -543
  58. package/dist/clients/inbox/controller.js +1 -1
  59. package/dist/clients/inbox/page-adapter.js +2 -1
  60. package/dist/clients/inbox/resolve.d.ts +1 -0
  61. package/dist/clients/inbox/review/review-client.js +3 -1
  62. package/dist/clients/inbox/tui/panel.js +23 -4
  63. package/dist/clients/inbox/tui/render.js +6 -0
  64. package/dist/clients/inbox/tui/types.d.ts +8 -0
  65. package/dist/commands/__tests__/human.test.js +2 -2
  66. package/dist/commands/human/request.d.ts +2 -0
  67. package/dist/commands/human/request.js +281 -0
  68. package/dist/commands/human.js +5 -2
  69. package/dist/commands/memory/shared.d.ts +2 -2
  70. package/dist/commands/memory/shared.js +11 -6
  71. package/dist/commands/pkg/browse/doc-view.js +2 -0
  72. package/dist/commands/sys/config.js +2 -2
  73. package/dist/commands/sys/doctor.js +54 -2
  74. package/dist/core/__tests__/broker-extension-canvas-db-boundary.test.js +7 -4
  75. package/dist/core/__tests__/fixtures/memory-slash-live-probe.d.ts +1 -0
  76. package/dist/core/__tests__/fixtures/memory-slash-live-probe.js +71 -0
  77. package/dist/core/__tests__/human-action-delivery.test.d.ts +1 -0
  78. package/dist/core/__tests__/human-action-delivery.test.js +140 -0
  79. package/dist/core/__tests__/human-actions.test.d.ts +1 -0
  80. package/dist/core/__tests__/human-actions.test.js +116 -0
  81. package/dist/core/__tests__/inline-memory-refs.test.js +1 -1
  82. package/dist/core/__tests__/profile-project-memory-delivery.test.js +70 -3
  83. package/dist/core/__tests__/prospective-inventory-capability-parity.test.d.ts +1 -0
  84. package/dist/core/__tests__/prospective-inventory-capability-parity.test.js +91 -0
  85. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.d.ts +1 -0
  86. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +127 -0
  87. package/dist/core/__tests__/seam/prospective-inventory-stdout.test.d.ts +1 -0
  88. package/dist/core/__tests__/seam/prospective-inventory-stdout.test.js +31 -0
  89. package/dist/core/canvas/db.js +23 -0
  90. package/dist/core/canvas/human-deliveries.d.ts +53 -0
  91. package/dist/core/canvas/human-deliveries.js +75 -0
  92. package/dist/core/config.d.ts +13 -1
  93. package/dist/core/config.js +51 -1
  94. package/dist/core/feed/inbox.d.ts +6 -0
  95. package/dist/core/feed/inbox.js +9 -1
  96. package/dist/core/human/action-binding.d.ts +21 -0
  97. package/dist/core/human/action-binding.js +40 -0
  98. package/dist/core/human/completion.d.ts +38 -0
  99. package/dist/core/human/completion.js +27 -0
  100. package/dist/core/human/convention.d.ts +2 -0
  101. package/dist/core/human/convention.js +2 -0
  102. package/dist/core/human/tickets.d.ts +25 -6
  103. package/dist/core/human/tickets.js +19 -13
  104. package/dist/core/human/types.d.ts +5 -0
  105. package/dist/core/human-actions.d.ts +25 -0
  106. package/dist/core/human-actions.js +101 -0
  107. package/dist/core/memory-resolver.js +1 -1
  108. package/dist/core/profiles/select.d.ts +2 -0
  109. package/dist/core/profiles/select.js +21 -4
  110. package/dist/core/runtime/broker/frame-dispatch.js +2 -5
  111. package/dist/core/runtime/broker-inventory.d.ts +1 -2
  112. package/dist/core/runtime/broker-inventory.js +2 -77
  113. package/dist/core/runtime/broker-persona-guidance.js +1 -1
  114. package/dist/core/runtime/broker.js +4 -4
  115. package/dist/core/runtime/chat-inventory-rows.d.ts +8 -0
  116. package/dist/core/runtime/chat-inventory-rows.js +105 -0
  117. package/dist/core/runtime/command-surface.d.ts +8 -3
  118. package/dist/core/runtime/command-surface.js +42 -6
  119. package/dist/core/runtime/launch-target.d.ts +25 -0
  120. package/dist/core/runtime/launch-target.js +54 -0
  121. package/dist/core/runtime/persona.js +3 -3
  122. package/dist/core/runtime/prospective-inventory-cli.d.ts +1 -0
  123. package/dist/core/runtime/prospective-inventory-cli.js +61 -0
  124. package/dist/core/runtime/prospective-inventory.d.ts +10 -0
  125. package/dist/core/runtime/prospective-inventory.js +88 -0
  126. package/dist/core/runtime/spawn.d.ts +3 -1
  127. package/dist/core/runtime/spawn.js +5 -3
  128. package/dist/core/substrate/frontmatter-validation.d.ts +2 -5
  129. package/dist/core/substrate/frontmatter-validation.js +8 -4
  130. package/dist/core/substrate/gate.d.ts +4 -1
  131. package/dist/core/substrate/gate.js +5 -0
  132. package/dist/core/substrate/on-read.js +21 -32
  133. package/dist/core/substrate/render-node.d.ts +3 -2
  134. package/dist/core/substrate/render-node.js +3 -2
  135. package/dist/core/substrate/render.js +64 -37
  136. package/dist/core/substrate/schema.d.ts +17 -8
  137. package/dist/core/substrate/schema.js +14 -10
  138. package/dist/core/substrate/surface-match.d.ts +8 -7
  139. package/dist/core/substrate/surface-match.js +15 -14
  140. package/dist/core/user-settings.d.ts +4 -0
  141. package/dist/core/user-settings.js +1 -0
  142. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +52 -3
  143. package/dist/daemon/api/handlers/human-requests.d.ts +2 -0
  144. package/dist/daemon/api/handlers/human-requests.js +409 -0
  145. package/dist/daemon/api/handlers/human.js +3 -0
  146. package/dist/daemon/api/handlers/inbox.js +3 -0
  147. package/dist/daemon/api/handlers/nodes.d.ts +1 -3
  148. package/dist/daemon/api/handlers/nodes.js +11 -46
  149. package/dist/daemon/api/handlers/prospective-chat-inventory.d.ts +2 -0
  150. package/dist/daemon/api/handlers/prospective-chat-inventory.js +59 -0
  151. package/dist/daemon/api/handlers/reviews.js +10 -2
  152. package/dist/daemon/api/server.js +4 -0
  153. package/dist/daemon/crtrd.js +6 -0
  154. package/dist/daemon/human/deliver-action.d.ts +16 -0
  155. package/dist/daemon/human/deliver-action.js +168 -0
  156. package/dist/daemon/human/finish.d.ts +8 -5
  157. package/dist/daemon/human/finish.js +45 -6
  158. package/dist/daemon/human/sweep.js +4 -1
  159. package/dist/daemon/reconcilers/human-delivery-lane.d.ts +10 -0
  160. package/dist/daemon/reconcilers/human-delivery-lane.js +41 -0
  161. package/dist/daemon/review/finish.d.ts +8 -3
  162. package/dist/daemon/review/finish.js +19 -1
  163. package/dist/types.d.ts +8 -0
  164. package/dist/types.js +1 -0
  165. package/package.json +1 -1
  166. package/runtime.lock.json +2 -2
  167. package/dist/builtin-memory/00-runtime-base.md +0 -55
  168. package/dist/builtin-memory/design.md +0 -55
  169. /package/dist/builtin-memory/{02-lifecycle/00-terminal.md → 02-turn-lifecycle/01-terminal.md} +0 -0
@@ -0,0 +1,59 @@
1
+ import { ApiError } from '../../../api/errors.js';
2
+ import { usage } from '../../../core/errors.js';
3
+ import { resolveLaunchTarget } from '../../../core/runtime/launch-target.js';
4
+ import { runProspectiveInventory } from '../../../core/runtime/prospective-inventory.js';
5
+ const inFlight = new Map();
6
+ function singleQueryValue(ctx, name) {
7
+ const values = ctx.query.getAll(name);
8
+ if (values.length > 1)
9
+ throw usage(`${name} must appear at most once`);
10
+ return values[0];
11
+ }
12
+ async function handleGet(ctx) {
13
+ for (const [name] of ctx.query) {
14
+ if (name !== 'profile' && name !== 'cwd' && name !== 'kind')
15
+ throw usage(`unknown prospective inventory query parameter: ${name}`);
16
+ }
17
+ const profile = singleQueryValue(ctx, 'profile');
18
+ const cwd = singleQueryValue(ctx, 'cwd');
19
+ const kind = singleQueryValue(ctx, 'kind');
20
+ if (kind !== undefined && kind.trim() === '')
21
+ throw usage('kind must be a non-empty string when provided');
22
+ const target = await resolveLaunchTarget({
23
+ root: true,
24
+ ...(profile !== undefined ? { profile } : {}),
25
+ ...(cwd !== undefined ? { cwd } : {}),
26
+ ...(kind !== undefined ? { kind } : {}),
27
+ }, { readOnlyProfileSelection: true });
28
+ const key = JSON.stringify([target.profileId, target.cwd, target.kind]);
29
+ let pending = inFlight.get(key);
30
+ if (pending === undefined) {
31
+ pending = runProspectiveInventory({
32
+ cwd: target.cwd,
33
+ profileId: target.profileId,
34
+ extensions: target.launch.extensions,
35
+ });
36
+ inFlight.set(key, pending);
37
+ void pending.finally(() => {
38
+ if (inFlight.get(key) === pending)
39
+ inFlight.delete(key);
40
+ }).catch(() => { });
41
+ }
42
+ let rows;
43
+ try {
44
+ rows = await pending;
45
+ }
46
+ catch {
47
+ throw new ApiError(503, 'preflight_failed', 'prospective chat inventory preflight failed');
48
+ }
49
+ const body = {
50
+ profile_id: target.profileId,
51
+ cwd: target.cwd,
52
+ commands: rows.commands,
53
+ memory_refs: rows.memory_refs,
54
+ };
55
+ return { status: 200, body };
56
+ }
57
+ export const prospectiveChatInventoryRoutes = [
58
+ { method: 'GET', pattern: '/v1/prospective-chat-inventory', handler: handleGet },
59
+ ];
@@ -14,7 +14,7 @@ import { toFeedbackResultDTO, toReviewDTO } from '../map.js';
14
14
  import { invalidRequest as rejectRequest, objectBody, optionalString, requireParam, requiredString, } from './validate.js';
15
15
  const REVIEW_STATES = new Set(['open', 'approved', 'canceled']);
16
16
  const CREATE_FIELDS = ['origin_kind', 'origin_node_id', 'file', 'idempotency_key', 'subtitle', 'output_path'];
17
- const CANCEL_FIELDS = ['reason', 'actor'];
17
+ const CANCEL_FIELDS = ['reason', 'actor', 'disposition'];
18
18
  function invalidRequest(violations) {
19
19
  rejectRequest('review request', violations);
20
20
  }
@@ -131,7 +131,15 @@ function parseCancel(ctx, violations) {
131
131
  const body = ctx.body === undefined ? {} : objectBody(ctx.body, CANCEL_FIELDS, violations);
132
132
  const reason = optionalString(body, 'reason', violations);
133
133
  const actor = optionalString(body, 'actor', violations);
134
- return { ...(reason === undefined ? {} : { reason }), ...(actor === undefined ? {} : { actor }) };
134
+ const disposition = optionalString(body, 'disposition', violations);
135
+ if (disposition !== undefined && disposition !== 'canceled' && disposition !== 'dismissed') {
136
+ violations.push({ field: 'disposition', problem: 'must be "canceled" or "dismissed"', received: disposition });
137
+ }
138
+ return {
139
+ ...(reason === undefined ? {} : { reason }),
140
+ ...(actor === undefined ? {} : { actor }),
141
+ ...(disposition === 'canceled' || disposition === 'dismissed' ? { disposition } : {}),
142
+ };
135
143
  }
136
144
  function requireEmptyBody(ctx, violations) {
137
145
  objectBody(ctx.body, [], violations);
@@ -28,6 +28,7 @@ import { fileRoutes } from './handlers/files.js';
28
28
  import { focusRoutes } from './handlers/focus.js';
29
29
  import { healthRoutes } from './handlers/health.js';
30
30
  import { humanRoutes } from './handlers/human.js';
31
+ import { humanRequestRoutes } from './handlers/human-requests.js';
31
32
  import { inboxRoutes } from './handlers/inbox.js';
32
33
  import { feedbackCommentRoutes } from './handlers/feedback-comments.js';
33
34
  import { memoryRoutes } from './handlers/memory.js';
@@ -35,6 +36,7 @@ import { messageRoutes } from './handlers/messages.js';
35
36
  import { modelAuthRoutes } from './handlers/modelauth.js';
36
37
  import { nodeRoutes } from './handlers/nodes.js';
37
38
  import { profileRoutes } from './handlers/profiles.js';
39
+ import { prospectiveChatInventoryRoutes } from './handlers/prospective-chat-inventory.js';
38
40
  import { reportRoutes } from './handlers/reports.js';
39
41
  import { reviewCommentRoutes } from './handlers/review-comments.js';
40
42
  import { reviewRoutes } from './handlers/reviews.js';
@@ -67,7 +69,9 @@ function buildRouter() {
67
69
  .registerAll(reviewRoutes)
68
70
  .registerAll(reviewCommentRoutes)
69
71
  .registerAll(inboxRoutes)
72
+ .registerAll(humanRequestRoutes)
70
73
  .registerAll(chatInventoryRoutes)
74
+ .registerAll(prospectiveChatInventoryRoutes)
71
75
  .registerAll(feedbackCommentRoutes);
72
76
  }
73
77
  /** Is this request an attach upgrade — `GET /v1/nodes/{id}/attach` — and if so,
@@ -50,6 +50,7 @@ import { crtrHome, isSafeNodeId } from '../core/canvas/paths.js';
50
50
  import { listNodes, migrateLegacyPidIdentities, } from '../core/canvas/index.js';
51
51
  import { createApiServer } from './api/server.js';
52
52
  import { reconcileUndeliveredTickets } from './human/sweep.js';
53
+ import { recoverRunningActionDeliveries } from '../core/canvas/human-deliveries.js';
53
54
  import { sweepReviews } from './review/sweep.js';
54
55
  import { pidfilePath } from './pidfile.js';
55
56
  import { rendererWarning } from '../core/runtime/package-health.js';
@@ -57,6 +58,7 @@ import { DEFAULT_INTERVAL_MS } from './supervise-cadence.js';
57
58
  import { BrokerSupervisionReconciler } from './reconcilers/broker-supervision.js';
58
59
  import { ControllerDeathReconciler, DormantInboxReconciler } from './reconcilers/dormant-inbox.js';
59
60
  import { CronLaneReconciler } from './reconcilers/cron-lane.js';
61
+ import { HumanDeliveryLaneReconciler } from './reconcilers/human-delivery-lane.js';
60
62
  import { PendingReviewSubmitReconciler } from './reconcilers/pending-review-submit.js';
61
63
  import { StorageMaintenanceReconciler } from './reconcilers/storage-maintenance.js';
62
64
  import { captureLivenessSnapshot, capturePidCommand, captureTeardownSnapshot, isPidAlive, killProcessTreePids, } from '../core/canvas/pid.js';
@@ -312,6 +314,7 @@ const brokerSupervision = new BrokerSupervisionReconciler();
312
314
  const controllerDeath = new ControllerDeathReconciler();
313
315
  const dormantInbox = new DormantInboxReconciler();
314
316
  const cronLane = new CronLaneReconciler();
317
+ const humanDeliveryLane = new HumanDeliveryLaneReconciler();
315
318
  const pendingReviewSubmit = new PendingReviewSubmitReconciler();
316
319
  const storageMaintenance = new StorageMaintenanceReconciler();
317
320
  /** Supervision tick over the daemon's current live set. */
@@ -336,6 +339,7 @@ export async function superviseTick(now = Date.now(), lifecycle = directTickLife
336
339
  controllerDeath.run(now, { rows });
337
340
  dormantInbox.run(now, { rows, fleet, hasBrokerCapacity });
338
341
  cronLane.run(now, { lifecycle, hasBrokerCapacity });
342
+ humanDeliveryLane.run(now, { lifecycle });
339
343
  pendingReviewSubmit.run({ lifecycle });
340
344
  storageMaintenance.run(now);
341
345
  }
@@ -652,6 +656,8 @@ export async function runDaemon(opts = {}) {
652
656
  await operationIdContext.fresh(async () => {
653
657
  await sweepReviews();
654
658
  await reconcileUndeliveredTickets();
659
+ // Unlike a cron one-shot, a completion is at-least-once: destinations deduplicate request IDs, so a dead daemon's running delivery is re-queued immediately.
660
+ recoverRunningActionDeliveries(Date.now());
655
661
  });
656
662
  // Boot reconciliation clears stale process identities before any tick can
657
663
  // inspect them, then migrates legacy identities only with proven same-boot
@@ -0,0 +1,16 @@
1
+ import { type ActionDeliveryRecord, type ActionDeliverySettlement } from '../../core/canvas/human-deliveries.js';
2
+ export declare const ACTION_DELIVERY_TIMEOUT_MS = 120000;
3
+ export declare const ACTION_DELIVERY_STDERR_TAIL_BYTES: number;
4
+ /** Durable exponential retry delay for the attempt that just failed. */
5
+ export declare function actionDeliveryBackoffMs(attempt: number): number;
6
+ export interface ActionDeliveryProcessOutcome {
7
+ exitCode: number | null;
8
+ signal: string | null;
9
+ timedOut: boolean;
10
+ spawnError?: Error;
11
+ stderr: string;
12
+ }
13
+ /** Process contract mapping. stdout deliberately does not participate. */
14
+ export declare function actionDeliverySettlementForOutcome(outcome: ActionDeliveryProcessOutcome, attempt: number, now: number): ActionDeliverySettlement;
15
+ /** Spawn and settle one already-claimed action delivery. */
16
+ export declare function deliverAction(delivery: ActionDeliveryRecord, claimOwner: string): Promise<void>;
@@ -0,0 +1,168 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { settleActionDelivery, } from '../../core/canvas/human-deliveries.js';
3
+ import { killProcessGroup } from '../../core/canvas/pid.js';
4
+ import { buildOperationalEnvBase } from '../../core/runtime/spawn-env.js';
5
+ export const ACTION_DELIVERY_TIMEOUT_MS = 120_000;
6
+ const ACTION_DELIVERY_KILL_GRACE_MS = 2_000;
7
+ export const ACTION_DELIVERY_STDERR_TAIL_BYTES = 4 * 1024;
8
+ /** Durable exponential retry delay for the attempt that just failed. */
9
+ export function actionDeliveryBackoffMs(attempt) {
10
+ return Math.min(5_000 * 2 ** (attempt - 1), 3_600_000);
11
+ }
12
+ /** Process contract mapping. stdout deliberately does not participate. */
13
+ export function actionDeliverySettlementForOutcome(outcome, attempt, now) {
14
+ if (outcome.spawnError !== undefined) {
15
+ return {
16
+ state: 'pending',
17
+ nextAttemptAt: now + actionDeliveryBackoffMs(attempt),
18
+ failure: failure('spawn_error', { message: outcome.spawnError.message, stderr: outcome.stderr }),
19
+ };
20
+ }
21
+ if (outcome.timedOut) {
22
+ return {
23
+ state: 'pending',
24
+ nextAttemptAt: now + actionDeliveryBackoffMs(attempt),
25
+ // The kill escalation's own signal distinguishes the graceful term from the SIGKILL.
26
+ failure: failure('timeout', { ...(outcome.signal === null ? {} : { signal: outcome.signal }), stderr: outcome.stderr }),
27
+ };
28
+ }
29
+ if (outcome.signal !== null) {
30
+ return {
31
+ state: 'pending',
32
+ nextAttemptAt: now + actionDeliveryBackoffMs(attempt),
33
+ failure: failure('signal', { signal: outcome.signal, stderr: outcome.stderr }),
34
+ };
35
+ }
36
+ if (outcome.exitCode === 0)
37
+ return { state: 'accepted' };
38
+ if (outcome.exitCode === 78) {
39
+ return { state: 'permanent_failed', failure: failure('exit', { exitCode: 78, stderr: outcome.stderr }) };
40
+ }
41
+ return {
42
+ state: 'pending',
43
+ nextAttemptAt: now + actionDeliveryBackoffMs(attempt),
44
+ failure: failure('exit', { exitCode: outcome.exitCode ?? undefined, stderr: outcome.stderr }),
45
+ };
46
+ }
47
+ function failure(kind, fields) {
48
+ return { kind, ...fields };
49
+ }
50
+ function stderrTailCapture() {
51
+ let tail = Buffer.alloc(0);
52
+ return {
53
+ take(chunk) {
54
+ tail = chunk.length >= ACTION_DELIVERY_STDERR_TAIL_BYTES
55
+ ? chunk.subarray(chunk.length - ACTION_DELIVERY_STDERR_TAIL_BYTES)
56
+ : Buffer.concat([tail, chunk]).subarray(Math.max(0, tail.length + chunk.length - ACTION_DELIVERY_STDERR_TAIL_BYTES));
57
+ },
58
+ decode() {
59
+ return tail.toString('utf8');
60
+ },
61
+ };
62
+ }
63
+ function completionForAttempt(completion, attempt) {
64
+ const document = completion;
65
+ const delivery = document.delivery;
66
+ return JSON.stringify({ ...document, delivery: { ...delivery, attempt } });
67
+ }
68
+ /** Spawn and settle one already-claimed action delivery. */
69
+ export async function deliverAction(delivery, claimOwner) {
70
+ const stderr = stderrTailCapture();
71
+ let completion;
72
+ try {
73
+ completion = completionForAttempt(delivery.completion, delivery.attempt);
74
+ }
75
+ catch (error) {
76
+ settle(delivery, claimOwner, actionDeliverySettlementForOutcome({
77
+ exitCode: null,
78
+ signal: null,
79
+ timedOut: false,
80
+ spawnError: error,
81
+ stderr: stderr.decode(),
82
+ }, delivery.attempt, Date.now()));
83
+ return;
84
+ }
85
+ let child;
86
+ try {
87
+ child = spawn(delivery.argv[0], delivery.argv.slice(1), {
88
+ cwd: delivery.cwd,
89
+ env: actionDeliveryEnv(delivery.cwd),
90
+ detached: true,
91
+ stdio: ['pipe', 'pipe', 'pipe'],
92
+ });
93
+ }
94
+ catch (error) {
95
+ settle(delivery, claimOwner, actionDeliverySettlementForOutcome({
96
+ exitCode: null,
97
+ signal: null,
98
+ timedOut: false,
99
+ spawnError: error,
100
+ stderr: stderr.decode(),
101
+ }, delivery.attempt, Date.now()));
102
+ return;
103
+ }
104
+ await new Promise((resolve) => {
105
+ let settled = false;
106
+ let timedOut = false;
107
+ let killTimer;
108
+ let timeout;
109
+ const finish = (outcome) => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ if (timeout !== undefined)
114
+ clearTimeout(timeout);
115
+ if (killTimer !== undefined)
116
+ clearTimeout(killTimer);
117
+ child.stdin?.destroy();
118
+ child.stdout?.destroy();
119
+ child.stderr?.destroy();
120
+ settle(delivery, claimOwner, actionDeliverySettlementForOutcome(outcome, delivery.attempt, Date.now()));
121
+ resolve();
122
+ };
123
+ child.stdout?.on('data', () => { }); // Drain diagnostics; stdout is never a control channel.
124
+ child.stderr?.on('data', (chunk) => stderr.take(chunk));
125
+ child.stdin?.on('error', () => { }); // A child may exit before draining a large completion; its exit code remains authoritative.
126
+ child.on('error', (error) => {
127
+ finish({ exitCode: null, signal: null, timedOut: false, spawnError: error, stderr: stderr.decode() });
128
+ });
129
+ child.on('exit', (exitCode, signal) => {
130
+ finish({
131
+ exitCode,
132
+ signal,
133
+ timedOut,
134
+ stderr: stderr.decode(),
135
+ });
136
+ });
137
+ const killWithEscalation = () => {
138
+ if (child.pid === undefined)
139
+ return;
140
+ killProcessGroup(child.pid);
141
+ killTimer = setTimeout(() => killProcessGroup(child.pid, 'SIGKILL'), ACTION_DELIVERY_KILL_GRACE_MS);
142
+ killTimer.unref?.();
143
+ };
144
+ // The deadline starts at spawn, not after stdin write or any output.
145
+ timeout = setTimeout(() => {
146
+ timedOut = true;
147
+ killWithEscalation();
148
+ }, ACTION_DELIVERY_TIMEOUT_MS);
149
+ timeout.unref?.();
150
+ child.stdin?.end(completion, 'utf8');
151
+ });
152
+ }
153
+ /** The delivery child crosses the same consent boundary as every other
154
+ * crouter spawn: nothing of crtrd's own environment reaches it except what
155
+ * `spawnEnv.allow` and the operational base admit for the action's own cwd.
156
+ * No profile participates — an action is declared in scope config alone. */
157
+ function actionDeliveryEnv(cwd) {
158
+ const env = buildOperationalEnvBase({ targetCwd: cwd, targetProfileId: null });
159
+ // Runtime identity, not host state: an action that calls `crtr` must reach
160
+ // the daemon that delivered to it, exactly as a cron body does.
161
+ const canvasHome = process.env['CRTR_HOME'];
162
+ if (canvasHome !== undefined && canvasHome !== '')
163
+ env['CRTR_HOME'] = canvasHome;
164
+ return env;
165
+ }
166
+ function settle(delivery, claimOwner, settlement) {
167
+ settleActionDelivery(delivery.requestId, delivery.attempt, claimOwner, settlement, Date.now());
168
+ }
@@ -1,3 +1,4 @@
1
+ import { type CancelTicketOptions, type CompletePageOptions } from '../../core/human/tickets.js';
1
2
  import type { TicketResult } from '../../core/human/types.js';
2
3
  /** Every ticket-ending action retires the ticket's feedback companion — done
3
4
  * with a canonical final when approved, never left resident. Idempotent and replay-safe; a
@@ -11,12 +12,14 @@ export declare function settleFeedbackCompanion(ticketId: string, outcome: 'appr
11
12
  * through this function, so the reply-route descriptor is the sole authority
12
13
  * on whether completion reaches a bridge.
13
14
  */
14
- export declare function resolvePageTicket(ticketId: string, responses: unknown): Promise<TicketResult>;
15
+ export declare function resolvePageTicket(ticketId: string, responses: unknown, opts?: CompletePageOptions): Promise<TicketResult>;
15
16
  /** Cancel a ticket with the store's first-writer-wins publication. */
16
- export declare function cancelHumanTicket(ticketId: string, opts: {
17
- reason?: string;
18
- actor?: string;
19
- }): Promise<TicketResult>;
17
+ export declare function cancelHumanTicket(ticketId: string, opts: CancelTicketOptions): Promise<TicketResult>;
18
+ /** Schedule the settled request's completion action, if it bound one. Only the
19
+ * settlement winner calls this; the daemon-start sweep replays it for a
20
+ * settlement that died before enqueueing, and the insert is idempotent on the
21
+ * request id so neither path can create a second delivery. */
22
+ export declare function releaseActionDelivery(ticketId: string): void;
20
23
  /**
21
24
  * Deliver a terminal ticket result to its bridge. The canonical response is the
22
25
  * receipt: every action below is replay-safe, so lost delivery can be retried
@@ -1,6 +1,9 @@
1
1
  import { ApiError } from '../../api/index.js';
2
2
  import { getRow, subscribersOf } from '../../core/canvas/canvas.js';
3
- import { ticketDir } from '../../core/human/root.js';
3
+ import { opaqueInboxTicketId, ticketDir } from '../../core/human/root.js';
4
+ import { readTicketActionBinding } from '../../core/human/action-binding.js';
5
+ import { completionEventFor, composeHumanCompletion } from '../../core/human/completion.js';
6
+ import { enqueueActionDelivery } from '../../core/canvas/human-deliveries.js';
4
7
  import { parsePage } from '../../core/human/page.js';
5
8
  import { cancelTicket, projectReviewOutput, readTicketResult, takeoverAndCompletePage, } from '../../core/human/tickets.js';
6
9
  import { describePageAnswer } from '../../core/human/answer.js';
@@ -151,10 +154,11 @@ async function deliverOrConflict(ticketId) {
151
154
  * through this function, so the reply-route descriptor is the sole authority
152
155
  * on whether completion reaches a bridge.
153
156
  */
154
- export async function resolvePageTicket(ticketId, responses) {
155
- const completed = takeoverAndCompletePage(ticketDir(ticketId), responses, { host: 'crtrd', pid: process.pid });
157
+ export async function resolvePageTicket(ticketId, responses, opts = {}) {
158
+ const completed = takeoverAndCompletePage(ticketDir(ticketId), responses, { host: 'crtrd', pid: process.pid }, opts);
156
159
  if (!completed.won)
157
160
  return deliverOrConflict(ticketId);
161
+ releaseActionDelivery(ticketId);
158
162
  try {
159
163
  await deliverTerminalResult(ticketId);
160
164
  }
@@ -168,6 +172,7 @@ export async function cancelHumanTicket(ticketId, opts) {
168
172
  const canceled = cancelTicket(ticketDir(ticketId), opts);
169
173
  if (canceled.status === 'already_resolved')
170
174
  return deliverOrConflict(ticketId);
175
+ releaseActionDelivery(ticketId);
171
176
  try {
172
177
  await deliverTerminalResult(ticketId);
173
178
  }
@@ -176,6 +181,40 @@ export async function cancelHumanTicket(ticketId, opts) {
176
181
  }
177
182
  return canceled.result;
178
183
  }
184
+ /** Schedule the settled request's completion action, if it bound one. Only the
185
+ * settlement winner calls this; the daemon-start sweep replays it for a
186
+ * settlement that died before enqueueing, and the insert is idempotent on the
187
+ * request id so neither path can create a second delivery. */
188
+ export function releaseActionDelivery(ticketId) {
189
+ try {
190
+ const dir = ticketDir(ticketId);
191
+ const binding = readTicketActionBinding(dir);
192
+ if (binding === null)
193
+ return;
194
+ const result = readTicketResult(dir);
195
+ if (result === null || result.kind === 'review')
196
+ return;
197
+ const requestId = opaqueInboxTicketId(ticketId);
198
+ enqueueActionDelivery({
199
+ requestId,
200
+ argv: binding.argv,
201
+ cwd: binding.cwd,
202
+ completion: composeHumanCompletion({ requestId, result, binding, source: pageSource(dir) }),
203
+ });
204
+ }
205
+ catch (error) {
206
+ // The canonical outcome is already durable; the sweep replays this.
207
+ emitEvent({ level: 'error', event: 'human.action_delivery.enqueue_failed', node_id: ticketId, error });
208
+ }
209
+ }
210
+ function pageSource(dir) {
211
+ try {
212
+ return parsePage(dir).source ?? {};
213
+ }
214
+ catch {
215
+ return {};
216
+ }
217
+ }
179
218
  /**
180
219
  * Deliver a terminal ticket result to its bridge. The canonical response is the
181
220
  * receipt: every action below is replay-safe, so lost delivery can be retried
@@ -207,9 +246,9 @@ export async function deliverTerminalResult(ticketId) {
207
246
  if (result.kind === 'canceled') {
208
247
  const title = pageTitle(dir);
209
248
  const reason = result.reason !== undefined && result.reason !== '' ? result.reason : 'no reason was given';
210
- // A human dismissal is a rejection, not a routing event: say so plainly so
211
- // the agent acts on the "no" instead of re-raising the same ask.
212
- const dismissed = result.actor === 'human';
249
+ // A dismissal is a rejection, not a routing event: say so plainly so the
250
+ // agent acts on the "no" instead of re-raising the same ask.
251
+ const dismissed = completionEventFor(result) === 'dismissed';
213
252
  const subject = dismissed
214
253
  ? (title === undefined ? `human interaction ${ticketId} dismissed without answering` : `human interaction ${ticketId} — “${title}” dismissed without answering`)
215
254
  : (title === undefined ? `human interaction ${ticketId} canceled — no answer is coming` : `human interaction ${ticketId} — “${title}” canceled, no answer is coming`);
@@ -5,7 +5,7 @@ import { getReviewByBridge } from '../../core/review/store.js';
5
5
  import { emitEvent } from '../../core/events/emit.js';
6
6
  import { ticketsRoot } from '../../core/human/root.js';
7
7
  import { readTicketResult } from '../../core/human/tickets.js';
8
- import { deliverTerminalResult, settleFeedbackCompanion } from './finish.js';
8
+ import { deliverTerminalResult, releaseActionDelivery, settleFeedbackCompanion } from './finish.js';
9
9
  function isTerminalBridge(status) {
10
10
  return status === 'done' || status === 'dead' || status === 'canceled';
11
11
  }
@@ -32,6 +32,9 @@ export async function reconcileUndeliveredTickets() {
32
32
  // reply bridge exists: inbox-only pages have no bridge row, yet the
33
33
  // daemon can die between result publication and companion retirement.
34
34
  await settleFeedbackCompanion(entry, ticketResult.kind === 'canceled' ? 'canceled' : 'approved');
35
+ // A settlement that died before enqueueing its completion action still
36
+ // owes that delivery; the insert is idempotent on the request id.
37
+ releaseActionDelivery(entry);
35
38
  // The ROW is what carries status; `getNode` would answer from a pruned
36
39
  // bridge's surviving meta.json with no status at all.
37
40
  const bridge = getRow(entry);
@@ -0,0 +1,10 @@
1
+ import type { DetachedWorkLifecycle } from './broker-supervision.js';
2
+ /** One daemon-life identity for guarded action-delivery claims. */
3
+ export declare const humanDeliveryDaemonInstanceId: `${string}-${string}-${string}-${string}-${string}`;
4
+ export interface HumanDeliveryLaneContext {
5
+ lifecycle: DetachedWorkLifecycle;
6
+ }
7
+ /** Admit due action deliveries without making the daemon's serial tick await subprocesses. */
8
+ export declare class HumanDeliveryLaneReconciler {
9
+ run(now: number, ctx: HumanDeliveryLaneContext): void;
10
+ }
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { claimActionDelivery, dueActionDeliveries } from '../../core/canvas/human-deliveries.js';
3
+ import { emitEvent } from '../../core/events/emit.js';
4
+ import { operationIdContext } from '../../core/events/operation-id.js';
5
+ import { deliverAction } from '../human/deliver-action.js';
6
+ /** One daemon-life identity for guarded action-delivery claims. */
7
+ export const humanDeliveryDaemonInstanceId = randomUUID();
8
+ /** Admit due action deliveries without making the daemon's serial tick await subprocesses. */
9
+ export class HumanDeliveryLaneReconciler {
10
+ run(now, ctx) {
11
+ if (!ctx.lifecycle.acceptsDetachedWork())
12
+ return;
13
+ let due;
14
+ try {
15
+ due = dueActionDeliveries(now);
16
+ }
17
+ catch (error) {
18
+ operationIdContext.fresh(() => {
19
+ emitEvent({ level: 'error', event: 'human.action_delivery.scan_failed', error });
20
+ });
21
+ return;
22
+ }
23
+ for (const candidate of due) {
24
+ try {
25
+ const claimed = claimActionDelivery(candidate.requestId, now, humanDeliveryDaemonInstanceId);
26
+ if (claimed === null)
27
+ continue;
28
+ ctx.lifecycle.registerDetached(deliverAction(claimed, humanDeliveryDaemonInstanceId).catch((error) => {
29
+ operationIdContext.fresh(() => {
30
+ emitEvent({ level: 'error', event: 'human.action_delivery.failed', error, fields: { request_id: claimed.requestId } });
31
+ });
32
+ }));
33
+ }
34
+ catch (error) {
35
+ operationIdContext.fresh(() => {
36
+ emitEvent({ level: 'error', event: 'human.action_delivery.claim_failed', error, fields: { request_id: candidate.requestId } });
37
+ });
38
+ }
39
+ }
40
+ }
41
+ }
@@ -40,8 +40,13 @@ export declare function completeRequestedSubmit(reviewId: string, opts: {
40
40
  /** The companion's turn ended: complete any submit the human left waiting on
41
41
  * it. Never throws — a settlement directive must not depend on delivery. */
42
42
  export declare function completeCompanionRequestedSubmit(companionNodeId: string): Promise<void>;
43
- /** Cancel an open review once; ticket cancellation remains owned by the retained human-ticket finisher. */
44
- export declare function cancelReview(reviewId: string, args: {
43
+ /** `disposition` rides through to the ticket result: the recipient closing a
44
+ * review in their inbox is a dismissal, while the requester withdrawing it
45
+ * stays a cancellation. */
46
+ export interface ReviewCancellationArgs {
45
47
  reason?: string;
46
48
  actor?: string;
47
- }): Promise<ReviewCancellationFinishOutcome>;
49
+ disposition?: 'canceled' | 'dismissed';
50
+ }
51
+ /** Cancel an open review once; ticket cancellation remains owned by the retained human-ticket finisher. */
52
+ export declare function cancelReview(reviewId: string, args: ReviewCancellationArgs): Promise<ReviewCancellationFinishOutcome>;
@@ -9,6 +9,7 @@ import { readReviewSource } from '../../core/review/document.js';
9
9
  import { signalReviewActivity } from '../../core/review/signal.js';
10
10
  import { approveReviewLocked, cancelReviewRow, getReview, getReviewByCompanion, markDelivered, markDeliveryError, markProjected, markProjectionError, requestReviewSubmit, requireVisibleReview, } from '../../core/review/store.js';
11
11
  import { ReviewOperationError } from '../../core/review/types.js';
12
+ import { cancelPendingEntries } from '../../core/feed/inbox.js';
12
13
  import { emitEvent } from '../../core/events/emit.js';
13
14
  import { retireCompanion } from '../companion-retire.js';
14
15
  import { deliverNodeMessage } from '../messaging/node-message.js';
@@ -68,6 +69,7 @@ async function serializedFinish(reviewId, work) {
68
69
  }
69
70
  }
70
71
  async function finishApproved(review, result) {
72
+ retractSubmitQueuedNote(review);
71
73
  let current = requireVisibleReview(review.review_id);
72
74
  if (current.projected_at === null) {
73
75
  try {
@@ -93,6 +95,7 @@ async function finishApproved(review, result) {
93
95
  return requireVisibleReview(review.review_id);
94
96
  }
95
97
  async function finishCanceled(review, winningWrite, args) {
98
+ retractSubmitQueuedNote(review);
96
99
  let current = requireVisibleReview(review.review_id);
97
100
  let canceledTicket;
98
101
  if (current.origin_kind === 'inline') {
@@ -153,6 +156,21 @@ async function finishCanceled(review, winningWrite, args) {
153
156
  await retireCompanion(current.companion_node_id, 'canceled');
154
157
  return { record: requireVisibleReview(review.review_id), ...(canceledTicket === undefined ? {} : { ticket_result: canceledTicket }) };
155
158
  }
159
+ const SUBMIT_QUEUED_NOTE = 'review-submit-queued';
160
+ /** Retract an undelivered queued-submit note once the review is settling. The
161
+ * note rides the deferred tier and so does not wake the origin, while the
162
+ * terminal result goes live — without this the origin reads "result to
163
+ * follow" only after it already acted on that result. */
164
+ function retractSubmitQueuedNote(review) {
165
+ try {
166
+ cancelPendingEntries(review.origin_node_id, (entry) => entry.kind === 'message'
167
+ && entry.data?.['note'] === SUBMIT_QUEUED_NOTE
168
+ && entry.data?.['review_id'] === review.review_id);
169
+ }
170
+ catch (error) {
171
+ emitEvent({ level: 'warn', event: 'review.submit_queued_note.retract_failed', error });
172
+ }
173
+ }
156
174
  /** Tell the origin its review was submitted but is finishing behind the
157
175
  * companion. Deliberately deferred: the origin is told nothing is needed of
158
176
  * it, so waking it to say so would be noise. The approval itself wakes it. */
@@ -163,7 +181,7 @@ async function noteSubmitQueued(review) {
163
181
  from: 'crtrd',
164
182
  mode: 'quiet',
165
183
  label: 'human review submitted — result to follow',
166
- data: { review_id: review.review_id },
184
+ data: { review_id: review.review_id, note: SUBMIT_QUEUED_NOTE },
167
185
  runtime: { kind: 'review-queued', facts: {} },
168
186
  body: `The human submitted their review of \`${review.file}\`; the document is still being edited. `
169
187
  + "You'll be notified with the result once the review completes. Nothing is needed from you now.",
package/dist/types.d.ts CHANGED
@@ -231,6 +231,10 @@ export interface PageComponentRegistration {
231
231
  /** A display-only product slot contributes no page response. */
232
232
  display?: boolean;
233
233
  }
234
+ export interface HumanActionConfig {
235
+ argv: string[];
236
+ cwd: string;
237
+ }
234
238
  /** The normalized product component catalog used to validate page slots. */
235
239
  export type ProductPageComponents = readonly PageComponentRegistration[];
236
240
  export interface ScopeConfig {
@@ -344,6 +348,10 @@ export interface ScopeConfig {
344
348
  * the trusted local binary is materialized into the PATH-prepended shim dir
345
349
  * by `core/runtime/bin-contributions.ts`. */
346
350
  bin?: Record<string, string>;
351
+ /** Named completion commands. Project scopes nearest to a creator's cwd
352
+ * outrank farther project scopes, then user scope; plugins and profiles do
353
+ * not contribute. Paths resolve against the declaring scope's authoring root. */
354
+ humanActions: Record<string, HumanActionConfig>;
347
355
  }
348
356
  /** One remote canvas target: where to relay-attach and how to find its
349
357
  * bearer token. The token itself is NEVER stored here — only a ref into the
package/dist/types.js CHANGED
@@ -103,6 +103,7 @@ export function defaultScopeConfig() {
103
103
  remoteCanvas: defaultRemoteCanvasConfig(),
104
104
  spawnEnv: { allow: [] },
105
105
  bin: {},
106
+ humanActions: {},
106
107
  };
107
108
  }
108
109
  /** No remote canvas targets are configured out of the box — every one is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.221",
3
+ "version": "0.3.223",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.221",
3
+ "version": "0.3.223",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.221",
9
+ "version": "0.3.223",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {