@ziggs-ai/agent-sdk 0.20.0 → 0.22.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 (61) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/Agent.d.ts +28 -7
  3. package/dist/Agent.js +189 -74
  4. package/dist/brain/ClaudeBrain.d.ts +11 -1
  5. package/dist/brain/ClaudeBrain.js +71 -18
  6. package/dist/brain/ZiggsBrain.d.ts +2 -0
  7. package/dist/brain/ZiggsBrain.js +1 -0
  8. package/dist/brain/claudeCognition.d.ts +1 -1
  9. package/dist/brain/claudeCognition.js +54 -7
  10. package/dist/brain/claudeSdkProtocol.d.ts +1 -1
  11. package/dist/brain/claudeSdkProtocol.js +5 -2
  12. package/dist/brain/claudeSdkTools.d.ts +5 -2
  13. package/dist/brain/claudeSdkTools.js +39 -10
  14. package/dist/brain/hireActivationNotice.d.ts +19 -0
  15. package/dist/brain/hireActivationNotice.js +79 -0
  16. package/dist/brain/standingOfferNotice.d.ts +15 -0
  17. package/dist/brain/standingOfferNotice.js +89 -0
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/ingress/normalizeIncoming.d.ts +3 -0
  21. package/dist/ingress/normalizeIncoming.js +8 -0
  22. package/dist/mcp/createMcpAgent.d.ts +1 -1
  23. package/dist/mcp/createMcpAgent.js +1 -1
  24. package/dist/memory/MemoryStore.d.ts +18 -4
  25. package/dist/memory/MemoryStore.js +52 -12
  26. package/dist/platform/ConnectionPool.d.ts +9 -4
  27. package/dist/platform/ConnectionPool.js +16 -6
  28. package/dist/platform/StateTimers.d.ts +12 -6
  29. package/dist/platform/StateTimers.js +26 -6
  30. package/dist/platform/ZiggsEffectHandler.js +17 -3
  31. package/dist/platform/inboxLoop.d.ts +5 -7
  32. package/dist/platform/inboxLoop.js +36 -80
  33. package/dist/platform/polls/declaredPollRunner.d.ts +7 -1
  34. package/dist/platform/polls/declaredPollRunner.js +7 -9
  35. package/dist/platform/tasks/TaskService.d.ts +23 -1
  36. package/dist/platform/tasks/TaskService.js +8 -2
  37. package/dist/platform/tools/index.d.ts +3 -2
  38. package/dist/platform/tools/index.js +3 -2
  39. package/dist/platform/tools/tier1/protocolRunner.js +8 -1
  40. package/dist/platform/tools/tier1/protocolTools.js +3 -3
  41. package/dist/platform/tools/tier2/contextTools.d.ts +3 -0
  42. package/dist/platform/tools/tier2/contextTools.js +4 -1
  43. package/dist/platform/tools/tier2/planTools.d.ts +20 -0
  44. package/dist/platform/tools/tier2/planTools.js +64 -0
  45. package/dist/platform/tools/tier2/taskTools.d.ts +1 -0
  46. package/dist/platform/tools/tier2/taskTools.js +2 -1
  47. package/dist/pricing/fleetDefaults.js +4 -9
  48. package/dist/pricing/fleetTierByAgentId.gen.d.ts +1 -1
  49. package/dist/pricing/fleetTierByAgentId.gen.js +2 -752
  50. package/dist/runtime/AgentMachine.d.ts +3 -0
  51. package/dist/runtime/AgentMachine.js +6 -1
  52. package/dist/runtime/runTurn.d.ts +1 -0
  53. package/dist/runtime/runTurn.js +19 -15
  54. package/dist/runtime/wakeEffects.d.ts +35 -0
  55. package/dist/runtime/wakeEffects.js +121 -0
  56. package/dist/runtime/wakeOutcome.d.ts +33 -0
  57. package/dist/runtime/wakeOutcome.js +61 -6
  58. package/dist/types.d.ts +13 -0
  59. package/package.json +2 -2
  60. package/dist/pricing/fleetFreeTierA.gen.d.ts +0 -6
  61. package/dist/pricing/fleetFreeTierA.gen.js +0 -11
package/CHANGELOG.md CHANGED
@@ -12,6 +12,11 @@
12
12
 
13
13
  ## Unreleased
14
14
 
15
+ ### Added
16
+
17
+ - `task_cancel` on the hosted Claude surface, same PATCH /tasks/:id/cancel
18
+ as the MCP catalog verb. A held graph withdraws by cancelling the root.
19
+
15
20
  ### Breaking
16
21
 
17
22
  - `AgentHostOptions.questMatchTags` is now `requestMatchTags`. The option is an
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,14 +17,14 @@ 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';
21
24
  import { marketplaceViewTool } from './platform/tools/tier2/marketplaceTools.js';
22
25
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
23
26
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
24
- import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
27
+ import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient, sendChatMessage, presentSendResult, } from '@ziggs-ai/api-client';
25
28
  import { normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
26
29
  import { TaskService } from './platform/tasks/TaskService.js';
27
30
  import { AgreementService } from './platform/agreements/AgreementService.js';
@@ -30,7 +33,8 @@ import { requestExactMatch } from './platform/requestMatch.js';
30
33
  import { SeenMessages } from './platform/SeenMessages.js';
31
34
  import { runtimeLog } from './shared/runtimeLog.js';
32
35
  import { isTelemetryIngestEnabled, sendTelemetry, } from './platform/telemetryIngest.js';
33
- import { buildWakeOutcomePayload, classifyWakeOutcome, } from './runtime/wakeOutcome.js';
36
+ import { buildWakeOutcomePayload, classifyWakeOutcome, wakeWasAsked, } 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,28 @@ 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: the server did not accept this message — nothing was delivered and nobody was woken. Retry with the same messageId, which the backend dedups on.');
320
+ this.outboundMessages.mark(JSON.stringify([result.chatId, result.messageId]));
321
+ // Presented once, here, so every hosted send — FSM reply, brain tool,
322
+ // stream follow-up — reports the destination the same way the MCP
323
+ // surface does.
324
+ return { ...result, ...presentSendResult(result, { creds, surface: 'sdk' }) };
325
+ };
328
326
  this.taskService = services.taskService;
329
327
  this.agreementService = services.agreementService;
330
328
  this.telemetryClient = services.telemetryClient;
@@ -366,12 +364,18 @@ export class Agent {
366
364
  agentId: this.options.agentId,
367
365
  telemetryClient: services.telemetryClient,
368
366
  telemetryIngest: this.options.telemetryIngest,
369
- effectTap: this.options.effectTap,
367
+ effectTap: (effect, response) => {
368
+ const marks = this.wakeEffects.getStore();
369
+ marks?.observe(effect, response);
370
+ for (const key of marks?.sentMessageKeys ?? [])
371
+ this.outboundMessages.mark(key);
372
+ this.options.effectTap?.(effect, response);
373
+ },
370
374
  // `wake_at` reaches the lane's timer through here. The handler
371
375
  // is built once and has no laneKey, so it derives one the same way the
372
376
  // event queue does (see laneKeyFor).
373
377
  wakeScheduler: {
374
- armWakeAt: (sessionId, at, reason) => this.stateTimers.armWakeAt(laneKeyFor(this.options.agentId, sessionId), at, reason),
378
+ armWakeAt: (sessionId, at, reason) => this.stateTimers.armWakeAt(laneKeyFor(this.options.agentId, sessionId), at, reason, this.wakeEffects.getStore()?.scope),
375
379
  },
376
380
  });
377
381
  this.brain = isClaudeCognition(this.options.cognition)
@@ -390,6 +394,7 @@ export class Agent {
390
394
  // services from the effect handler's deps like the FSM path.
391
395
  maxTurns: this.options.claudeMaxTurns,
392
396
  statelessPerWake: this.options.claudeStatelessPerWake,
397
+ actsOnHireActivation: this.options.actsOnHireActivation === true,
393
398
  })
394
399
  : new ZiggsBrain({
395
400
  workflow,
@@ -513,7 +518,7 @@ export class Agent {
513
518
  // the same message being processed twice; leaving it set on a failed wake
514
519
  // would make the redelivery this rejection buys us a no-op.
515
520
  try {
516
- await this.eventQueue.enqueue(normalized.event, laneKey);
521
+ await this.eventQueue.enqueue({ ...normalized.event, chatId: normalized.chatId }, laneKey);
517
522
  }
518
523
  catch (err) {
519
524
  if (messageId)
@@ -542,7 +547,13 @@ export class Agent {
542
547
  const agentId = session.ownAgentId || this.options.agentId;
543
548
  const laneKey = laneKeyFor(agentId, session.sessionId);
544
549
  runtimeLog.info(this.options.name || 'agent', `wake-outcome agent=${agentId} lane=${laneKey} kind=${outcome.kind}` +
545
- (outcome.reason ? ` reason=${outcome.reason}` : ''));
550
+ (outcome.reason ? ` reason=${outcome.reason}` : '') +
551
+ // A question nobody answered is the line worth grepping for, so it is
552
+ // on the line rather than only in the telemetry payload.
553
+ (outcome.reply
554
+ ? ` asked=1 answered=${outcome.reply.sent ? 1 : 0}` +
555
+ (outcome.reply.sent ? '' : ` unanswered=${outcome.reply.reason}`)
556
+ : ''));
546
557
  sendTelemetry(this.telemetryClient, buildWakeOutcomePayload({
547
558
  agentId,
548
559
  sessionId: session.sessionId,
@@ -551,12 +562,28 @@ export class Agent {
551
562
  llmCalls,
552
563
  }), isTelemetryIngestEnabled(this.options.telemetryIngest));
553
564
  }
554
- memoryForWake(session) {
555
- return partitionMemoryStore(this.memory, memorySectionForWake({
565
+ memoryForWake(session, event) {
566
+ const fromEvent = hireIdsFromEvent(event);
567
+ const wakeAgreementIds = uniqueHireIds([
568
+ ...fromEvent,
569
+ session.actingHireId,
570
+ ]);
571
+ const section = memorySectionForWake({
556
572
  sessionId: session.sessionId,
557
573
  activeAgreementId: session.ctx
558
574
  ?.activeAgreementId,
559
- }));
575
+ wakeAgreementIds,
576
+ });
577
+ const known = uniqueHireIds([
578
+ session.ctx
579
+ ?.activeAgreementId,
580
+ ...wakeAgreementIds,
581
+ ]);
582
+ if (known.length === 1)
583
+ session.actingHireId = known[0];
584
+ else if (known.length > 1)
585
+ session.actingHireId = null;
586
+ return partitionMemoryStore(this.memory, section);
560
587
  }
561
588
  /**
562
589
  * A chat-lane session that still carries a cancelled hire from a previous
@@ -585,6 +612,24 @@ export class Agent {
585
612
  // Fail open: a lookup error must not drop a live hire's session.
586
613
  }
587
614
  }
615
+ async formedByForHireActivation(event) {
616
+ const fromWake = formedByFromWake(event);
617
+ if (fromWake)
618
+ return fromWake;
619
+ const agreementId = agreementIdFromWake(event);
620
+ if (!agreementId || !this.agreementService)
621
+ return undefined;
622
+ try {
623
+ const agreement = await this.agreementService.get(agreementId);
624
+ const formedBy = agreement?.formedBy;
625
+ if (formedBy === 'agent' || formedBy === 'principal')
626
+ return formedBy;
627
+ }
628
+ catch {
629
+ // Fail open: a lookup error must not drop a person's store-claim wake.
630
+ }
631
+ return undefined;
632
+ }
588
633
  async laneAgreementIsTerminal(sessionId) {
589
634
  const agreementId = agreementIdFromLane(sessionId);
590
635
  if (!agreementId || !this.agreementService)
@@ -598,6 +643,43 @@ export class Agent {
598
643
  return false;
599
644
  }
600
645
  }
646
+ /**
647
+ * Whether the host answers this wake without spending a model turn.
648
+ *
649
+ * One decision, consulted by the live path and by {@link Agent.replayWake},
650
+ * because a recording is only worth keeping if a replay can tell you what the
651
+ * host would do now. It used to run only on the live path, so an edit to any
652
+ * of these rules was invisible to a replay — the tool that exists to answer
653
+ * "what changed?" could not see the change.
654
+ *
655
+ * Every rule here reads a row or the frame itself. None of them reads a lane,
656
+ * an agent kind, a name or an id shape.
657
+ */
658
+ async wakeSuppression(event, sessionId) {
659
+ if (await this.laneAgreementIsTerminal(sessionId)) {
660
+ return { reason: 'agreement_terminal', because: 'its agreement is terminal' };
661
+ }
662
+ if (this.isSettledTaskEcho(event)) {
663
+ return {
664
+ reason: 'task_already_settled',
665
+ because: 'its task already reported a result',
666
+ };
667
+ }
668
+ if (isHireActivationNotice(event)) {
669
+ const formedBy = await this.formedByForHireActivation(event);
670
+ if (shouldSkipAgentFormedHireActivation({
671
+ event,
672
+ formedBy,
673
+ actsOnHireActivation: this.options.actsOnHireActivation === true,
674
+ })) {
675
+ return {
676
+ reason: 'agent_formed_hire_activation',
677
+ because: 'agent-formed hire activation',
678
+ };
679
+ }
680
+ }
681
+ return null;
682
+ }
601
683
  /**
602
684
  * the trailing turn on work that is already reported.
603
685
  *
@@ -780,14 +862,30 @@ export class Agent {
780
862
  const session = this.sessions.get(laneKey);
781
863
  if (!session?.sessionId)
782
864
  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' });
865
+ // Presentation may call our sender an rpb-* room binding or the person we
866
+ // stand in for. Only an exact (chat, messageId) we successfully sent proves
867
+ // an echo; another message from the same binding still gets a full wake.
868
+ const incoming = event;
869
+ const events = incoming?.type === 'batch' && Array.isArray(incoming.events) ? incoming.events : [event];
870
+ const remaining = events.filter((item) => {
871
+ const e = item;
872
+ const task = e?.type === 'task_result' ? e.result : undefined;
873
+ if (this.stateTimers.peek(laneKey)?.kind === 'wake-at'
874
+ && e?.source === 'active-task-snapshot' && (task?.state === 'active' || task?.state === 'in-progress')
875
+ && task?.assigneeId === this.options.agentId && task.taskId && task.updatedAt && this.confirmedPlanVersions.has(JSON.stringify([laneKey, JSON.stringify([task.taskId, task.updatedAt])])))
876
+ return false;
877
+ return e?.type !== 'message' || !e.messageId || !this.outboundMessages.has(JSON.stringify([e.chatId ?? session.sessionId, e.messageId]));
878
+ });
879
+ if (remaining.length === 0) {
880
+ this.recordWakeOutcome(session, { kind: 'no-op', reason: events.some(e => e?.type === 'task_result') ? 'own_plan_snapshot' : 'own_message_echo' });
786
881
  return;
787
882
  }
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' });
883
+ if (remaining.length !== events.length)
884
+ event = remaining.length === 1 ? remaining[0] : { type: 'batch', events: remaining };
885
+ const suppressed = await this.wakeSuppression(event, session.sessionId);
886
+ if (suppressed) {
887
+ runtimeLog.info(this.options.name || 'agent', `skipping wake on ${session.sessionId} — ${suppressed.because}`);
888
+ this.recordWakeOutcome(session, { kind: 'no-op', reason: suppressed.reason });
791
889
  return;
792
890
  }
793
891
  // Before the tick spends anything: work this agent declared it cannot do
@@ -799,9 +897,10 @@ export class Agent {
799
897
  const frame = this.stateTimers.stripStale(event, laneKey);
800
898
  if (frame === null)
801
899
  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);
900
+ // Traffic resets inactivity timers, but must not cancel an appointment
901
+ // the agent explicitly requested. Only its own fresh timer frame consumes
902
+ // that wake; a notice-only tick cannot ask the model to restore it.
903
+ this.stateTimers.consumeForEvent(frame, laneKey);
805
904
  // Cold-start recovery: restore FSM state from sessionStore when the
806
905
  // in-memory lane has no prior context (e.g. after a container restart).
807
906
  if (this.sessionStore && session.ctx === undefined && session.state === undefined) {
@@ -822,7 +921,7 @@ export class Agent {
822
921
  sessionId: session.sessionId,
823
922
  laneKey,
824
923
  };
825
- const memory = this.memoryForWake(session);
924
+ const memory = this.memoryForWake(session, frame);
826
925
  // Recorded here rather than after the tick: this is the last point where the
827
926
  // input is still exactly what the brain will see, and a wake that goes on to
828
927
  // throw is the one most worth replaying.
@@ -850,20 +949,17 @@ export class Agent {
850
949
  // a zombie — burning tokens and racing the redelivered retry of the same
851
950
  // event (duplicate sends, runaway per-wake input).
852
951
  const tickAbort = new AbortController();
853
- const marks = { sent: 0, tasks: 0 };
952
+ const marks = WakeEffects.forWake(frame, session.sessionId);
854
953
  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);
954
+ const response = await this.eff(effect);
955
+ marks.observe(effect, response);
956
+ for (const key of marks.sentMessageKeys)
957
+ this.outboundMessages.mark(key);
958
+ return response;
863
959
  };
864
960
  try {
865
961
  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 }),
962
+ 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
963
  new Promise((_, reject) => {
868
964
  tickWatchdog = setTimeout(() => {
869
965
  tickAbort.abort(new Error('tick timeout'));
@@ -875,11 +971,11 @@ export class Agent {
875
971
  session.state = result.state;
876
972
  this.sessions.set(laneKey, session);
877
973
  this.recordWakeOutcome(session, classifyWakeOutcome({
878
- lastOutcomeKind: result.ctx
879
- ?.lastOutcome?.kind,
974
+ skippedReason: result.skippedReason,
880
975
  llmCalls: result.metrics?.llmCalls ?? 0,
881
- sentMessage: marks.sent > 0,
882
- taskWrite: marks.tasks > 0,
976
+ sentMessage: marks.sentMessage,
977
+ taskWrite: marks.taskWrite,
978
+ asked: wakeWasAsked(frame, identity.agentId),
883
979
  }), result.metrics?.llmCalls ?? 0);
884
980
  // One line per wake that actually spent LLM budget — the handle for
885
981
  // tracking a hosted agent live (grep `agent=<id>`). Parked no-op ticks
@@ -902,7 +998,7 @@ export class Agent {
902
998
  model: this.options.model ?? 'unknown',
903
999
  cognition: this.options.cognition ?? 'fsm',
904
1000
  metrics: result.metrics,
905
- ...workScopeFromWake(event, session.sessionId),
1001
+ ...marks.scope,
906
1002
  });
907
1003
  }
908
1004
  catch (err) {
@@ -934,6 +1030,14 @@ export class Agent {
934
1030
  this.unmetTasksPendingAsk.delete(laneKey);
935
1031
  this.askedForAccessLanes.delete(laneKey);
936
1032
  }
1033
+ // The poll can return the row just written by this wake. Only suppress
1034
+ // that exact version while this lane has an explicit future wake: a
1035
+ // successful but unfinished turn still needs the task poll to recover
1036
+ // when it did not schedule its own continuation.
1037
+ if (this.stateTimers.peek(laneKey)?.kind === 'wake-at') {
1038
+ for (const version of marks.planVersions)
1039
+ this.confirmedPlanVersions.mark(JSON.stringify([laneKey, version]));
1040
+ }
937
1041
  }
938
1042
  catch (err) {
939
1043
  this.recordWakeOutcome(session, {
@@ -992,17 +1096,26 @@ export class Agent {
992
1096
  sessionId: record.sessionId,
993
1097
  laneKey: record.laneKey || record.sessionId,
994
1098
  };
1099
+ // The host's own answer comes first, exactly as it does live. A replay that
1100
+ // skipped this would report a model turn the host would never have spent,
1101
+ // which is the opposite of what a recording is for.
1102
+ const suppressed = await this.wakeSuppression(record.event, record.sessionId);
1103
+ if (suppressed) {
1104
+ runtimeLog.info(this.options.name || 'agent', `replay: no wake on ${record.sessionId} — ${suppressed.because}`);
1105
+ return { state: record.state };
1106
+ }
995
1107
  const memory = this.memoryForWake({
996
1108
  sessionId: record.sessionId,
997
1109
  ctx: record.ctx,
998
- });
1110
+ }, record.event);
999
1111
  // Same watchdog as a live wake, for the same reason: an unaborted Claude
1000
1112
  // query outlives the promise that abandoned it and keeps spending.
1001
1113
  const abort = new AbortController();
1114
+ const marks = WakeEffects.forWake(record.event, identity.sessionId);
1002
1115
  let watchdog;
1003
1116
  try {
1004
1117
  const result = await Promise.race([
1005
- this.brain.tick({
1118
+ this.wakeEffects.run(marks, () => this.brain.tick({
1006
1119
  event: record.event,
1007
1120
  ctx: record.ctx,
1008
1121
  state: record.state,
@@ -1011,7 +1124,7 @@ export class Agent {
1011
1124
  memory,
1012
1125
  transitionTap: this.options.transitionTap,
1013
1126
  signal: abort.signal,
1014
- }),
1127
+ })),
1015
1128
  new Promise((_, reject) => {
1016
1129
  watchdog = setTimeout(() => {
1017
1130
  abort.abort(new Error('tick timeout'));
@@ -1027,7 +1140,7 @@ export class Agent {
1027
1140
  model: this.options.model ?? 'unknown',
1028
1141
  cognition: this.options.cognition ?? 'fsm',
1029
1142
  metrics: result.metrics,
1030
- ...workScopeFromWake(record.event, identity.sessionId),
1143
+ ...marks.scope,
1031
1144
  });
1032
1145
  }
1033
1146
  catch (err) {
@@ -1091,7 +1204,7 @@ export class Agent {
1091
1204
  // the agent assigned to itself.
1092
1205
  onTask: async (task) => {
1093
1206
  try {
1094
- await this.wakeTaskAssignment(task.taskId);
1207
+ await this.wakeTaskAssignment(task.taskId, { fromInbox: true });
1095
1208
  }
1096
1209
  catch (err) {
1097
1210
  runtimeLog.warn(this.options.name || 'agent', `inbox task ${task.taskId} could not be woken: ${err.message}`);
@@ -1129,7 +1242,7 @@ export class Agent {
1129
1242
  });
1130
1243
  }
1131
1244
  /** replay task-assigned for an existing active task (demo recovery). */
1132
- async wakeTaskAssignment(taskId) {
1245
+ async wakeTaskAssignment(taskId, options) {
1133
1246
  const agentId = this.options.agentId;
1134
1247
  if (!agentId || !this.taskService) {
1135
1248
  throw new Error('wakeTaskAssignment: agent not configured');
@@ -1146,6 +1259,7 @@ export class Agent {
1146
1259
  }
1147
1260
  await this.eventQueue.enqueue({
1148
1261
  type: 'task_result',
1262
+ ...(options?.fromInbox ? { source: 'active-task-snapshot' } : {}),
1149
1263
  result: {
1150
1264
  ...full,
1151
1265
  taskId: full.taskId ?? taskId,
@@ -1159,8 +1273,9 @@ export class Agent {
1159
1273
  this.started = false;
1160
1274
  this.pollRunner?.stop();
1161
1275
  this.stateTimers.clearAll();
1162
- void this.inboxLoop?.stop();
1276
+ const stopping = this.inboxLoop?.stop() ?? Promise.resolve();
1163
1277
  this.inboxLoop = null;
1278
+ return stopping;
1164
1279
  }
1165
1280
  /**
1166
1281
  * 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
  }