@ziggs-ai/agent-sdk 0.10.0 → 0.10.1

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 (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/AgentHost.d.ts +50 -14
  3. package/dist/AgentHost.js +129 -7
  4. package/dist/agent/ClaudeSdkAgent.d.ts +23 -1
  5. package/dist/agent/ClaudeSdkAgent.js +145 -75
  6. package/dist/context/applyEffects.js +4 -3
  7. package/dist/index.d.ts +3 -2
  8. package/dist/index.js +3 -2
  9. package/dist/runtime/bootstrapCtx.js +6 -8
  10. package/dist/runtime/buildOutcome.d.ts +9 -1
  11. package/dist/runtime/buildOutcome.js +29 -6
  12. package/dist/runtime/taskIntake.js +6 -4
  13. package/dist/runtime/wakeWriteKeys.js +14 -21
  14. package/dist/server/ConnectionPool.d.ts +10 -52
  15. package/dist/server/ConnectionPool.js +26 -57
  16. package/dist/server/EventQueue.d.ts +6 -0
  17. package/dist/server/EventQueue.js +21 -6
  18. package/dist/server/SeenMessages.d.ts +7 -0
  19. package/dist/server/SeenMessages.js +15 -0
  20. package/dist/server/ZiggsEffectHandler.d.ts +1 -0
  21. package/dist/server/ZiggsEffectHandler.js +14 -7
  22. package/dist/server/inboxLoop.d.ts +17 -2
  23. package/dist/server/inboxLoop.js +92 -16
  24. package/dist/server/polls/PollKind.d.ts +72 -0
  25. package/dist/server/polls/PollKind.js +53 -0
  26. package/dist/server/polls/PollRunner.d.ts +62 -0
  27. package/dist/server/polls/PollRunner.js +138 -0
  28. package/dist/server/questMatch.d.ts +5 -0
  29. package/dist/server/questMatch.js +14 -0
  30. package/dist/server/runLauncher.d.ts +0 -6
  31. package/dist/server/runLauncher.js +54 -27
  32. package/dist/server/tools/tier1/protocolRunner.d.ts +3 -1
  33. package/dist/server/tools/tier1/protocolRunner.js +24 -28
  34. package/dist/server/tools/tier1/protocolTools.js +23 -13
  35. package/dist/server/tools/tier2/connectionTools.js +12 -1
  36. package/dist/tasks/protocolRegistry.d.ts +27 -0
  37. package/dist/tasks/protocolRegistry.js +36 -0
  38. package/dist/types.d.ts +0 -9
  39. package/package.json +2 -2
  40. package/dist/server/staleBacklogGate.d.ts +0 -57
  41. package/dist/server/staleBacklogGate.js +0 -100
@@ -1,5 +1,5 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
- import { outcomeFromEvent } from '../runtime/buildOutcome.js';
2
+ import { isMessageAddressedElsewhere, outcomeFromEvent } from '../runtime/buildOutcome.js';
3
3
  import { getBatchEvents } from '../context/batch.js';
4
4
  import { bootstrapCtx } from '../runtime/bootstrapCtx.js';
5
5
  import { createWakeWriteKeys } from '../runtime/wakeWriteKeys.js';
@@ -20,12 +20,18 @@ import { runtimeLog } from '../shared/runtimeLog.js';
20
20
  * `notification` only.
21
21
  *
22
22
  * Empty / missing batch → notice-only (do not burn a turn on a malformed wake).
23
+ *
24
+ * A message addressed to a DIFFERENT party is notice-only too (attention
25
+ * gate): the sender named who should act, and it wasn't this agent. The
26
+ * message still lands in chat history and arrives as context on the next
27
+ * real wake — skipped here is the LLM turn, not the information.
23
28
  */
24
- export function isNoticeOnlyWake(event) {
29
+ export function isNoticeOnlyWake(event, ownAgentId) {
25
30
  const events = getBatchEvents(event);
26
31
  if (events.length === 0)
27
32
  return true;
28
- return events.every((ev) => ev?.type === 'notification');
33
+ return events.every((ev) => ev?.type === 'notification' ||
34
+ isMessageAddressedElsewhere(ev, ownAgentId));
29
35
  }
30
36
  const DEFAULT_STATE = 'ready';
31
37
  /**
@@ -62,6 +68,20 @@ const MAX_WAKES_PER_WINDOW = Number(process.env.CLAUDE_SDK_MAX_WAKES_PER_WINDOW
62
68
  * discard. Env-overridable. Kept below MAX_TASK_INPUT_TOKENS so a lane resets
63
69
  * before it can approach the per-wake abort. */
64
70
  const MAX_SESSION_RESUME_INPUT_TOKENS = Number(process.env.CLAUDE_SDK_MAX_SESSION_INPUT_TOKENS ?? 200_000);
71
+ /**
72
+ * ZIG-1191 — "the transcript you asked me to resume is not here".
73
+ *
74
+ * Claude Code answers a `resume` of an unknown id with this, and it is the only
75
+ * failure that a fresh session reliably fixes: the id is valid-looking but its
76
+ * transcript lived on a container that is gone. Matched on the message because
77
+ * that is all the CLI gives us — no code, no typed error — so keep it narrow
78
+ * enough that a different failure never claims the retry.
79
+ */
80
+ export function isMissingSessionError(err) {
81
+ const message = err?.message;
82
+ return (typeof message === 'string' &&
83
+ /no conversation found with session id/i.test(message));
84
+ }
65
85
  /** Total billable input for one API response (base + cache read + cache write). */
66
86
  function billableInput(usage) {
67
87
  if (!usage)
@@ -164,6 +184,13 @@ export class ClaudeSdkAgent {
164
184
  maxTurns;
165
185
  statelessPerWake;
166
186
  queryFn;
187
+ /**
188
+ * ZIG-1120 #7 — tool set is fixed at host construction; rebuilding the
189
+ * in-process MCP server (+ zod schema tree) every wake was pure waste.
190
+ * A mutable holder supplies per-wake context without closing over `tick`.
191
+ */
192
+ toolContextHolder = { current: null };
193
+ sdkMcpServer = null;
167
194
  constructor(opts) {
168
195
  if (!opts.workflow?.states)
169
196
  throw new Error('ClaudeSdkAgent: workflow with states is required');
@@ -209,7 +236,7 @@ export class ClaudeSdkAgent {
209
236
  };
210
237
  }
211
238
  nextCtx.recentWakes = [...recentWakes, now];
212
- if (isNoticeOnlyWake(rawEvent)) {
239
+ if (isNoticeOnlyWake(rawEvent, identity.agentId)) {
213
240
  runtimeLog.info('ClaudeSdkAgent', `notice-only wake — skipping LLM session=${identity.sessionId} agent=${this.agentId} ` +
214
241
  `type=${rawEvent?.type ?? 'unknown'}`);
215
242
  return {
@@ -222,9 +249,13 @@ export class ClaudeSdkAgent {
222
249
  }
223
250
  let contextSnap = null;
224
251
  try {
252
+ // ZIG-1120 #5: pre-read is for wake gating + summarizeContext (a count) +
253
+ // onBehalf agreement resolution — same 12-message cap as the tool path.
254
+ // Uncapped pulls burned 35–47k input tokens/wake on long chats.
225
255
  contextSnap = await eff({
226
256
  kind: 'read-context',
227
257
  sessionId: identity.sessionId,
258
+ opts: { maxMessages: 12 },
228
259
  });
229
260
  }
230
261
  catch (err) {
@@ -287,13 +318,22 @@ export class ClaudeSdkAgent {
287
318
  runId,
288
319
  wakeKeys,
289
320
  };
290
- const availableTools = await this.toolManager.getAvailableTools();
291
- const mcpServer = buildZiggsSdkMcpServer(availableTools, () => toolContext, {
292
- name: 'ziggs',
293
- instructions: 'In-process Ziggs tools backed by api-client. No external MCP process.',
294
- });
321
+ this.toolContextHolder.current = toolContext;
322
+ if (!this.sdkMcpServer) {
323
+ const availableTools = await this.toolManager.getAvailableTools();
324
+ this.sdkMcpServer = buildZiggsSdkMcpServer(availableTools, () => {
325
+ const ctx = this.toolContextHolder.current;
326
+ if (!ctx) {
327
+ throw new Error('ClaudeSdkAgent: tool context missing for in-process MCP call');
328
+ }
329
+ return ctx;
330
+ }, {
331
+ name: 'ziggs',
332
+ instructions: 'In-process Ziggs tools backed by api-client. No external MCP process.',
333
+ });
334
+ }
295
335
  const prompt = buildClaudeEventPrompt(rawEvent, summarizeContext(contextSnap));
296
- const mcpServers = { ziggs: mcpServer };
336
+ const mcpServers = { ziggs: this.sdkMcpServer };
297
337
  assertHostedAgentZiggsTools(mcpServers);
298
338
  const queryOptions = claudeQueryOptions({
299
339
  systemPrompt,
@@ -306,80 +346,110 @@ export class ClaudeSdkAgent {
306
346
  if (line)
307
347
  runtimeLog.warn('ClaudeSdkAgent', `stderr session=${identity.sessionId}: ${line}`);
308
348
  },
309
- // ZIG-860: stateless-per-wake never resumes — each wake is a fresh
310
- // transcript, so per-wake input stays flat instead of replaying the
311
- // whole lane history every turn.
312
- resume: this.statelessPerWake ? undefined : nextCtx.claudeSessionId,
313
349
  });
314
350
  let metrics = { llmCalls: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
315
351
  let sessionId = nextCtx.claudeSessionId;
316
352
  let spentInput = 0;
317
- // Host watchdog abort (see TickInput.signal): stop consuming AND interrupt
318
- // the query, so a timed-out wake never lingers as a token-burning zombie
319
- // racing its own redelivered retry. Breaking the for-await returns the
320
- // generator; interrupt() additionally stops a query blocked mid-turn.
321
- const query = this.queryFn({ prompt, options: queryOptions });
322
- const onAbort = () => {
323
- void query.interrupt?.()?.catch(() => { });
324
- };
325
- if (signal?.aborted)
326
- onAbort();
327
- else
328
- signal?.addEventListener('abort', onAbort, { once: true });
329
- try {
330
- for await (const message of query) {
331
- if (signal?.aborted) {
332
- runtimeLog.warn('ClaudeSdkAgent', `host watchdog aborted wake session=${identity.sessionId} agent=${this.agentId} — stopping query`);
333
- break;
334
- }
335
- if ('session_id' in message && typeof message.session_id === 'string') {
336
- sessionId = message.session_id;
337
- }
338
- // Surface the model's own prose so a hired Claude agent is as legible as
339
- // an FSM agent (ZIG-787). Each completed assistant turn's text is recorded
340
- // as a `thought` — the SAME agent-private channel the FSM uses for its
341
- // reasoning (runTurn `emitThought` → record-event → recordThought), so it
342
- // shows in the hired-agent view without cluttering the counterparty chat.
343
- // Pure tool-use turns carry no text and record nothing.
344
- //
345
- // Fire-and-forget on purpose: this runs inside `for await (query())`, so
346
- // awaiting an artifacts round-trip per turn would couple model-stream
347
- // consumption to the artifacts backend — a slow/wedged backend would stall
348
- // the whole wake, not just fail a breadcrumb. Recording is best-effort and
349
- // thought ordering is not load-bearing, so we let it run in the background
350
- // and swallow failures with a warn.
351
- if (message.type === 'assistant') {
352
- const text = extractAssistantText(message).trim();
353
- if (text) {
354
- // Ordinal drawn synchronously so the fire-and-forget below can't
355
- // reorder key assignment against other writes of this wake.
356
- const idempotencyKey = wakeKeys.artifactKey({ kind: 'thought', text });
357
- void eff({ kind: 'record-event', sessionId: identity.sessionId, entry: { kind: 'thought', text }, idempotencyKey })
358
- .catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record thought failed session=${identity.sessionId}: ${err.message}`));
353
+ // ZIG-860: stateless-per-wake never resumes — each wake is a fresh
354
+ // transcript, so per-wake input stays flat instead of replaying the whole
355
+ // lane history every turn.
356
+ const resumeId = this.statelessPerWake ? undefined : nextCtx.claudeSessionId;
357
+ const consumeQuery = async (resume) => {
358
+ // Host watchdog abort (see TickInput.signal): stop consuming AND interrupt
359
+ // the query, so a timed-out wake never lingers as a token-burning zombie
360
+ // racing its own redelivered retry. Breaking the for-await returns the
361
+ // generator; interrupt() additionally stops a query blocked mid-turn.
362
+ const query = this.queryFn({ prompt, options: { ...queryOptions, resume } });
363
+ const onAbort = () => {
364
+ void query.interrupt?.()?.catch(() => { });
365
+ };
366
+ if (signal?.aborted)
367
+ onAbort();
368
+ else
369
+ signal?.addEventListener('abort', onAbort, { once: true });
370
+ try {
371
+ for await (const message of query) {
372
+ if (signal?.aborted) {
373
+ runtimeLog.warn('ClaudeSdkAgent', `host watchdog aborted wake session=${identity.sessionId} agent=${this.agentId} — stopping query`);
374
+ break;
375
+ }
376
+ if ('session_id' in message && typeof message.session_id === 'string') {
377
+ sessionId = message.session_id;
378
+ }
379
+ // Surface the model's own prose so a hired Claude agent is as legible as
380
+ // an FSM agent (ZIG-787). Each completed assistant turn's text is recorded
381
+ // as a `thought` — the SAME agent-private channel the FSM uses for its
382
+ // reasoning (runTurn `emitThought` → record-event → recordThought), so it
383
+ // shows in the hired-agent view without cluttering the counterparty chat.
384
+ // Pure tool-use turns carry no text and record nothing.
385
+ //
386
+ // Fire-and-forget on purpose: this runs inside `for await (query())`, so
387
+ // awaiting an artifacts round-trip per turn would couple model-stream
388
+ // consumption to the artifacts backend — a slow/wedged backend would stall
389
+ // the whole wake, not just fail a breadcrumb. Recording is best-effort and
390
+ // thought ordering is not load-bearing, so we let it run in the background
391
+ // and swallow failures with a warn.
392
+ if (message.type === 'assistant') {
393
+ const text = extractAssistantText(message).trim();
394
+ if (text) {
395
+ // Ordinal drawn synchronously so the fire-and-forget below can't
396
+ // reorder key assignment against other writes of this wake.
397
+ const idempotencyKey = wakeKeys.artifactKey({ kind: 'thought', text });
398
+ void eff({ kind: 'record-event', sessionId: identity.sessionId, entry: { kind: 'thought', text }, idempotencyKey })
399
+ .catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record thought failed session=${identity.sessionId}: ${err.message}`));
400
+ }
401
+ }
402
+ const m = extractMetrics(message);
403
+ if (m)
404
+ metrics = m;
405
+ if (message.type === 'result' && message.subtype !== 'success') {
406
+ runtimeLog.warn('ClaudeSdkAgent', `query ended without success subtype=${message.subtype} session=${identity.sessionId}`);
407
+ }
408
+ // Runaway guard (ZIG-759): abort before an unbounded loop drains the
409
+ // shared fleet key. Breaking the for-await calls the generator's return(),
410
+ // which terminates the underlying query.
411
+ spentInput += billableInput(usageOf(message));
412
+ if (spentInput > MAX_TASK_INPUT_TOKENS) {
413
+ runtimeLog.warn('ClaudeSdkAgent', `input-token budget exceeded (~${spentInput} > ${MAX_TASK_INPUT_TOKENS}) session=${identity.sessionId} agent=${this.agentId} — aborting query to cap spend (ZIG-759)`);
414
+ break;
359
415
  }
360
416
  }
361
- const m = extractMetrics(message);
362
- if (m)
363
- metrics = m;
364
- if (message.type === 'result' && message.subtype !== 'success') {
365
- runtimeLog.warn('ClaudeSdkAgent', `query ended without success subtype=${message.subtype} session=${identity.sessionId}`);
366
- }
367
- // Runaway guard (ZIG-759): abort before an unbounded loop drains the
368
- // shared fleet key. Breaking the for-await calls the generator's return(),
369
- // which terminates the underlying query.
370
- spentInput += billableInput(usageOf(message));
371
- if (spentInput > MAX_TASK_INPUT_TOKENS) {
372
- runtimeLog.warn('ClaudeSdkAgent', `input-token budget exceeded (~${spentInput} > ${MAX_TASK_INPUT_TOKENS}) session=${identity.sessionId} agent=${this.agentId} — aborting query to cap spend (ZIG-759)`);
373
- break;
374
- }
375
417
  }
418
+ finally {
419
+ signal?.removeEventListener('abort', onAbort);
420
+ }
421
+ };
422
+ try {
423
+ await consumeQuery(resumeId);
376
424
  }
377
425
  catch (err) {
378
- runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${err.message}`);
379
- throw err;
380
- }
381
- finally {
382
- signal?.removeEventListener('abort', onAbort);
426
+ // ZIG-1191: the resumed transcript is gone. Claude Code stores transcripts
427
+ // on the container's own disk while the session id outlives it — persisted
428
+ // per lane in the session store with a 24h TTL — so a redeployed agents
429
+ // task inherits an id pointing at a file that no longer exists. Every wake
430
+ // on that lane then failed identically for as long as the TTL held, and the
431
+ // human saw one "Something went wrong. Please try again." per attempt.
432
+ //
433
+ // Retrying here is safe precisely because this failure happens BEFORE any
434
+ // turn runs: nothing was emitted, no tool ran, no idempotency ordinal was
435
+ // spent. That is why the retry is scoped to this one error and to one
436
+ // attempt — a mid-wake failure must keep propagating, since its partial
437
+ // writes are already out.
438
+ if (!isMissingSessionError(err) || !resumeId) {
439
+ runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${err.message}`);
440
+ throw err;
441
+ }
442
+ runtimeLog.info('ClaudeSdkAgent', `resumed Claude session ${resumeId} no longer exists (container replaced?) ` +
443
+ `session=${identity.sessionId} agent=${this.agentId} — retrying once on a fresh session (ZIG-1191)`);
444
+ nextCtx.claudeSessionId = undefined;
445
+ sessionId = undefined;
446
+ try {
447
+ await consumeQuery(undefined);
448
+ }
449
+ catch (retryErr) {
450
+ runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${retryErr.message}`);
451
+ throw retryErr;
452
+ }
383
453
  }
384
454
  // Stateless-per-wake keeps no session id — clear any carried over from the
385
455
  // prior ctx so the next wake starts fresh too (ZIG-860). Otherwise remember
@@ -21,10 +21,11 @@ function applyTaskOperationResultToContext(updates, operation, result) {
21
21
  }
22
22
  }
23
23
  if (operation === 'task-update') {
24
- const status = (task?.['state'] || r['state'] || r['status']);
25
- if (status === 'completed')
24
+ // `state` only — ZIG-1057 cut `status` from the wire (ZIG-1128).
25
+ const state = (task?.['state'] || r['state']);
26
+ if (state === 'completed')
26
27
  updates['taskCompleted'] = true;
27
- if (status === 'failed')
28
+ if (state === 'failed')
28
29
  updates['taskFailed'] = true;
29
30
  }
30
31
  if (operation === 'agreement-respond')
package/dist/index.d.ts CHANGED
@@ -41,7 +41,6 @@ export { default } from './AgentHost.js';
41
41
  export { validateContext, ContextWarning } from './cognition/validateContext.js';
42
42
  export { runLauncher } from './server/runLauncher.js';
43
43
  export { ConnectionPool } from './server/ConnectionPool.js';
44
- export { createStaleBacklogGate, resolveReplayPolicy, newestActionableAt, parseReplayMaxAge, } from './server/staleBacklogGate.js';
45
44
  export { decodeOperatorKeyClaims, boundKeyFleetWarning } from './shared/operatorKeyClaims.js';
46
45
  export { createHealthServer } from './server/createHealthServer.js';
47
46
  export { TaskService } from './server/tasks/TaskService.js';
@@ -55,6 +54,8 @@ export { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
55
54
  export { extractJSON, safeParseJSON } from './utils/jsonExtractor.js';
56
55
  export { HistoryFormatter, historyFormatter, AgreementFormatter, agreementFormatter } from './formatters/index.js';
57
56
  export { ProactiveTrigger } from './server/proactive/ProactiveTrigger.js';
57
+ export { PollRegistry, declaredPolls, type PollBinding, type PollContext, type PollKind, type PollResult, } from './server/polls/PollKind.js';
58
+ export { PollRunner, type PollRunnerOptions, type WakeTarget } from './server/polls/PollRunner.js';
58
59
  export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreementRespondTool, agreementClaimTool, agreementCounterTool, agreementGetTool, agreementRevokeTool, agreementFulfillTool, taskCreateTool, taskSetResultTool, taskUpdatePlanStepTool, dispatchProtocolOp, PAYMENT_TOOLS, paymentBalanceTool, paymentTransferTool, paymentHoldTool, paymentReleaseTool, paymentResolveWalletTool, CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, DISCOVERY_TOOLS, agentSearchTool, agentGetTool, MARKETPLACE_TOOLS, marketplaceViewTool, CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, GRANTS_TOOLS, listGrantsTool, LINK_TOOLS, linkCreateInviteTool, linkListTool, ARTIFACT_TOOLS, recordArtifactTool, CHAT_TOOLS, openConversationTool, } from './server/tools/index.js';
59
60
  export { CLAUDE_SDK_TOOLS } from './agent/claudeSdkTools.js';
60
61
  export { PROTOCOL_TOOL_NAMES, PROTOCOL_TOOL_TO_OPERATION, mapProtocolToolToOperation, isProtocolToolName } from './tasks/protocolRegistry.js';
@@ -65,7 +66,7 @@ export { classifyIncomingEvent } from './context/classifyEnvelope.js';
65
66
  export { buildContextUpdates, CONTEXT_RESET } from './context/applyEffects.js';
66
67
  export { getBatchEvents, isTaskResultRelevantToAgent } from './context/batch.js';
67
68
  export { classifyWorkflowEvent, findTaskResult, unwrapBatchEvent, findIncomingTaskResult } from './context/routingLabels.js';
68
- export { MessagesClient, ArtifactsClient, AgentSearchClient, getBackendUrl, getWebSocketUrl, } from '@ziggs-ai/api-client';
69
+ export { MessagesClient, ArtifactsClient, AgentSearchClient, getBackendUrl, } from '@ziggs-ai/api-client';
69
70
  export { SeenMessages } from './server/SeenMessages.js';
70
71
  export { runtimeLog, resetRuntimeLogLevelCache } from './shared/runtimeLog.js';
71
72
  export type { RuntimeLogLevel } from './shared/runtimeLog.js';
package/dist/index.js CHANGED
@@ -35,7 +35,6 @@ export { default } from './AgentHost.js';
35
35
  export { validateContext, ContextWarning } from './cognition/validateContext.js';
36
36
  export { runLauncher } from './server/runLauncher.js';
37
37
  export { ConnectionPool } from './server/ConnectionPool.js';
38
- export { createStaleBacklogGate, resolveReplayPolicy, newestActionableAt, parseReplayMaxAge, } from './server/staleBacklogGate.js';
39
38
  export { decodeOperatorKeyClaims, boundKeyFleetWarning } from './shared/operatorKeyClaims.js';
40
39
  export { createHealthServer } from './server/createHealthServer.js';
41
40
  export { TaskService } from './server/tasks/TaskService.js';
@@ -48,6 +47,8 @@ export { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
48
47
  export { extractJSON, safeParseJSON } from './utils/jsonExtractor.js';
49
48
  export { HistoryFormatter, historyFormatter, AgreementFormatter, agreementFormatter } from './formatters/index.js';
50
49
  export { ProactiveTrigger } from './server/proactive/ProactiveTrigger.js';
50
+ export { PollRegistry, declaredPolls, } from './server/polls/PollKind.js';
51
+ export { PollRunner } from './server/polls/PollRunner.js';
51
52
  // ZIG-894 — the hand-rolled ZiggsPayClient / ZiggsConnectClient were deleted;
52
53
  // payment and connection tools now use @ziggs-ai/api-client's PaymentsClient /
53
54
  // ConnectionsClient, the same clients ziggs-mcp uses.
@@ -70,6 +71,6 @@ export { classifyIncomingEvent } from './context/classifyEnvelope.js';
70
71
  export { buildContextUpdates, CONTEXT_RESET } from './context/applyEffects.js';
71
72
  export { getBatchEvents, isTaskResultRelevantToAgent } from './context/batch.js';
72
73
  export { classifyWorkflowEvent, findTaskResult, unwrapBatchEvent, findIncomingTaskResult } from './context/routingLabels.js';
73
- export { MessagesClient, ArtifactsClient, AgentSearchClient, getBackendUrl, getWebSocketUrl, } from '@ziggs-ai/api-client';
74
+ export { MessagesClient, ArtifactsClient, AgentSearchClient, getBackendUrl, } from '@ziggs-ai/api-client';
74
75
  export { SeenMessages } from './server/SeenMessages.js';
75
76
  export { runtimeLog, resetRuntimeLogLevelCache } from './shared/runtimeLog.js';
@@ -18,14 +18,15 @@ import { migrateLegacyTaskFields } from './taskBookkeeping.js';
18
18
  export function bootstrapCtx(identity, existing) {
19
19
  if (existing) {
20
20
  const legacy = existing;
21
+ // ZIG-1128: the lane-level mirrors are destructured OFF, not carried through.
22
+ // `migrateLegacyTaskFields` has already folded whatever they held into
23
+ // `tasks`; keeping the empty husks on the restored Ctx is what let a live FSM
24
+ // guard read one and see "nothing delegated" forever.
25
+ const { pendingSubtasks: _pendingSubtasks, collectedResults: _collectedResults, delegatedAgreementIds: _delegatedAgreementIds, ...rest } = existing;
21
26
  return {
22
- ...existing,
27
+ ...rest,
23
28
  identity,
24
29
  tasks: migrateLegacyTaskFields(legacy),
25
- // ZIG-1057: deprecated mirrors stay empty; use all*() readers.
26
- pendingSubtasks: {},
27
- collectedResults: [],
28
- delegatedAgreementIds: [],
29
30
  };
30
31
  }
31
32
  return {
@@ -34,9 +35,6 @@ export function bootstrapCtx(identity, existing) {
34
35
  activeAgreementId: null,
35
36
  activeTaskId: null,
36
37
  pendingProposalAgreementId: null,
37
- pendingSubtasks: {},
38
- collectedResults: [],
39
- delegatedAgreementIds: [],
40
38
  toolResults: [],
41
39
  lastOutcome: { kind: 'enter' },
42
40
  };
@@ -3,7 +3,6 @@ import type { Action, AgreementRef, Ctx, Outcome, TaskRef, ToolResultEntry } fro
3
3
  export interface WireResult {
4
4
  taskId?: string;
5
5
  state?: string;
6
- status?: string;
7
6
  agreement?: AgreementRef;
8
7
  agreementId?: string;
9
8
  proposal?: {
@@ -37,6 +36,15 @@ export interface EmittedEvent {
37
36
  senderId?: string;
38
37
  [key: string]: unknown;
39
38
  }
39
+ /**
40
+ * A chat message whose stated receiver is a DIFFERENT party. `receiverId` is
41
+ * the sender's addressing decision, carried on every wire frame; the
42
+ * human-broadcast sentinel counts as "elsewhere" for an agent (a broadcast is
43
+ * addressed to the room's humans). A frame with NO receiver keeps the turn —
44
+ * we cannot know it is not ours, and skipping on absence would deafen agents
45
+ * to malformed or legacy frames.
46
+ */
47
+ export declare function isMessageAddressedElsewhere(ev: RawEvent | null | undefined, ownAgentId: string | null | undefined): boolean;
40
48
  /**
41
49
  * Converts a raw incoming event into the single Outcome that re-enters the
42
50
  * machine.
@@ -1,6 +1,21 @@
1
1
  import { isProtocolToolName, mapProtocolToolToOperation } from '../tasks/protocolRegistry.js';
2
2
  import { getBatchEvents, isTaskResultRelevantToAgent } from '../context/batch.js';
3
3
  import { bookForDelegation, ensureTaskBook, findBookHoldingPending, removeTaskBook, } from './taskBookkeeping.js';
4
+ /**
5
+ * A chat message whose stated receiver is a DIFFERENT party. `receiverId` is
6
+ * the sender's addressing decision, carried on every wire frame; the
7
+ * human-broadcast sentinel counts as "elsewhere" for an agent (a broadcast is
8
+ * addressed to the room's humans). A frame with NO receiver keeps the turn —
9
+ * we cannot know it is not ours, and skipping on absence would deafen agents
10
+ * to malformed or legacy frames.
11
+ */
12
+ export function isMessageAddressedElsewhere(ev, ownAgentId) {
13
+ return Boolean(ev &&
14
+ ev.type === 'message' &&
15
+ ownAgentId &&
16
+ ev.receiverId &&
17
+ ev.receiverId !== ownAgentId);
18
+ }
4
19
  // ── outcomeFromEvent: incoming wire event → Outcome ──────────────────────────
5
20
  /**
6
21
  * Converts a raw incoming event into the single Outcome that re-enters the
@@ -139,6 +154,14 @@ function singleEventToOutcome(ev, ownAgentId) {
139
154
  // Suppress our own messages echoed back through the chat fanout.
140
155
  if (ownAgentId && ev.senderId && ev.senderId === ownAgentId)
141
156
  return null;
157
+ // Attention gate: a message addressed to someone else is room context, not
158
+ // a turn. Delivery is audience-based on purpose (everyone in the chat hears
159
+ // everything), so in a multi-agent room every agent used to burn an LLM turn
160
+ // per utterance just to decide it wasn't theirs — the sender already decided
161
+ // that by naming a receiver. The message still lands in chat history and
162
+ // rides in on the next wake's read-back; nothing is lost but the interrupt.
163
+ if (isMessageAddressedElsewhere(ev, ownAgentId))
164
+ return null;
142
165
  const text = typeof ev.text === 'string' ? ev.text : undefined;
143
166
  const senderType = typeof ev.senderType === 'string' ? ev.senderType : undefined;
144
167
  return { kind: 'message-received', text, senderId: ev.senderId, senderType };
@@ -286,8 +309,9 @@ function inferKindFromProtocolOp(op, result) {
286
309
  return 'task-delegated';
287
310
  }
288
311
  if (op === 'task-update') {
289
- const status = task?.state || r.state || r.status;
290
- if (status === 'completed' || status === 'failed')
312
+ // Same resolution as `isStateChangingProtocolEvent` above — `state` only.
313
+ const state = task?.state || r.state;
314
+ if (state === 'completed' || state === 'failed')
291
315
  return 'task-closed';
292
316
  return 'tool-result';
293
317
  }
@@ -462,10 +486,9 @@ export function applyOutcomeToCtx(ctx, outcome) {
462
486
  }
463
487
  case 'task-closed': {
464
488
  // Clear only this task's entry — sibling open tasks keep their books (ZIG-977).
465
- const closedId = outcome.taskId ||
466
- outcome.result?.task?.taskId ||
467
- outcome.result?.taskId ||
468
- ctx.activeTaskId;
489
+ // ZIG-1171: buildOutcomeOfKind already collapses result.task.taskId /
490
+ // result.taskId into outcome.taskId — do not re-walk the raw result here.
491
+ const closedId = outcome.taskId || ctx.activeTaskId;
469
492
  if (closedId)
470
493
  removeTaskBook(ctx, closedId);
471
494
  else {
@@ -49,9 +49,10 @@ export function buildTaskIntakeState(initial) {
49
49
  // Function form so a failed task_set_result surfaces as an error outcome
50
50
  // (→ dflt error transition) instead of a phantom task-closed.
51
51
  produces: (result) => {
52
+ // `state` only — ZIG-1057 cut `status` from the wire (ZIG-1128).
52
53
  const r = result;
53
- const status = r?.task?.state ?? r?.state ?? r?.status;
54
- return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
54
+ const state = r?.task?.state ?? r?.state;
55
+ return state === 'completed' || state === 'failed' ? 'task-closed' : 'error';
55
56
  },
56
57
  terminatesLoop: true,
57
58
  },
@@ -63,9 +64,10 @@ export function buildTaskIntakeState(initial) {
63
64
  },
64
65
  tool: 'task_set_result',
65
66
  produces: (result) => {
67
+ // `state` only — ZIG-1057 cut `status` from the wire (ZIG-1128).
66
68
  const r = result;
67
- const status = r?.task?.state ?? r?.state ?? r?.status;
68
- return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
69
+ const state = r?.task?.state ?? r?.state;
70
+ return state === 'completed' || state === 'failed' ? 'task-closed' : 'error';
69
71
  },
70
72
  terminatesLoop: true,
71
73
  },
@@ -97,6 +97,13 @@ export function createWakeWriteKeys(laneKey, event) {
97
97
  const identity = eventIdentity(event);
98
98
  const wakeHash = identity === null ? null : sha(`${laneKey}\n${identity}`).slice(0, 40);
99
99
  let ordinal = 0;
100
+ // ZIG-1171: one content-hash builder; tags differ, the shape does not.
101
+ const keyed = (tag, material, opts) => {
102
+ if (wakeHash === null)
103
+ return undefined;
104
+ const body = `wk_${wakeHash}_${tag}_${sha(material).slice(0, 16)}`;
105
+ return opts?.asMessageId ? `sdk_idem_${body}` : body;
106
+ };
100
107
  return {
101
108
  wakeKey: wakeHash === null ? null : `wk_${wakeHash}`,
102
109
  artifactKey(content) {
@@ -110,37 +117,23 @@ export function createWakeWriteKeys(laneKey, event) {
110
117
  // agent like agent-definer. Callers without content keep the ordinal.
111
118
  if (content === undefined)
112
119
  return `wk_${wakeHash}_a${ordinal++}`;
113
- const contentHash = sha(typeof content === 'string' ? content : JSON.stringify(content) ?? '').slice(0, 16);
114
- return `wk_${wakeHash}_a_${contentHash}`;
120
+ const material = typeof content === 'string' ? content : JSON.stringify(content) ?? '';
121
+ return keyed('a', material);
115
122
  },
116
123
  taskStateKey(taskId, state) {
117
- if (wakeHash === null)
118
- return undefined;
119
- return `wk_${wakeHash}_t_${sha(`${taskId}\n${state}`).slice(0, 16)}`;
124
+ return keyed('t', `${taskId}\n${state}`);
120
125
  },
121
126
  messageKey(fields) {
122
- if (wakeHash === null)
123
- return undefined;
124
- const msgHash = sha(`${fields.sessionId ?? ''}\n${fields.receiverId ?? ''}\n${fields.text}`).slice(0, 16);
125
- return `sdk_idem_wk_${wakeHash}_m_${msgHash}`;
127
+ return keyed('m', `${fields.sessionId ?? ''}\n${fields.receiverId ?? ''}\n${fields.text}`, { asMessageId: true });
126
128
  },
127
129
  taskSpawnKey(fields) {
128
- if (wakeHash === null)
129
- return undefined;
130
- const spawnHash = sha(`${fields.agreementId}\n${fields.description}\n${fields.assigneeId ?? ''}\n${fields.parentTaskId ?? ''}`).slice(0, 16);
131
- return `wk_${wakeHash}_s_${spawnHash}`;
130
+ return keyed('s', `${fields.agreementId}\n${fields.description}\n${fields.assigneeId ?? ''}\n${fields.parentTaskId ?? ''}`);
132
131
  },
133
132
  agreementProposeKey(fields) {
134
- if (wakeHash === null)
135
- return undefined;
136
- const proposeHash = sha(`${fields.proposedTo}\n${fields.providerId ?? ''}\n${fields.description}\n${fields.parentAgreementId ?? ''}`).slice(0, 16);
137
- return `wk_${wakeHash}_ap_${proposeHash}`;
133
+ return keyed('ap', `${fields.proposedTo}\n${fields.providerId ?? ''}\n${fields.description}\n${fields.parentAgreementId ?? ''}`);
138
134
  },
139
135
  agreementSubcontractKey(fields) {
140
- if (wakeHash === null)
141
- return undefined;
142
- const subHash = sha(`${fields.executorId}\n${fields.description}\n${fields.parentAgreementId}`).slice(0, 16);
143
- return `wk_${wakeHash}_asc_${subHash}`;
136
+ return keyed('asc', `${fields.executorId}\n${fields.description}\n${fields.parentAgreementId}`);
144
137
  },
145
138
  };
146
139
  }