@north-light/crouter 0.3.250 → 0.3.251

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.
@@ -13,6 +13,9 @@ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher
13
13
  import { appendInbox, readCursor } from '../feed/inbox.js';
14
14
  import { drainBearings } from '../runtime/kickoff.js';
15
15
  import { appendSituationalContext } from '../runtime/situational-context.js';
16
+ import { createEngineDriver } from '../runtime/broker/engine-drive.js';
17
+ import { createTurnAdmissionGate } from '../runtime/broker/turn-admission.js';
18
+ import { heldDeferredInboxPromptJoin } from '../runtime/broker/held-deferred-inbox.js';
16
19
  import { createNode, getNode, recordPid, setIntent } from '../canvas/canvas.js';
17
20
  import { createProfile, pauseProfile, resumeProfile } from '../profiles/manifest.js';
18
21
  import { closeDb } from '../canvas/db.js';
@@ -275,6 +278,98 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
275
278
  assert.equal(pi.injected.length, 1);
276
279
  assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
277
280
  });
281
+ test('the broker prompt boundary prepends held deferred mail before the human body', async () => {
282
+ freshNode('node-deferred-engine-prompt');
283
+ const pi = makeFakePi();
284
+ disposers.push(registerWatcher(pi));
285
+ await wait(TICK_MS + 100);
286
+ const entry = appendInbox('node-deferred-engine-prompt', {
287
+ from: 'child-4',
288
+ tier: 'deferred',
289
+ kind: 'update',
290
+ label: 'held update',
291
+ });
292
+ await wait(SETTLE_MS);
293
+ const order = [];
294
+ const session = {
295
+ isStreaming: false,
296
+ sendCustomMessage: async ({ content }) => { order.push(content); },
297
+ prompt: async (text, options) => {
298
+ order.push(text);
299
+ options.preflightResult?.(true);
300
+ },
301
+ };
302
+ const drive = createEngineDriver({
303
+ registry: { sendFrame: () => undefined },
304
+ replies: {},
305
+ session: () => session,
306
+ dispatches: { track: (promise) => promise },
307
+ memoryRefs: { guidanceFor: () => null },
308
+ authReloadGate: { inFlight: () => undefined },
309
+ rebind: { installedGeneration: () => 0 },
310
+ projection: {},
311
+ toolGroups: {},
312
+ turnAdmission: createTurnAdmissionGate(),
313
+ });
314
+ await drive({}, { type: 'prompt', text: 'where are we?' });
315
+ assert.equal(order.length, 2, 'one held card and the one human prompt share the admitted operation');
316
+ assert.match(order[0], /held update/, 'the held inbox card is first');
317
+ assert.equal(order[1], 'where are we?', 'the human body follows the held card');
318
+ assert.equal(readCursor('node-deferred-engine-prompt'), undefined, 'the joined handoff waits for settlement');
319
+ await pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
320
+ await settle(pi);
321
+ assert.equal(readCursor('node-deferred-engine-prompt'), entry.entry_id, 'agent_settled commits the broker-joined handoff');
322
+ const staleFollowUpEntry = appendInbox('node-deferred-engine-prompt', {
323
+ from: 'child-4',
324
+ tier: 'deferred',
325
+ kind: 'update',
326
+ label: 'held after a stale client snapshot',
327
+ });
328
+ await wait(SETTLE_MS);
329
+ const beforeFollowUp = order.length;
330
+ await drive({}, { type: 'follow_up', text: 'continue from there' });
331
+ assert.equal(order.length, beforeFollowUp + 2, 'an idle-routed follow_up shares the same prompt boundary');
332
+ assert.match(order.at(-2), /held after a stale client snapshot/, 'the held card still precedes the stale follow_up body');
333
+ assert.equal(order.at(-1), 'continue from there');
334
+ await pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
335
+ await settle(pi);
336
+ assert.equal(readCursor('node-deferred-engine-prompt'), staleFollowUpEntry.entry_id, 'the m-C prompt settles its joined handoff too');
337
+ });
338
+ test('a held deferred batch is prepended to an admitted human prompt and replays if that prompt reloads before settlement', async () => {
339
+ freshNode('node-deferred-human-prompt');
340
+ const pi = makeFakePi();
341
+ disposers.push(registerWatcher(pi));
342
+ await wait(TICK_MS + 100);
343
+ const entry = appendInbox('node-deferred-human-prompt', {
344
+ from: 'child-4',
345
+ tier: 'deferred',
346
+ kind: 'update',
347
+ label: 'held update',
348
+ });
349
+ await wait(SETTLE_MS);
350
+ assert.equal(pi.injected.length, 0, 'deferred-only mail remains held while no turn starts');
351
+ const firstPromptPrefix = [];
352
+ await heldDeferredInboxPromptJoin()?.({
353
+ sendCustomMessage: async ({ content }) => { firstPromptPrefix.push(content); },
354
+ });
355
+ assert.equal(firstPromptPrefix.length, 1, 'the held card is sent before the human prompt starts');
356
+ assert.match(firstPromptPrefix[0], /held update/);
357
+ assert.equal(readCursor('node-deferred-human-prompt'), undefined, 'the pre-prompt handoff is not committed');
358
+ await shutdown(pi);
359
+ assert.equal(readCursor('node-deferred-human-prompt'), undefined, 'a reload before agent_settled leaves the held entry replayable');
360
+ const replacement = makeFakePi();
361
+ disposers.push(registerWatcher(replacement));
362
+ await wait(SETTLE_MS);
363
+ const replayedPrefix = [];
364
+ await heldDeferredInboxPromptJoin()?.({
365
+ sendCustomMessage: async ({ content }) => { replayedPrefix.push(content); },
366
+ });
367
+ assert.equal(replayedPrefix.length, 1, 'the replacement prompt receives the uncommitted held card again');
368
+ assert.match(replayedPrefix[0], /held update/);
369
+ await replacement.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
370
+ await settle(replacement);
371
+ assert.equal(readCursor('node-deferred-human-prompt'), entry.entry_id, 'only agent_settled commits the prompt-joined handoff');
372
+ });
278
373
  // Pause makes the profile inert while its broker is still alive: the entry
279
374
  // must be RETAINED, not consumed. A critical entry is the sharpest case —
280
375
  // its route aborts the live turn, exactly the punch-through pause forbids.
@@ -494,12 +494,19 @@ class FakeSession {
494
494
  * a resolvable `/name` token must not crash on a missing method. */
495
495
  customMessages = [];
496
496
  sendCustomMessage(message, options) {
497
- this.customMessages.push({
497
+ const customMessage = {
498
498
  customType: message.customType,
499
499
  content: message.content,
500
500
  display: message.display,
501
501
  deliverAs: options?.deliverAs,
502
- });
502
+ };
503
+ this.customMessages.push(customMessage);
504
+ try {
505
+ appendFileSync(join(this.dir, 'fake-pi.custom-messages.jsonl'), JSON.stringify(customMessage) + '\n');
506
+ }
507
+ catch {
508
+ /* best effort test proof */
509
+ }
503
510
  return Promise.resolve();
504
511
  }
505
512
  abort() {
@@ -0,0 +1,63 @@
1
+ // Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/held-deferred-human-prompt.test.js
2
+ //
3
+ // Regression: Pi loads canvas extensions through Jiti with moduleCache:false,
4
+ // while broker frame dispatch is native ESM. Deferred mail held by the Jiti
5
+ // watcher must still join the next human prompt driven by native broker code.
6
+ import { after, before, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { appendInbox, readCursor } from '../../feed/inbox.js';
11
+ import { updateNode } from '../../canvas/index.js';
12
+ import { reviveNode } from '../../runtime/revive.js';
13
+ import { createHeadlessHarness } from '../helpers/harness.js';
14
+ import { createAttachKit, delay, frameHas, tok } from '../helpers/broker-clients.js';
15
+ let h;
16
+ let root;
17
+ const kit = createAttachKit(() => h);
18
+ before(async () => {
19
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-held-deferred-prompt' });
20
+ root = h.spawnRoot('held deferred prompt seam root');
21
+ });
22
+ after(async () => {
23
+ kit.closeAll();
24
+ if (h !== undefined)
25
+ await h.dispose();
26
+ });
27
+ test('a Jiti-loaded watcher prepends its held deferred batch to a native broker human prompt', { timeout: 30_000 }, async () => {
28
+ const nodeId = h.fabricateBrokerNode({
29
+ id: 'held-deferred-human-prompt',
30
+ parent: root,
31
+ lifecycle: 'resident',
32
+ status: 'idle',
33
+ });
34
+ updateNode(nodeId, {
35
+ launch: {
36
+ extensions: [],
37
+ tools: [],
38
+ systemPrompt: '',
39
+ env: {
40
+ CRTR_WATCHER_TICK_MS: '20',
41
+ CRTR_WATCHER_DEBOUNCE_MS: '25',
42
+ },
43
+ },
44
+ });
45
+ reviveNode(nodeId, { resume: true, capacity: 'freeze' });
46
+ await h.awaitBoot(nodeId);
47
+ const entry = appendInbox(nodeId, {
48
+ from: 'child',
49
+ tier: 'deferred',
50
+ kind: 'update',
51
+ label: 'held Jiti deferred update',
52
+ });
53
+ await delay(150);
54
+ const customPath = join(h.home, 'nodes', nodeId, 'fake-pi.custom-messages.jsonl');
55
+ assert.equal(existsSync(customPath), false, 'deferred-only mail stays held and does not wake the broker');
56
+ const controller = await kit.attach(nodeId, 'controller', 'held-deferred-prompt-controller');
57
+ const prompt = tok('HELD-DEFERRED-HUMAN-PROMPT');
58
+ controller.send({ type: 'prompt', text: prompt });
59
+ await controller.waitFrame((frame) => frame.type === 'agent_end' && frameHas(frame, prompt), 'the human prompt turn');
60
+ const customMessages = readFileSync(customPath, 'utf8');
61
+ assert.match(customMessages, /held Jiti deferred update/, 'the Jiti extension instance joined its held batch before the native broker prompt');
62
+ assert.equal(readCursor(nodeId), entry.entry_id, 'agent_settled commits the joined handoff');
63
+ });
@@ -1,6 +1,7 @@
1
1
  import { emitEvent } from '../../events/emit.js';
2
2
  import { operationIdContext } from '../../events/operation-id.js';
3
3
  import { promptWithAdvertisedCommandInvocation } from '../advertised-command-invocation.js';
4
+ import { heldDeferredInboxPromptJoin } from './held-deferred-inbox.js';
4
5
  import { chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, resolveEngineRoute, resolveSteerCall, } from './engine-routing.js';
5
6
  export function createEngineDriver(deps) {
6
7
  const { registry, replies, session, dispatches, memoryRefs, authReloadGate, rebind, projection, toolGroups, turnAdmission, } = deps;
@@ -44,14 +45,24 @@ export function createEngineDriver(deps) {
44
45
  // false. Admission waits for that preflight, then routes against the
45
46
  // state it actually established instead of its stale snapshot.
46
47
  const route = resolveEngineRoute(frame, session().isStreaming);
48
+ const promptOptions = route.call === 'prompt' ? route.options : undefined;
47
49
  const guidance = memoryRefs.guidanceFor(frame.text);
50
+ const prompt = async () => {
51
+ // Both human surfaces converge at this admitted boundary. The watcher
52
+ // claims held deferred mail before the prompt body, but its cursor stays
53
+ // uncommitted until the resulting turn's agent_settled event.
54
+ if (route.call === 'prompt' && !session().isStreaming) {
55
+ await heldDeferredInboxPromptJoin()?.(session());
56
+ }
57
+ await session().prompt(frame.text, { ...promptOptions, preflightResult: release });
58
+ };
48
59
  if (guidance === null) {
49
60
  if (route.call === 'prompt') {
50
61
  // A routed prompt STARTS a turn — including an idle follow_up
51
62
  // escalated by m-C — so it gets a fresh operation id. A true
52
63
  // (mid-stream) followUp rides the current turn's operation, like
53
64
  // steer.
54
- return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, () => session().prompt(frame.text, { ...route.options, preflightResult: release })).catch(relayError))));
65
+ return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, prompt).catch(relayError))));
55
66
  }
56
67
  // C1: pi's followUp() takes images as a POSITIONAL 2nd arg (`followUp(text, images?)`).
57
68
  release();
@@ -67,9 +78,9 @@ export function createEngineDriver(deps) {
67
78
  // ATOMICALLY clears its own pending entry if prompt() settles
68
79
  // without ever reaching that drain (an early throw/return leaves
69
80
  // nothing stranded to leak into a later, unrelated turn).
70
- return settle(dispatches.track(promptWithNextTurnGuidance(session(), guidance, () => operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, () => session().prompt(frame.text, { ...route.options, preflightResult: release })))).catch(relayError)));
81
+ return settle(dispatches.track(promptWithNextTurnGuidance(session(), guidance, () => operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, prompt))).catch(relayError)));
71
82
  }
72
- return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, () => session().prompt(frame.text, { ...route.options, preflightResult: release }))
83
+ return settle(dispatches.track(operationIdContext.fresh(() => promptWithAdvertisedCommandInvocation(session(), frame.text, prompt)
73
84
  .catch(relayError)
74
85
  .then(() => sendGuidance(guidance, deliverAs)))));
75
86
  }
@@ -0,0 +1,9 @@
1
+ export declare const HELD_DEFERRED_INBOX_PROMPT_JOIN: unique symbol;
2
+ export type HeldDeferredInboxPromptJoin = (session: {
3
+ sendCustomMessage: (message: {
4
+ customType: string;
5
+ content: string;
6
+ display: boolean;
7
+ }) => Promise<void>;
8
+ }) => Promise<void>;
9
+ export declare function heldDeferredInboxPromptJoin(): HeldDeferredInboxPromptJoin | undefined;
@@ -0,0 +1,8 @@
1
+ // The inbox watcher is a Pi extension loaded through Jiti, while the broker is
2
+ // native ESM. They therefore cannot share a module singleton. This process-global
3
+ // symbol names the watcher-owned join function across that loader boundary.
4
+ export const HELD_DEFERRED_INBOX_PROMPT_JOIN = Symbol.for('@crouton-kit/crtr:held-deferred-inbox-prompt-join');
5
+ export function heldDeferredInboxPromptJoin() {
6
+ const join = globalThis[HELD_DEFERRED_INBOX_PROMPT_JOIN];
7
+ return typeof join === 'function' ? join : undefined;
8
+ }
@@ -10,7 +10,10 @@
10
10
  // Context sends never trigger a turn: the visible human delivery is the sole
11
11
  // trigger, so the agent cannot run on context alone before hearing the person.
12
12
  // A context-only batch keeps its wake by triggering on its LAST card while idle,
13
- // and rides the running turn while streaming.
13
+ // and rides the running turn while streaming. A deferred-only idle batch stays
14
+ // held until either durable mail wakes a turn or the broker prepends it to an
15
+ // already-admitted live human prompt; that bridge still commits only at
16
+ // agent_settled.
14
17
  import { envNodeId, envProfileId, envWatcherDebounceMs, envWatcherTickMs } from '../shared/env.js';
15
18
  import { coalesceBrokerInbox, entryCards, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
16
19
  import { advanceBrokerInboxCursor, brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
@@ -20,6 +23,7 @@ import { errorClassFromError } from '../core/events/errors.js';
20
23
  import { operationIdContext } from '../core/events/operation-id.js';
21
24
  import { situationalContextEnvelope, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../core/runtime/situational-context.js';
22
25
  import { isProfilePaused } from '../core/profiles/manifest.js';
26
+ import { HELD_DEFERRED_INBOX_PROMPT_JOIN, } from '../core/runtime/broker/held-deferred-inbox.js';
23
27
  const MAX_PENDING_HANDOFFS = 64;
24
28
  const MAX_SOURCE_OPERATION_IDS = 16;
25
29
  let currentWatcher;
@@ -188,6 +192,7 @@ class CanvasInboxWatcher {
188
192
  lastArrival = 0;
189
193
  pendingHandoffs = [];
190
194
  pendingCommit;
195
+ promptJoinInFlight = false;
191
196
  seeded = false;
192
197
  disposed = false;
193
198
  disposalReason;
@@ -250,7 +255,7 @@ class CanvasInboxWatcher {
250
255
  // every queued continuation has drained, so hasPendingMessages() must
251
256
  // not veto the commit here. Await crtrd before changing the in-memory
252
257
  // durable cursor; a failed commit remains replayable after process exit.
253
- const result = await this.settle(nodeId, ctx, false);
258
+ const result = await this.settle(nodeId, ctx, false, true);
254
259
  if (result === 'refresh_held')
255
260
  this.dispose('refresh', false);
256
261
  }
@@ -383,6 +388,7 @@ class CanvasInboxWatcher {
383
388
  this.reportNodes.clear();
384
389
  this.reportNodesAsked.clear();
385
390
  this.pendingCommit = undefined;
391
+ this.promptJoinInFlight = false;
386
392
  this.nodeId = undefined;
387
393
  this.lastCtx = undefined;
388
394
  this.lastArrival = 0;
@@ -531,6 +537,8 @@ class CanvasInboxWatcher {
531
537
  this.retryPendingCommitOnTick(nodeId);
532
538
  return;
533
539
  }
540
+ if (this.promptJoinInFlight)
541
+ return;
534
542
  // A paused profile is inert: read nothing, send nothing, leave the cursor
535
543
  // where it is, so every queued entry — critical included, since aborting the
536
544
  // live turn is exactly the punch-through pause forbids — is still there to
@@ -599,6 +607,9 @@ class CanvasInboxWatcher {
599
607
  const hiddenIds = new Set(hiddenEntries.map((entry) => entry.entry_id));
600
608
  const visibleIds = new Set(visibleEntries.map((entry) => entry.entry_id));
601
609
  const routedEntries = entries.filter((entry) => hiddenIds.has(entry.entry_id) || visibleIds.has(entry.entry_id));
610
+ // Deferred-only idle mail remains in this buffer until a non-deferred inbox
611
+ // entry starts delivery, or a live human prompt claims it through
612
+ // prependHeldDeferredInbox(). Neither path wakes the node on its own.
602
613
  const deferredHold = this.isIdle()
603
614
  && routedEntries.length > 0
604
615
  && routedEntries.every((entry) => entry.tier === 'deferred');
@@ -826,6 +837,83 @@ class CanvasInboxWatcher {
826
837
  this.requeueFromBatch(batch, execution.requeueEntryIds);
827
838
  return 'attempted';
828
839
  }
840
+ /** Claim the deferred-only idle batch for the human prompt already admitted
841
+ * by the broker. Each rendered delivery is persisted before that prompt, but
842
+ * no cursor may advance until the prompt's agent_settled event proves the
843
+ * turn consumed it. */
844
+ async prependHeldDeferredInbox(session) {
845
+ if (!this.canMutate() || this.nodeId === undefined || profilePaused())
846
+ return;
847
+ const plan = this.ordinaryPlan();
848
+ if (!plan.deferredHold)
849
+ return;
850
+ const deliveries = plan.visibleEntries.length === 0
851
+ ? []
852
+ : this.digest(plan.visibleEntries, 'poll');
853
+ const handoffCount = Number(plan.hiddenEntries.length > 0) + deliveries.length;
854
+ if (this.pendingHandoffs.length + handoffCount > MAX_PENDING_HANDOFFS)
855
+ return;
856
+ const batch = [...this.buffer];
857
+ this.promptJoinInFlight = true;
858
+ try {
859
+ if (plan.hiddenEntries.length > 0) {
860
+ await this.sendPromptJoinDelivery(session, 'hidden', plan.hiddenEntries, this.contextCards(this.nodeId, plan.hiddenEntries));
861
+ }
862
+ for (const delivery of deliveries) {
863
+ await this.sendPromptJoinDelivery(session, 'visible', delivery.entries, [...delivery.cards, delivery.text]);
864
+ }
865
+ if (!this.canMutate())
866
+ return;
867
+ this.buffer.splice(0, batch.length);
868
+ }
869
+ finally {
870
+ this.promptJoinInFlight = false;
871
+ }
872
+ }
873
+ async sendPromptJoinDelivery(session, channel, entries, messages) {
874
+ let facts;
875
+ try {
876
+ facts = deliveryFacts(entries, channel);
877
+ }
878
+ catch (error) {
879
+ this.raiseReadFatal('poll', error);
880
+ }
881
+ let operationId;
882
+ try {
883
+ await operationIdContext.fresh(async (freshOperationId) => {
884
+ operationId = freshOperationId;
885
+ emitBestEffort({
886
+ level: 'info',
887
+ event: 'inbox.delivery.started',
888
+ operation_id: freshOperationId,
889
+ fields: deliveryFields(facts),
890
+ });
891
+ for (const content of messages) {
892
+ await session.sendCustomMessage({
893
+ customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
894
+ content,
895
+ display: true,
896
+ });
897
+ }
898
+ });
899
+ }
900
+ catch (error) {
901
+ if (operationId === undefined)
902
+ this.raiseReadFatal('poll', error);
903
+ emitDeliveryFailure(operationId, facts, 'send', error);
904
+ throw error;
905
+ }
906
+ if (operationId === undefined) {
907
+ this.raiseReadFatal('poll', new Error(`${channel} prompt-join delivery returned without an operation id`));
908
+ }
909
+ const handoff = { operationId, ...facts, settleAtAgentSettled: true };
910
+ if (!this.canMutate()) {
911
+ if (this.disposed && this.disposalReason !== undefined)
912
+ emitDeliveryAbandoned(handoff, this.disposalReason);
913
+ return;
914
+ }
915
+ this.pendingHandoffs.push(handoff);
916
+ }
829
917
  coveredEntryIds(channel) {
830
918
  const covered = new Set();
831
919
  for (const handoff of this.pendingHandoffs) {
@@ -883,7 +971,7 @@ class CanvasInboxWatcher {
883
971
  emitDeliveryCompleted(handoff);
884
972
  return 'settled';
885
973
  }
886
- async settle(nodeId, ctx, holdPendingMessages) {
974
+ async settle(nodeId, ctx, holdPendingMessages, atAgentSettled = false) {
887
975
  if (holdPendingMessages) {
888
976
  try {
889
977
  if (ctx?.hasPendingMessages?.() === true)
@@ -903,9 +991,17 @@ class CanvasInboxWatcher {
903
991
  for (const handoff of alreadyDurable)
904
992
  emitDeliveryCompleted(handoff);
905
993
  }
994
+ const agentSettledBlock = atAgentSettled
995
+ ? undefined
996
+ : this.pendingHandoffs
997
+ .filter((handoff) => handoff.settleAtAgentSettled === true)
998
+ .map((handoff) => physical.throughIndexes.get(handoff))
999
+ .reduce((first, index) => first === undefined ? index : Math.min(first, index), undefined);
906
1000
  const eligible = this.pendingHandoffs.filter((handoff) => {
907
1001
  const throughIndex = physical.throughIndexes.get(handoff);
908
- return physical.blockedIndex === undefined || throughIndex < physical.blockedIndex;
1002
+ return (handoff.settleAtAgentSettled !== true || atAgentSettled)
1003
+ && (physical.blockedIndex === undefined || throughIndex < physical.blockedIndex)
1004
+ && (agentSettledBlock === undefined || throughIndex < agentSettledBlock);
909
1005
  });
910
1006
  if (eligible.length === 0)
911
1007
  return 'settled';
@@ -1006,6 +1102,11 @@ function installWatcher(pi, cadence) {
1006
1102
  currentWatcher?.dispose('reload', false);
1007
1103
  const watcher = new CanvasInboxWatcher(pi, cadence, ++nextGeneration);
1008
1104
  currentWatcher = watcher;
1105
+ // Pi loads this extension through Jiti while the broker driver is native ESM,
1106
+ // so their module-level singletons differ. Register the bound watcher on the
1107
+ // process-global key that the broker reads immediately before a human prompt.
1108
+ const promptJoin = (session) => watcher.prependHeldDeferredInbox(session);
1109
+ globalThis[HELD_DEFERRED_INBOX_PROMPT_JOIN] = promptJoin;
1009
1110
  try {
1010
1111
  watcher.start();
1011
1112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.250",
3
+ "version": "0.3.251",
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.250",
3
+ "version": "0.3.251",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.250",
9
+ "version": "0.3.251",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {