@ziggs-ai/agent-sdk 0.20.0 → 0.21.0

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 (51) hide show
  1. package/dist/Agent.d.ts +28 -7
  2. package/dist/Agent.js +176 -71
  3. package/dist/brain/ClaudeBrain.d.ts +11 -1
  4. package/dist/brain/ClaudeBrain.js +71 -18
  5. package/dist/brain/ZiggsBrain.d.ts +2 -0
  6. package/dist/brain/ZiggsBrain.js +1 -0
  7. package/dist/brain/claudeCognition.d.ts +1 -1
  8. package/dist/brain/claudeCognition.js +47 -7
  9. package/dist/brain/claudeSdkProtocol.d.ts +1 -1
  10. package/dist/brain/claudeSdkProtocol.js +1 -1
  11. package/dist/brain/claudeSdkTools.d.ts +3 -0
  12. package/dist/brain/claudeSdkTools.js +4 -2
  13. package/dist/brain/hireActivationNotice.d.ts +19 -0
  14. package/dist/brain/hireActivationNotice.js +79 -0
  15. package/dist/brain/standingOfferNotice.d.ts +15 -0
  16. package/dist/brain/standingOfferNotice.js +89 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ingress/normalizeIncoming.d.ts +3 -0
  20. package/dist/ingress/normalizeIncoming.js +8 -0
  21. package/dist/mcp/createMcpAgent.d.ts +1 -1
  22. package/dist/mcp/createMcpAgent.js +1 -1
  23. package/dist/memory/MemoryStore.d.ts +18 -4
  24. package/dist/memory/MemoryStore.js +52 -12
  25. package/dist/platform/ConnectionPool.d.ts +9 -4
  26. package/dist/platform/ConnectionPool.js +16 -6
  27. package/dist/platform/StateTimers.d.ts +12 -6
  28. package/dist/platform/StateTimers.js +26 -6
  29. package/dist/platform/ZiggsEffectHandler.js +5 -1
  30. package/dist/platform/inboxLoop.d.ts +5 -7
  31. package/dist/platform/inboxLoop.js +36 -80
  32. package/dist/platform/polls/declaredPollRunner.d.ts +7 -1
  33. package/dist/platform/polls/declaredPollRunner.js +7 -9
  34. package/dist/platform/tools/index.d.ts +1 -0
  35. package/dist/platform/tools/index.js +1 -0
  36. package/dist/platform/tools/tier2/contextTools.d.ts +2 -0
  37. package/dist/platform/tools/tier2/contextTools.js +3 -1
  38. package/dist/platform/tools/tier2/planTools.d.ts +20 -0
  39. package/dist/platform/tools/tier2/planTools.js +64 -0
  40. package/dist/pricing/fleetDefaults.js +4 -9
  41. package/dist/pricing/fleetTierByAgentId.gen.d.ts +1 -1
  42. package/dist/pricing/fleetTierByAgentId.gen.js +2 -752
  43. package/dist/runtime/AgentMachine.d.ts +3 -0
  44. package/dist/runtime/AgentMachine.js +6 -1
  45. package/dist/runtime/runTurn.d.ts +1 -0
  46. package/dist/runtime/runTurn.js +19 -15
  47. package/dist/runtime/wakeEffects.d.ts +35 -0
  48. package/dist/runtime/wakeEffects.js +121 -0
  49. package/package.json +2 -2
  50. package/dist/pricing/fleetFreeTierA.gen.d.ts +0 -6
  51. package/dist/pricing/fleetFreeTierA.gen.js +0 -11
package/dist/Agent.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { PollBinding, PollKind } from './platform/polls/PollKind.js';
2
+ export { workScopeFromWake } from './runtime/wakeEffects.js';
2
3
  import type { AgentConfig, Ctx, Workflow } from './types.js';
3
4
  import { ZiggsBrain, type TickOutput } from './brain/ZiggsBrain.js';
4
5
  import { ClaudeBrain, type CognitionKind } from './brain/ClaudeBrain.js';
@@ -56,11 +57,6 @@ export declare function laneSessionIdForTask(task: {
56
57
  originChatId?: string | null;
57
58
  agreementId?: string | null;
58
59
  }): string;
59
- /** Pull the cost-record keys off a wake event or session id. */
60
- export declare function workScopeFromWake(event: unknown, sessionId?: string): {
61
- agreementId?: string;
62
- taskId?: string;
63
- };
64
60
  interface LlmAdapter {
65
61
  chatMessages(messages: unknown[], tools?: unknown[], options?: Record<string, unknown>): Promise<unknown>;
66
62
  }
@@ -111,6 +107,12 @@ export interface AgentOptions {
111
107
  * Internal mapping: `fsm` → Ziggs brain, `claude-sdk` → Claude brain.
112
108
  */
113
109
  cognition?: CognitionKind;
110
+ /**
111
+ * Spend a model turn on an activation notice for a hire an agent formed.
112
+ * Default off — the host records a no-op and waits for the assigned task.
113
+ * A hire a person formed (store claim) still wakes regardless of this flag.
114
+ */
115
+ actsOnHireActivation?: boolean;
114
116
  /** Max agentic turns per Claude SDK wake. Default 30. */
115
117
  claudeMaxTurns?: number;
116
118
  /**
@@ -256,6 +258,9 @@ export declare class Agent {
256
258
  private askedForAccessLanes;
257
259
  /** Tasks left active by the asking turn, failed right after it (per lane). */
258
260
  private unmetTasksPendingAsk;
261
+ private readonly wakeEffects;
262
+ private readonly outboundMessages;
263
+ private readonly confirmedPlanVersions;
259
264
  private grantProviderCache;
260
265
  constructor(options?: AgentOptions);
261
266
  private _validate;
@@ -300,7 +305,21 @@ export declare class Agent {
300
305
  * wake keys on the live room / hire.
301
306
  */
302
307
  private dropStaleCancelledAgreementSession;
308
+ private formedByForHireActivation;
303
309
  private laneAgreementIsTerminal;
310
+ /**
311
+ * Whether the host answers this wake without spending a model turn.
312
+ *
313
+ * One decision, consulted by the live path and by {@link Agent.replayWake},
314
+ * because a recording is only worth keeping if a replay can tell you what the
315
+ * host would do now. It used to run only on the live path, so an edit to any
316
+ * of these rules was invisible to a replay — the tool that exists to answer
317
+ * "what changed?" could not see the change.
318
+ *
319
+ * Every rule here reads a row or the frame itself. None of them reads a lane,
320
+ * an agent kind, a name or an id shape.
321
+ */
322
+ private wakeSuppression;
304
323
  /**
305
324
  * the trailing turn on work that is already reported.
306
325
  *
@@ -384,8 +403,10 @@ export declare class Agent {
384
403
  */
385
404
  private triageRequestDelivery;
386
405
  /** replay task-assigned for an existing active task (demo recovery). */
387
- wakeTaskAssignment(taskId: string): Promise<void>;
388
- disconnect(): void;
406
+ wakeTaskAssignment(taskId: string, options?: {
407
+ fromInbox?: boolean;
408
+ }): Promise<void>;
409
+ disconnect(): Promise<void>;
389
410
  /**
390
411
  * Waits for in-flight ticks to finish. Call before process.exit() on SIGTERM.
391
412
  * A timeout is returned, not thrown — the caller names it (lab verdict, fleet
package/dist/Agent.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { agreementLaneId, agreementIdFromLane } from '@ziggs-ai/api-client';
2
2
  import { createDeclaredPollRunner } from './platform/polls/declaredPollRunner.js';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
+ import { WakeEffects } from './runtime/wakeEffects.js';
6
+ export { workScopeFromWake } from './runtime/wakeEffects.js';
4
7
  import { ZiggsBrain } from './brain/ZiggsBrain.js';
5
8
  import { ClaudeBrain } from './brain/ClaudeBrain.js';
6
9
  import { applyBrainToAgentOptions, isClaudeCognition, } from './brain/index.js';
@@ -14,7 +17,7 @@ import { WAKE_TOOLS } from './platform/tools/tier1/wakeTools.js';
14
17
  import { createZiggsEffectHandler } from './platform/ZiggsEffectHandler.js';
15
18
  import { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
16
19
  import { PromptBuilder } from './runtime/PromptBuilder.js';
17
- import { InMemoryStore, memorySectionForWake, partitionMemoryStore, } from './memory/MemoryStore.js';
20
+ import { InMemoryStore, hireIdsFromEvent, memorySectionForWake, partitionMemoryStore, uniqueHireIds, } from './memory/MemoryStore.js';
18
21
  import { ToolManager } from './tools/ToolManager.js';
19
22
  import { toolName } from './tools/defineTool.js';
20
23
  import { PROTOCOL_TOOLS } from './platform/tools/tier1/protocolTools.js';
@@ -31,6 +34,7 @@ import { SeenMessages } from './platform/SeenMessages.js';
31
34
  import { runtimeLog } from './shared/runtimeLog.js';
32
35
  import { isTelemetryIngestEnabled, sendTelemetry, } from './platform/telemetryIngest.js';
33
36
  import { buildWakeOutcomePayload, classifyWakeOutcome, } from './runtime/wakeOutcome.js';
37
+ import { agreementIdFromWake, formedByFromWake, isHireActivationNotice, shouldSkipAgentFormedHireActivation, } from './brain/hireActivationNotice.js';
34
38
  /**
35
39
  * The single fleet-wide default model — the one place the fallback lives.
36
40
  * Change this (or set `LLM_MODEL`) to move the whole fleet; nothing hardcodes a
@@ -65,26 +69,6 @@ export function laneSessionIdForTask(task) {
65
69
  }
66
70
  return 'default';
67
71
  }
68
- /** Pull the cost-record keys off a wake event or session id. */
69
- export function workScopeFromWake(event, sessionId) {
70
- const rec = event && typeof event === 'object' ? event : {};
71
- const resource = rec.resource && typeof rec.resource === 'object'
72
- ? rec.resource
73
- : {};
74
- const agreementId = (typeof rec.agreementId === 'string' && rec.agreementId) ||
75
- (typeof resource.agreementId === 'string' && resource.agreementId) ||
76
- agreementIdFromLane(rec.chatId) ||
77
- agreementIdFromLane(resource.chatId) ||
78
- agreementIdFromLane(sessionId) ||
79
- undefined;
80
- const taskId = (typeof rec.taskId === 'string' && rec.taskId) ||
81
- (typeof resource.taskId === 'string' && resource.taskId) ||
82
- undefined;
83
- return {
84
- ...(agreementId ? { agreementId } : {}),
85
- ...(taskId ? { taskId } : {}),
86
- };
87
- }
88
72
  function createToolManager(options) {
89
73
  const toolManager = new ToolManager();
90
74
  const userTools = options.tools || options.services?.tools || [];
@@ -220,6 +204,9 @@ export class Agent {
220
204
  askedForAccessLanes = new Set();
221
205
  /** Tasks left active by the asking turn, failed right after it (per lane). */
222
206
  unmetTasksPendingAsk = new Map();
207
+ wakeEffects = new AsyncLocalStorage();
208
+ outboundMessages = new SeenMessages();
209
+ confirmedPlanVersions = new SeenMessages();
223
210
  grantProviderCache = null;
224
211
  constructor(options = {}) {
225
212
  const resolved = applyBrainToAgentOptions(options);
@@ -258,6 +245,7 @@ export class Agent {
258
245
  polls: resolved.polls,
259
246
  pollKinds: resolved.pollKinds,
260
247
  cognition: resolved.cognition ?? 'fsm',
248
+ actsOnHireActivation: resolved.actsOnHireActivation === true,
261
249
  claudeMaxTurns: resolved.claudeMaxTurns,
262
250
  claudeStatelessPerWake: resolved.claudeStatelessPerWake,
263
251
  };
@@ -313,18 +301,25 @@ export class Agent {
313
301
  operatorKey: this.options.operatorKey,
314
302
  agentId: this.options.agentId,
315
303
  };
316
- const messageSender = (message, receiverId, sessionId, messageId) => sendChatMessage({
317
- chatId: sessionId,
318
- // Omitted on purpose when the caller has no valid address for THIS
319
- // room: the backend infers the recipient from the room's own
320
- // participants.
321
- receiverId,
322
- text: message,
323
- // messageId is the backend's (chatId, messageId) dedup key. Callers
324
- // pass a wake-derived id for replay-stability; fall back to a fresh id
325
- // only when absent (matches the old WS path letting the server assign).
326
- messageId: messageId ?? `sdk_${randomUUID()}`,
327
- }, creds);
304
+ const messageSender = async (message, receiverId, sessionId, messageId) => {
305
+ const sentId = messageId ?? `sdk_${randomUUID()}`;
306
+ const result = await sendChatMessage({
307
+ chatId: sessionId,
308
+ // Omitted on purpose when the caller has no valid address for THIS
309
+ // room: the backend infers the recipient from the room's own
310
+ // participants.
311
+ receiverId,
312
+ text: message,
313
+ // messageId is the backend's (chatId, messageId) dedup key. Callers
314
+ // pass a wake-derived id for replay-stability; fall back to a fresh id
315
+ // only when absent (matches the old WS path letting the server assign).
316
+ messageId: sentId,
317
+ }, creds);
318
+ if (!result.success)
319
+ throw new Error('sendChatMessage did not confirm delivery');
320
+ this.outboundMessages.mark(JSON.stringify([result.chatId, result.messageId]));
321
+ return result;
322
+ };
328
323
  this.taskService = services.taskService;
329
324
  this.agreementService = services.agreementService;
330
325
  this.telemetryClient = services.telemetryClient;
@@ -366,12 +361,18 @@ export class Agent {
366
361
  agentId: this.options.agentId,
367
362
  telemetryClient: services.telemetryClient,
368
363
  telemetryIngest: this.options.telemetryIngest,
369
- effectTap: this.options.effectTap,
364
+ effectTap: (effect, response) => {
365
+ const marks = this.wakeEffects.getStore();
366
+ marks?.observe(effect, response);
367
+ for (const key of marks?.sentMessageKeys ?? [])
368
+ this.outboundMessages.mark(key);
369
+ this.options.effectTap?.(effect, response);
370
+ },
370
371
  // `wake_at` reaches the lane's timer through here. The handler
371
372
  // is built once and has no laneKey, so it derives one the same way the
372
373
  // event queue does (see laneKeyFor).
373
374
  wakeScheduler: {
374
- armWakeAt: (sessionId, at, reason) => this.stateTimers.armWakeAt(laneKeyFor(this.options.agentId, sessionId), at, reason),
375
+ armWakeAt: (sessionId, at, reason) => this.stateTimers.armWakeAt(laneKeyFor(this.options.agentId, sessionId), at, reason, this.wakeEffects.getStore()?.scope),
375
376
  },
376
377
  });
377
378
  this.brain = isClaudeCognition(this.options.cognition)
@@ -390,6 +391,7 @@ export class Agent {
390
391
  // services from the effect handler's deps like the FSM path.
391
392
  maxTurns: this.options.claudeMaxTurns,
392
393
  statelessPerWake: this.options.claudeStatelessPerWake,
394
+ actsOnHireActivation: this.options.actsOnHireActivation === true,
393
395
  })
394
396
  : new ZiggsBrain({
395
397
  workflow,
@@ -513,7 +515,7 @@ export class Agent {
513
515
  // the same message being processed twice; leaving it set on a failed wake
514
516
  // would make the redelivery this rejection buys us a no-op.
515
517
  try {
516
- await this.eventQueue.enqueue(normalized.event, laneKey);
518
+ await this.eventQueue.enqueue({ ...normalized.event, chatId: normalized.chatId }, laneKey);
517
519
  }
518
520
  catch (err) {
519
521
  if (messageId)
@@ -551,12 +553,28 @@ export class Agent {
551
553
  llmCalls,
552
554
  }), isTelemetryIngestEnabled(this.options.telemetryIngest));
553
555
  }
554
- memoryForWake(session) {
555
- return partitionMemoryStore(this.memory, memorySectionForWake({
556
+ memoryForWake(session, event) {
557
+ const fromEvent = hireIdsFromEvent(event);
558
+ const wakeAgreementIds = uniqueHireIds([
559
+ ...fromEvent,
560
+ session.actingHireId,
561
+ ]);
562
+ const section = memorySectionForWake({
556
563
  sessionId: session.sessionId,
557
564
  activeAgreementId: session.ctx
558
565
  ?.activeAgreementId,
559
- }));
566
+ wakeAgreementIds,
567
+ });
568
+ const known = uniqueHireIds([
569
+ session.ctx
570
+ ?.activeAgreementId,
571
+ ...wakeAgreementIds,
572
+ ]);
573
+ if (known.length === 1)
574
+ session.actingHireId = known[0];
575
+ else if (known.length > 1)
576
+ session.actingHireId = null;
577
+ return partitionMemoryStore(this.memory, section);
560
578
  }
561
579
  /**
562
580
  * A chat-lane session that still carries a cancelled hire from a previous
@@ -585,6 +603,24 @@ export class Agent {
585
603
  // Fail open: a lookup error must not drop a live hire's session.
586
604
  }
587
605
  }
606
+ async formedByForHireActivation(event) {
607
+ const fromWake = formedByFromWake(event);
608
+ if (fromWake)
609
+ return fromWake;
610
+ const agreementId = agreementIdFromWake(event);
611
+ if (!agreementId || !this.agreementService)
612
+ return undefined;
613
+ try {
614
+ const agreement = await this.agreementService.get(agreementId);
615
+ const formedBy = agreement?.formedBy;
616
+ if (formedBy === 'agent' || formedBy === 'principal')
617
+ return formedBy;
618
+ }
619
+ catch {
620
+ // Fail open: a lookup error must not drop a person's store-claim wake.
621
+ }
622
+ return undefined;
623
+ }
588
624
  async laneAgreementIsTerminal(sessionId) {
589
625
  const agreementId = agreementIdFromLane(sessionId);
590
626
  if (!agreementId || !this.agreementService)
@@ -598,6 +634,43 @@ export class Agent {
598
634
  return false;
599
635
  }
600
636
  }
637
+ /**
638
+ * Whether the host answers this wake without spending a model turn.
639
+ *
640
+ * One decision, consulted by the live path and by {@link Agent.replayWake},
641
+ * because a recording is only worth keeping if a replay can tell you what the
642
+ * host would do now. It used to run only on the live path, so an edit to any
643
+ * of these rules was invisible to a replay — the tool that exists to answer
644
+ * "what changed?" could not see the change.
645
+ *
646
+ * Every rule here reads a row or the frame itself. None of them reads a lane,
647
+ * an agent kind, a name or an id shape.
648
+ */
649
+ async wakeSuppression(event, sessionId) {
650
+ if (await this.laneAgreementIsTerminal(sessionId)) {
651
+ return { reason: 'agreement_terminal', because: 'its agreement is terminal' };
652
+ }
653
+ if (this.isSettledTaskEcho(event)) {
654
+ return {
655
+ reason: 'task_already_settled',
656
+ because: 'its task already reported a result',
657
+ };
658
+ }
659
+ if (isHireActivationNotice(event)) {
660
+ const formedBy = await this.formedByForHireActivation(event);
661
+ if (shouldSkipAgentFormedHireActivation({
662
+ event,
663
+ formedBy,
664
+ actsOnHireActivation: this.options.actsOnHireActivation === true,
665
+ })) {
666
+ return {
667
+ reason: 'agent_formed_hire_activation',
668
+ because: 'agent-formed hire activation',
669
+ };
670
+ }
671
+ }
672
+ return null;
673
+ }
601
674
  /**
602
675
  * the trailing turn on work that is already reported.
603
676
  *
@@ -780,14 +853,30 @@ export class Agent {
780
853
  const session = this.sessions.get(laneKey);
781
854
  if (!session?.sessionId)
782
855
  return;
783
- if (await this.laneAgreementIsTerminal(session.sessionId)) {
784
- runtimeLog.info(this.options.name || 'agent', `skipping wake on ${session.sessionId} — its agreement is terminal`);
785
- this.recordWakeOutcome(session, { kind: 'no-op', reason: 'agreement_terminal' });
856
+ // Presentation may call our sender an rpb-* room binding or the person we
857
+ // stand in for. Only an exact (chat, messageId) we successfully sent proves
858
+ // an echo; another message from the same binding still gets a full wake.
859
+ const incoming = event;
860
+ const events = incoming?.type === 'batch' && Array.isArray(incoming.events) ? incoming.events : [event];
861
+ const remaining = events.filter((item) => {
862
+ const e = item;
863
+ const task = e?.type === 'task_result' ? e.result : undefined;
864
+ if (this.stateTimers.peek(laneKey)?.kind === 'wake-at'
865
+ && e?.source === 'active-task-snapshot' && (task?.state === 'active' || task?.state === 'in-progress')
866
+ && task?.assigneeId === this.options.agentId && task.taskId && task.updatedAt && this.confirmedPlanVersions.has(JSON.stringify([laneKey, JSON.stringify([task.taskId, task.updatedAt])])))
867
+ return false;
868
+ return e?.type !== 'message' || !e.messageId || !this.outboundMessages.has(JSON.stringify([e.chatId ?? session.sessionId, e.messageId]));
869
+ });
870
+ if (remaining.length === 0) {
871
+ this.recordWakeOutcome(session, { kind: 'no-op', reason: events.some(e => e?.type === 'task_result') ? 'own_plan_snapshot' : 'own_message_echo' });
786
872
  return;
787
873
  }
788
- if (this.isSettledTaskEcho(event)) {
789
- runtimeLog.info(this.options.name || 'agent', `skipping wake on ${session.sessionId} — its task already reported a result`);
790
- this.recordWakeOutcome(session, { kind: 'no-op', reason: 'task_already_settled' });
874
+ if (remaining.length !== events.length)
875
+ event = remaining.length === 1 ? remaining[0] : { type: 'batch', events: remaining };
876
+ const suppressed = await this.wakeSuppression(event, session.sessionId);
877
+ if (suppressed) {
878
+ runtimeLog.info(this.options.name || 'agent', `skipping wake on ${session.sessionId} — ${suppressed.because}`);
879
+ this.recordWakeOutcome(session, { kind: 'no-op', reason: suppressed.reason });
791
880
  return;
792
881
  }
793
882
  // Before the tick spends anything: work this agent declared it cannot do
@@ -799,9 +888,10 @@ export class Agent {
799
888
  const frame = this.stateTimers.stripStale(event, laneKey);
800
889
  if (frame === null)
801
890
  return;
802
- // Any event reaching the machine invalidates the countdown ("clear on any
803
- // event for the lane"); re-armed below if the tick settles parked+timed.
804
- this.stateTimers.clear(laneKey);
891
+ // Traffic resets inactivity timers, but must not cancel an appointment
892
+ // the agent explicitly requested. Only its own fresh timer frame consumes
893
+ // that wake; a notice-only tick cannot ask the model to restore it.
894
+ this.stateTimers.consumeForEvent(frame, laneKey);
805
895
  // Cold-start recovery: restore FSM state from sessionStore when the
806
896
  // in-memory lane has no prior context (e.g. after a container restart).
807
897
  if (this.sessionStore && session.ctx === undefined && session.state === undefined) {
@@ -822,7 +912,7 @@ export class Agent {
822
912
  sessionId: session.sessionId,
823
913
  laneKey,
824
914
  };
825
- const memory = this.memoryForWake(session);
915
+ const memory = this.memoryForWake(session, frame);
826
916
  // Recorded here rather than after the tick: this is the last point where the
827
917
  // input is still exactly what the brain will see, and a wake that goes on to
828
918
  // throw is the one most worth replaying.
@@ -850,20 +940,17 @@ export class Agent {
850
940
  // a zombie — burning tokens and racing the redelivered retry of the same
851
941
  // event (duplicate sends, runaway per-wake input).
852
942
  const tickAbort = new AbortController();
853
- const marks = { sent: 0, tasks: 0 };
943
+ const marks = WakeEffects.forWake(frame, session.sessionId);
854
944
  const eff = async (effect) => {
855
- if (effect.kind === 'send-message')
856
- marks.sent += 1;
857
- if (effect.kind === 'tool-call') {
858
- const name = 'name' in effect ? String(effect.name) : '';
859
- if (name.startsWith('task_'))
860
- marks.tasks += 1;
861
- }
862
- return this.eff(effect);
945
+ const response = await this.eff(effect);
946
+ marks.observe(effect, response);
947
+ for (const key of marks.sentMessageKeys)
948
+ this.outboundMessages.mark(key);
949
+ return response;
863
950
  };
864
951
  try {
865
952
  const result = await Promise.race([
866
- this.brain.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff, memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal }),
953
+ this.wakeEffects.run(marks, () => this.brain.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff, memory, transitionTap: this.options.transitionTap, signal: tickAbort.signal })),
867
954
  new Promise((_, reject) => {
868
955
  tickWatchdog = setTimeout(() => {
869
956
  tickAbort.abort(new Error('tick timeout'));
@@ -875,11 +962,10 @@ export class Agent {
875
962
  session.state = result.state;
876
963
  this.sessions.set(laneKey, session);
877
964
  this.recordWakeOutcome(session, classifyWakeOutcome({
878
- lastOutcomeKind: result.ctx
879
- ?.lastOutcome?.kind,
965
+ skippedReason: result.skippedReason,
880
966
  llmCalls: result.metrics?.llmCalls ?? 0,
881
- sentMessage: marks.sent > 0,
882
- taskWrite: marks.tasks > 0,
967
+ sentMessage: marks.sentMessage,
968
+ taskWrite: marks.taskWrite,
883
969
  }), result.metrics?.llmCalls ?? 0);
884
970
  // One line per wake that actually spent LLM budget — the handle for
885
971
  // tracking a hosted agent live (grep `agent=<id>`). Parked no-op ticks
@@ -902,7 +988,7 @@ export class Agent {
902
988
  model: this.options.model ?? 'unknown',
903
989
  cognition: this.options.cognition ?? 'fsm',
904
990
  metrics: result.metrics,
905
- ...workScopeFromWake(event, session.sessionId),
991
+ ...marks.scope,
906
992
  });
907
993
  }
908
994
  catch (err) {
@@ -934,6 +1020,14 @@ export class Agent {
934
1020
  this.unmetTasksPendingAsk.delete(laneKey);
935
1021
  this.askedForAccessLanes.delete(laneKey);
936
1022
  }
1023
+ // The poll can return the row just written by this wake. Only suppress
1024
+ // that exact version while this lane has an explicit future wake: a
1025
+ // successful but unfinished turn still needs the task poll to recover
1026
+ // when it did not schedule its own continuation.
1027
+ if (this.stateTimers.peek(laneKey)?.kind === 'wake-at') {
1028
+ for (const version of marks.planVersions)
1029
+ this.confirmedPlanVersions.mark(JSON.stringify([laneKey, version]));
1030
+ }
937
1031
  }
938
1032
  catch (err) {
939
1033
  this.recordWakeOutcome(session, {
@@ -992,17 +1086,26 @@ export class Agent {
992
1086
  sessionId: record.sessionId,
993
1087
  laneKey: record.laneKey || record.sessionId,
994
1088
  };
1089
+ // The host's own answer comes first, exactly as it does live. A replay that
1090
+ // skipped this would report a model turn the host would never have spent,
1091
+ // which is the opposite of what a recording is for.
1092
+ const suppressed = await this.wakeSuppression(record.event, record.sessionId);
1093
+ if (suppressed) {
1094
+ runtimeLog.info(this.options.name || 'agent', `replay: no wake on ${record.sessionId} — ${suppressed.because}`);
1095
+ return { state: record.state };
1096
+ }
995
1097
  const memory = this.memoryForWake({
996
1098
  sessionId: record.sessionId,
997
1099
  ctx: record.ctx,
998
- });
1100
+ }, record.event);
999
1101
  // Same watchdog as a live wake, for the same reason: an unaborted Claude
1000
1102
  // query outlives the promise that abandoned it and keeps spending.
1001
1103
  const abort = new AbortController();
1104
+ const marks = WakeEffects.forWake(record.event, identity.sessionId);
1002
1105
  let watchdog;
1003
1106
  try {
1004
1107
  const result = await Promise.race([
1005
- this.brain.tick({
1108
+ this.wakeEffects.run(marks, () => this.brain.tick({
1006
1109
  event: record.event,
1007
1110
  ctx: record.ctx,
1008
1111
  state: record.state,
@@ -1011,7 +1114,7 @@ export class Agent {
1011
1114
  memory,
1012
1115
  transitionTap: this.options.transitionTap,
1013
1116
  signal: abort.signal,
1014
- }),
1117
+ })),
1015
1118
  new Promise((_, reject) => {
1016
1119
  watchdog = setTimeout(() => {
1017
1120
  abort.abort(new Error('tick timeout'));
@@ -1027,7 +1130,7 @@ export class Agent {
1027
1130
  model: this.options.model ?? 'unknown',
1028
1131
  cognition: this.options.cognition ?? 'fsm',
1029
1132
  metrics: result.metrics,
1030
- ...workScopeFromWake(record.event, identity.sessionId),
1133
+ ...marks.scope,
1031
1134
  });
1032
1135
  }
1033
1136
  catch (err) {
@@ -1091,7 +1194,7 @@ export class Agent {
1091
1194
  // the agent assigned to itself.
1092
1195
  onTask: async (task) => {
1093
1196
  try {
1094
- await this.wakeTaskAssignment(task.taskId);
1197
+ await this.wakeTaskAssignment(task.taskId, { fromInbox: true });
1095
1198
  }
1096
1199
  catch (err) {
1097
1200
  runtimeLog.warn(this.options.name || 'agent', `inbox task ${task.taskId} could not be woken: ${err.message}`);
@@ -1129,7 +1232,7 @@ export class Agent {
1129
1232
  });
1130
1233
  }
1131
1234
  /** replay task-assigned for an existing active task (demo recovery). */
1132
- async wakeTaskAssignment(taskId) {
1235
+ async wakeTaskAssignment(taskId, options) {
1133
1236
  const agentId = this.options.agentId;
1134
1237
  if (!agentId || !this.taskService) {
1135
1238
  throw new Error('wakeTaskAssignment: agent not configured');
@@ -1146,6 +1249,7 @@ export class Agent {
1146
1249
  }
1147
1250
  await this.eventQueue.enqueue({
1148
1251
  type: 'task_result',
1252
+ ...(options?.fromInbox ? { source: 'active-task-snapshot' } : {}),
1149
1253
  result: {
1150
1254
  ...full,
1151
1255
  taskId: full.taskId ?? taskId,
@@ -1159,8 +1263,9 @@ export class Agent {
1159
1263
  this.started = false;
1160
1264
  this.pollRunner?.stop();
1161
1265
  this.stateTimers.clearAll();
1162
- void this.inboxLoop?.stop();
1266
+ const stopping = this.inboxLoop?.stop() ?? Promise.resolve();
1163
1267
  this.inboxLoop = null;
1268
+ return stopping;
1164
1269
  }
1165
1270
  /**
1166
1271
  * Waits for in-flight ticks to finish. Call before process.exit() on SIGTERM.
@@ -49,6 +49,14 @@ export interface ClaudeBrainOptions {
49
49
  statelessPerWake?: boolean;
50
50
  /** Injectable for tests. Defaults to @anthropic-ai/claude-agent-sdk query(). */
51
51
  queryFn?: typeof query;
52
+ /** Full authoritative agreement read used to recognize listing bookkeeping. */
53
+ readAgreement?: (agreementId: string) => Promise<unknown>;
54
+ /**
55
+ * Opt in to spending a model turn on an activation notice for a hire
56
+ * this agent's counterparty formed. Default is off — the host records a
57
+ * no-op and waits for the task.
58
+ */
59
+ actsOnHireActivation?: boolean;
52
60
  }
53
61
  /**
54
62
  * "the transcript you asked me to resume is not here".
@@ -90,6 +98,8 @@ export declare class ClaudeBrain implements Brain {
90
98
  private maxTurns;
91
99
  private statelessPerWake;
92
100
  private queryFn;
101
+ private readAgreement;
102
+ private actsOnHireActivation;
93
103
  /**
94
104
  * Per-lane tool context. One agent object serves every room, and a
95
105
  * wake's query can still be running when another lane starts. A single
@@ -100,5 +110,5 @@ export declare class ClaudeBrain implements Brain {
100
110
  private toolContextByLane;
101
111
  private sdkMcpByLane;
102
112
  constructor(opts: ClaudeBrainOptions);
103
- tick({ event, ctx, state, identity, eff, signal }: TickInput): Promise<TickOutput>;
113
+ tick({ event, ctx, state, identity, eff, memory, signal }: TickInput): Promise<TickOutput>;
104
114
  }