@ziggs-ai/agent-sdk 0.9.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 (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/AgentHost.d.ts +53 -14
  3. package/dist/AgentHost.js +137 -8
  4. package/dist/agent/ClaudeSdkAgent.d.ts +27 -2
  5. package/dist/agent/ClaudeSdkAgent.js +151 -78
  6. package/dist/agent/claudeSdkTools.js +39 -11
  7. package/dist/context/applyEffects.js +4 -3
  8. package/dist/defineAgent.js +4 -1
  9. package/dist/formatters/HistoryFormatter.js +11 -0
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +3 -2
  12. package/dist/ingress/normalizeIncoming.d.ts +5 -0
  13. package/dist/ingress/normalizeIncoming.js +23 -1
  14. package/dist/pricing/fleetDefaults.d.ts +0 -3
  15. package/dist/pricing/fleetDefaults.js +0 -6
  16. package/dist/runtime/PromptBuilder.js +9 -5
  17. package/dist/runtime/bootstrapCtx.js +6 -8
  18. package/dist/runtime/buildOutcome.d.ts +9 -1
  19. package/dist/runtime/buildOutcome.js +29 -6
  20. package/dist/runtime/runTurn.js +1 -1
  21. package/dist/runtime/taskBookkeeping.d.ts +0 -2
  22. package/dist/runtime/taskBookkeeping.js +0 -6
  23. package/dist/runtime/taskIntake.js +6 -4
  24. package/dist/runtime/wakeWriteKeys.js +23 -21
  25. package/dist/server/ConnectionPool.d.ts +10 -52
  26. package/dist/server/ConnectionPool.js +26 -57
  27. package/dist/server/EventQueue.d.ts +6 -0
  28. package/dist/server/EventQueue.js +21 -6
  29. package/dist/server/SeenMessages.d.ts +7 -0
  30. package/dist/server/SeenMessages.js +15 -0
  31. package/dist/server/ZiggsEffectHandler.d.ts +1 -0
  32. package/dist/server/ZiggsEffectHandler.js +21 -11
  33. package/dist/server/inboxLoop.d.ts +17 -2
  34. package/dist/server/inboxLoop.js +92 -16
  35. package/dist/server/polls/PollKind.d.ts +72 -0
  36. package/dist/server/polls/PollKind.js +53 -0
  37. package/dist/server/polls/PollRunner.d.ts +62 -0
  38. package/dist/server/polls/PollRunner.js +138 -0
  39. package/dist/server/questMatch.d.ts +5 -0
  40. package/dist/server/questMatch.js +14 -0
  41. package/dist/server/runLauncher.d.ts +0 -6
  42. package/dist/server/runLauncher.js +54 -27
  43. package/dist/server/tasks/TaskService.d.ts +2 -11
  44. package/dist/server/tools/tier1/protocolRunner.d.ts +3 -1
  45. package/dist/server/tools/tier1/protocolRunner.js +40 -41
  46. package/dist/server/tools/tier1/protocolTools.js +23 -13
  47. package/dist/server/tools/tier2/connectionTools.js +12 -1
  48. package/dist/tasks/protocolRegistry.d.ts +27 -0
  49. package/dist/tasks/protocolRegistry.js +36 -0
  50. package/dist/tools/capabilityAdapter.js +12 -1
  51. package/dist/tools/recordReport.js +8 -4
  52. package/dist/types.d.ts +24 -11
  53. package/package.json +2 -2
  54. package/dist/server/staleBacklogGate.d.ts +0 -57
  55. package/dist/server/staleBacklogGate.js +0 -100
@@ -7,7 +7,13 @@ import { toolFromCapability } from '../../../tools/capabilityAdapter.js';
7
7
  // allowance in the parity table).
8
8
  export const connectionProxyTool = toolFromCapability(connectionProxyCapability);
9
9
  export const requestConnectionTool = toolFromCapability(requestConnectionCapability);
10
- export const connectionListGrantsTool = defineTool('connection_list_grants', { connectionId: { type: 'string', required: true } }, async (args, ctx) => {
10
+ export const connectionListGrantsTool = defineTool('connection_list_grants', {
11
+ connectionId: {
12
+ type: 'string',
13
+ required: true,
14
+ description: 'The stored connection to list grants for.',
15
+ },
16
+ }, async (args, ctx) => {
11
17
  if (!args['connectionId'])
12
18
  throw new Error('connectionId is required');
13
19
  const operatorKey = ctx?.['operatorKey'];
@@ -28,6 +34,11 @@ export const connectionListGrantsTool = defineTool('connection_list_grants', { c
28
34
  wrapped.body = err.body;
29
35
  throw wrapped;
30
36
  }
37
+ }, {
38
+ // ZIG-1128: this shipped with no description at all — the model was handed a
39
+ // name and a parameter and left to guess what the tool was for. Found by the
40
+ // new SDK tool-text guard.
41
+ description: "List the grants issued to this agent on one stored connection. Returns the grantId that connection_proxy needs. Use it when you have a connectionId but not the grantId — grant_list (scopeKind=connection) is the wider read across every connection.",
31
42
  });
32
43
  export const CONNECTION_TOOLS = [
33
44
  connectionProxyTool,
@@ -1,3 +1,23 @@
1
+ /**
2
+ * The protocol verbs and the state operation each one performs.
3
+ *
4
+ * This is the single place the set is written down (ZIG-1110). It used to be
5
+ * restated: the tool definitions passed their operation string to `handlerFor`
6
+ * by hand, and this table listed them again. `agreement_fulfill` was in the
7
+ * first list and not the second, so it dispatched fine but
8
+ * `isProtocolToolName` said no — and at every consumer (applyEffects,
9
+ * routingLabels, buildOutcome) the verb that ends an engagement permanently
10
+ * degraded to an unlabelled plain tool-result whose outcome never reached
11
+ * context.
12
+ *
13
+ * A protocol tool now takes its handler from this table BY TOOL NAME, and a
14
+ * name that is absent throws while the module is loading. Adding a verb in one
15
+ * place can no longer half-register it: the tool cannot be constructed at all
16
+ * until it appears here.
17
+ *
18
+ * This module deliberately imports nothing. It is read from the context and
19
+ * runtime paths, which must not pull in the server tool chain.
20
+ */
1
21
  export declare const PROTOCOL_TOOL_TO_OPERATION: Readonly<{
2
22
  readonly agreement_propose: "agreement-propose";
3
23
  readonly agreement_subcontract: "agreement-subcontract";
@@ -6,6 +26,7 @@ export declare const PROTOCOL_TOOL_TO_OPERATION: Readonly<{
6
26
  readonly agreement_counter: "agreement-counter-proposal";
7
27
  readonly agreement_get: "agreement-check-proposal";
8
28
  readonly agreement_revoke: "agreement-revoke";
29
+ readonly agreement_fulfill: "agreement-fulfill";
9
30
  readonly task_create: "task-spawn";
10
31
  readonly task_set_result: "task-update";
11
32
  readonly task_update_plan_step: "task-update-plan-step";
@@ -15,3 +36,9 @@ export type ProtocolOperation = (typeof PROTOCOL_TOOL_TO_OPERATION)[ProtocolTool
15
36
  export declare const PROTOCOL_TOOL_NAMES: readonly ProtocolToolName[];
16
37
  export declare function mapProtocolToolToOperation(toolName: string): ProtocolOperation | null;
17
38
  export declare function isProtocolToolName(toolName: string): toolName is ProtocolToolName;
39
+ /**
40
+ * The operation a protocol tool performs, or a throw. Called at module load by
41
+ * the tool definitions, so a verb defined without a registry entry takes the
42
+ * import down instead of shipping half-registered.
43
+ */
44
+ export declare function requireProtocolOperation(toolName: string): ProtocolOperation;
@@ -1,3 +1,23 @@
1
+ /**
2
+ * The protocol verbs and the state operation each one performs.
3
+ *
4
+ * This is the single place the set is written down (ZIG-1110). It used to be
5
+ * restated: the tool definitions passed their operation string to `handlerFor`
6
+ * by hand, and this table listed them again. `agreement_fulfill` was in the
7
+ * first list and not the second, so it dispatched fine but
8
+ * `isProtocolToolName` said no — and at every consumer (applyEffects,
9
+ * routingLabels, buildOutcome) the verb that ends an engagement permanently
10
+ * degraded to an unlabelled plain tool-result whose outcome never reached
11
+ * context.
12
+ *
13
+ * A protocol tool now takes its handler from this table BY TOOL NAME, and a
14
+ * name that is absent throws while the module is loading. Adding a verb in one
15
+ * place can no longer half-register it: the tool cannot be constructed at all
16
+ * until it appears here.
17
+ *
18
+ * This module deliberately imports nothing. It is read from the context and
19
+ * runtime paths, which must not pull in the server tool chain.
20
+ */
1
21
  export const PROTOCOL_TOOL_TO_OPERATION = Object.freeze({
2
22
  agreement_propose: 'agreement-propose',
3
23
  agreement_subcontract: 'agreement-subcontract',
@@ -6,6 +26,7 @@ export const PROTOCOL_TOOL_TO_OPERATION = Object.freeze({
6
26
  agreement_counter: 'agreement-counter-proposal',
7
27
  agreement_get: 'agreement-check-proposal',
8
28
  agreement_revoke: 'agreement-revoke',
29
+ agreement_fulfill: 'agreement-fulfill',
9
30
  task_create: 'task-spawn',
10
31
  task_set_result: 'task-update',
11
32
  task_update_plan_step: 'task-update-plan-step',
@@ -19,3 +40,18 @@ export function mapProtocolToolToOperation(toolName) {
19
40
  export function isProtocolToolName(toolName) {
20
41
  return mapProtocolToolToOperation(toolName) != null;
21
42
  }
43
+ /**
44
+ * The operation a protocol tool performs, or a throw. Called at module load by
45
+ * the tool definitions, so a verb defined without a registry entry takes the
46
+ * import down instead of shipping half-registered.
47
+ */
48
+ export function requireProtocolOperation(toolName) {
49
+ const operation = mapProtocolToolToOperation(toolName);
50
+ if (!operation) {
51
+ throw new Error(`Unknown protocol tool "${toolName}": add it to PROTOCOL_TOOL_TO_OPERATION ` +
52
+ '(agent-sdk/src/tasks/protocolRegistry.ts). Every consumer of the protocol ' +
53
+ 'set reads that table — a tool missing from it dispatches but is never ' +
54
+ 'recognised as protocol, so its result never reaches context.');
55
+ }
56
+ return operation;
57
+ }
@@ -10,12 +10,23 @@ export function toolFromCapability(cap) {
10
10
  return defineTool(cap.names.sdk, cap.params, async (args, ctx) => {
11
11
  const operatorKey = ctx?.['operatorKey'];
12
12
  const agentId = ctx?.['agentId'];
13
+ // ZIG-1092 — the lane was already threaded to this exact point by
14
+ // ZIG-775's shared executor (`chatId: effect.sessionId` in
15
+ // executeToolWithSideEffects); it just was not used to narrow anything.
16
+ // Reading it here makes the fence brain-agnostic by construction: the
17
+ // Claude brain and the FSM brain both arrive through this adapter.
18
+ const laneId = ctx?.['chatId'] ||
19
+ ctx?.['sessionId'];
13
20
  if (!operatorKey)
14
21
  throw new Error('operatorKey missing from tool context');
15
22
  if (cap.needsAgentId && !agentId)
16
23
  throw new Error('agentId missing from tool context');
17
24
  const env = {
18
- creds: { operatorKey, ...(agentId ? { agentId } : {}) },
25
+ creds: {
26
+ operatorKey,
27
+ ...(agentId ? { agentId } : {}),
28
+ ...(laneId ? { laneId } : {}),
29
+ },
19
30
  // BACKEND_URL / ZIGGS_BACKEND_URL are the SDK runner's overrides; the
20
31
  // shared clients default to HTTP_URL (api-client convention) otherwise.
21
32
  baseUrl: process.env.BACKEND_URL || process.env.ZIGGS_BACKEND_URL || undefined,
@@ -12,10 +12,14 @@ export async function recordReport(input) {
12
12
  const { creds, taskId, report, chatId, agreementId, deliverable } = input;
13
13
  // ZIG-1037: no container needed. The deliverable is bound to its task, which
14
14
  // is what makes it retrievable as `via=task:<taskId>` and readable by the
15
- // task's parties — a chat or agreement was never what made that work. Both
16
- // containers at once IS still a mistake (only one takes effect), so resolve it
17
- // here the way artifact_record does rather than letting the client throw
18
- // at the last step of a finished task.
15
+ // task's parties — a chat or agreement was never what made that work.
16
+ //
17
+ // ZIG-1075: the agreement still wins here, and this is now the ONE place that
18
+ // resolves rather than refuses — deliberately, because the caller is not a
19
+ // model choosing arguments. `recordReport` is handed both ids by the runtime
20
+ // (the task's agreement and the chat it is running in), so there is no
21
+ // mistake to surface and nobody to retry: refusing would fail a finished
22
+ // task's delivery over a pair of ids it did not choose.
19
23
  const scope = agreementId ? { agreementId } : chatId ? { chatId } : {};
20
24
  // Deliverable FIRST, then close the task.
21
25
  //
package/dist/types.d.ts CHANGED
@@ -187,15 +187,6 @@ export interface Ctx {
187
187
  /** Focus agreement — sync'd with tasks[activeTaskId] when a task is focused. */
188
188
  activeAgreementId: string | null;
189
189
  pendingProposalAgreementId: string | null;
190
- /**
191
- * @deprecated ZIG-1057 — unused mirrors; always `{}` / `[]` after bootstrap.
192
- * Read via `allPendingSubtasks` / `allCollectedResults` / `allDelegatedAgreementIds`.
193
- */
194
- pendingSubtasks: Record<string, PendingSubtask>;
195
- /** @deprecated ZIG-1057 — see `pendingSubtasks`. */
196
- collectedResults: CollectedSubtaskResult[];
197
- /** @deprecated ZIG-1057 — see `pendingSubtasks`. */
198
- delegatedAgreementIds: string[];
199
190
  toolResults: ToolResultEntry[];
200
191
  lastOutcome: Outcome;
201
192
  /** Claude Agent SDK session id for resume across ticks (claude-sdk cognition only). */
@@ -385,8 +376,15 @@ export interface RecordedEntry {
385
376
  export interface WakeWriteKeys {
386
377
  /** Stable id of this wake, or null when its trigger has no durable delivery identity (timer/synthetic frames) — null means writes go keyless, as before. */
387
378
  readonly wakeKey: string | null;
388
- /** Key for the next artifact write of this wake. Draws a per-wake ordinal — call exactly once per write, at the call site. */
389
- artifactKey(): string | undefined;
379
+ /**
380
+ * Key for an artifact write of this wake. Pass the artifact's content so the
381
+ * key is derived from it (ZIG-1045 semantics): a wake writing several
382
+ * artifacts gets several keys, and a redelivered wake that re-writes the same
383
+ * content reuses its key instead of colliding on an ordinal slot and being
384
+ * discarded. Omitting content falls back to a per-wake ordinal — call exactly
385
+ * once per write in that case.
386
+ */
387
+ artifactKey(content?: unknown): string | undefined;
390
388
  /** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
391
389
  taskStateKey(taskId: string, state: string): string | undefined;
392
390
  /**
@@ -512,7 +510,19 @@ export interface ServicesConfig {
512
510
  }
513
511
  export interface DefineAgentInput {
514
512
  agentId: string;
513
+ /**
514
+ * Human-facing one-liner: what this agent does, for a buyer reading the store
515
+ * row. NOT the system prompt — a claude-sdk agent puts its prompt in
516
+ * `systemPrompt` (ZIG-1080: every claude-sdk agent used to pass its prompt
517
+ * here, and `deriveAgentRecord` published it straight to the store).
518
+ */
515
519
  description: string;
520
+ /**
521
+ * Claude-brain only: the agent's system prompt (its `## Your role` block).
522
+ * Falls back to `description` when absent, which is what every FSM agent
523
+ * relies on. Never surfaced in the store.
524
+ */
525
+ systemPrompt?: string;
516
526
  specialization?: string;
517
527
  initial: string;
518
528
  states: Record<string, State>;
@@ -534,7 +544,10 @@ export interface AgentConfig {
534
544
  anthropicKey: string | undefined;
535
545
  operatorKey?: string;
536
546
  agentId: string;
547
+ /** Store-facing one-liner. See DefineAgentInput.description. */
537
548
  description: string;
549
+ /** Claude-brain system prompt; falls back to `description`. */
550
+ systemPrompt?: string;
538
551
  specialization?: string;
539
552
  tools: unknown[];
540
553
  model: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "@anthropic-ai/claude-agent-sdk": "^0.3.204",
27
27
  "@anthropic-ai/sdk": "^0.110.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
29
- "@ziggs-ai/api-client": "^0.8.0",
29
+ "@ziggs-ai/api-client": "^0.9.1",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",
@@ -1,57 +0,0 @@
1
- /**
2
- * Launcher startup backlog policy (ZIG-910).
3
- *
4
- * "Work waits" has no statute of limitations on the backend — the inbox never
5
- * expires anything. But when a fleet comes up after a dark period, replaying
6
- * every weeks-old actionable slice means a mass of LLM turns on stale work at
7
- * real token cost. The consumer chooses what to pick up: this gate keeps the
8
- * operator-poll lazy start from resurrecting agents whose newest actionable
9
- * item is older than a cutoff. Skips are never silent — one loud aggregated
10
- * summary line announces what was left down and how to opt back in.
11
- *
12
- * Env:
13
- * FLEET_REPLAY_MAX_AGE cutoff, e.g. "3d" | "12h" | "30m" | bare number =
14
- * days (default "3d")
15
- * FLEET_REPLAY_STALE=1 replay everything (disable the gate)
16
- *
17
- * Scope is the launcher only. The backend inbox is untouched, explicit
18
- * `pool.startAgent()` / preStart are untouched, and an agent with ANY fresh item
19
- * starts normally (its own inbox loop then drains the stale backlog too).
20
- */
21
- import type { InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
22
- export declare const DEFAULT_REPLAY_MAX_AGE_MS: number;
23
- export interface ReplayPolicy {
24
- /** Skip slices whose newest actionable item is older than this. */
25
- maxAgeMs: number;
26
- /** FLEET_REPLAY_STALE=1 — replay everything, gate disabled. */
27
- replayAll: boolean;
28
- }
29
- /**
30
- * "3d" | "12h" | "30m" | "45s"; a bare number means days (the knob's natural
31
- * granularity). Returns null on garbage so the caller can warn and fall back.
32
- */
33
- export declare function parseReplayMaxAge(raw: string): number | null;
34
- export declare function resolveReplayPolicy(env?: NodeJS.ProcessEnv): ReplayPolicy;
35
- /**
36
- * Newest actionable instant for an agent, from its operator-sweep entry.
37
- *
38
- * The sweep hands this over directly now — it is the agent's delivery
39
- * watermark — where this used to fold together the max timestamp across every
40
- * channel of a whole envelope. An agent surfaced only for open work carries no
41
- * delivery instant: age is unprovable, so it is treated as fresh and started.
42
- * Stale-skipping a half-finished task would strand it.
43
- */
44
- export declare function newestActionableAt(entry: InboxOperatorAgentEntry): number | null;
45
- export declare function formatAge(ms: number): string;
46
- export interface StaleBacklogGateOptions extends ReplayPolicy {
47
- label?: string;
48
- now?: () => number;
49
- log?: (line: string) => void;
50
- }
51
- /**
52
- * Build the `shouldLazyStart` gate for the operator poll. Skipped agents
53
- * resurface in every envelope (reading never acks), so announcements are
54
- * per-agent-once: each poll pass that skips agents not yet announced emits one
55
- * aggregated warn line covering them.
56
- */
57
- export declare function createStaleBacklogGate({ maxAgeMs, replayAll, label, now, log, }: StaleBacklogGateOptions): (entry: InboxOperatorAgentEntry) => boolean;
@@ -1,100 +0,0 @@
1
- import { runtimeLog } from '../shared/runtimeLog.js';
2
- export const DEFAULT_REPLAY_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
3
- /**
4
- * "3d" | "12h" | "30m" | "45s"; a bare number means days (the knob's natural
5
- * granularity). Returns null on garbage so the caller can warn and fall back.
6
- */
7
- export function parseReplayMaxAge(raw) {
8
- const m = /^\s*(\d+(?:\.\d+)?)\s*([dhms]?)\s*$/i.exec(raw);
9
- if (!m)
10
- return null;
11
- const value = Number(m[1]);
12
- if (!Number.isFinite(value) || value <= 0)
13
- return null;
14
- const unitMs = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1_000 }[(m[2] || 'd').toLowerCase()];
15
- return value * unitMs;
16
- }
17
- export function resolveReplayPolicy(env = process.env) {
18
- const replayAll = env.FLEET_REPLAY_STALE === '1' || env.FLEET_REPLAY_STALE === 'true';
19
- const raw = env.FLEET_REPLAY_MAX_AGE?.trim();
20
- let maxAgeMs = DEFAULT_REPLAY_MAX_AGE_MS;
21
- if (raw) {
22
- const parsed = parseReplayMaxAge(raw);
23
- if (parsed == null) {
24
- runtimeLog.warn('staleBacklogGate', `FLEET_REPLAY_MAX_AGE="${raw}" is not a duration (use e.g. "3d", "12h", "30m") — using default ${formatAge(DEFAULT_REPLAY_MAX_AGE_MS)}`);
25
- }
26
- else {
27
- maxAgeMs = parsed;
28
- }
29
- }
30
- return { maxAgeMs, replayAll };
31
- }
32
- /**
33
- * Newest actionable instant for an agent, from its operator-sweep entry.
34
- *
35
- * The sweep hands this over directly now — it is the agent's delivery
36
- * watermark — where this used to fold together the max timestamp across every
37
- * channel of a whole envelope. An agent surfaced only for open work carries no
38
- * delivery instant: age is unprovable, so it is treated as fresh and started.
39
- * Stale-skipping a half-finished task would strand it.
40
- */
41
- export function newestActionableAt(entry) {
42
- if (!entry.deliveredUpTo)
43
- return null;
44
- const t = Date.parse(entry.deliveredUpTo);
45
- return Number.isNaN(t) ? null : t;
46
- }
47
- export function formatAge(ms) {
48
- if (ms >= 86_400_000)
49
- return `${trimNum(ms / 86_400_000)}d`;
50
- if (ms >= 3_600_000)
51
- return `${trimNum(ms / 3_600_000)}h`;
52
- if (ms >= 60_000)
53
- return `${trimNum(ms / 60_000)}m`;
54
- return `${trimNum(ms / 1_000)}s`;
55
- }
56
- function trimNum(n) {
57
- return Number(n.toFixed(1)).toString();
58
- }
59
- /**
60
- * Build the `shouldLazyStart` gate for the operator poll. Skipped agents
61
- * resurface in every envelope (reading never acks), so announcements are
62
- * per-agent-once: each poll pass that skips agents not yet announced emits one
63
- * aggregated warn line covering them.
64
- */
65
- export function createStaleBacklogGate({ maxAgeMs, replayAll, label = 'launcher', now = Date.now, log, }) {
66
- const emit = log ?? ((line) => runtimeLog.warn(label, line));
67
- const announced = new Set();
68
- let pendingAgents = [];
69
- let flushScheduled = false;
70
- // One envelope's entries are gated synchronously; a microtask-deferred
71
- // flush turns a whole boot flood into a single summary line.
72
- const flush = () => {
73
- flushScheduled = false;
74
- if (pendingAgents.length === 0)
75
- return;
76
- const preview = pendingAgents.slice(0, 5).join(', ');
77
- const more = pendingAgents.length > 5 ? `, +${pendingAgents.length - 5} more` : '';
78
- emit(`startup backlog policy: skipped ${pendingAgents.length} agent(s) whose newest mail is older than ${formatAge(maxAgeMs)} (${preview}${more}) — set FLEET_REPLAY_STALE=1 to replay them`);
79
- pendingAgents = [];
80
- };
81
- return (entry) => {
82
- if (replayAll)
83
- return true;
84
- // Open work always starts, however old: the task is unfinished, not stale.
85
- if (entry.hasOpenTasks)
86
- return true;
87
- const newest = newestActionableAt(entry);
88
- if (newest == null || now() - newest <= maxAgeMs)
89
- return true;
90
- if (!announced.has(entry.agentId)) {
91
- announced.add(entry.agentId);
92
- pendingAgents.push(entry.agentId);
93
- if (!flushScheduled) {
94
- flushScheduled = true;
95
- queueMicrotask(flush);
96
- }
97
- }
98
- return false;
99
- };
100
- }